pycontrails 0.58.0__cp314-cp314-macosx_11_0_arm64.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,1075 @@
1
+ """Support for humidity scaling methodologies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ import contextlib
7
+ import dataclasses
8
+ import functools
9
+ import pathlib
10
+ import sys
11
+ import warnings
12
+ from typing import Any, NoReturn, overload
13
+
14
+ if sys.version_info >= (3, 12):
15
+ from typing import override
16
+ else:
17
+ from typing_extensions import override
18
+
19
+ import numpy as np
20
+ import numpy.typing as npt
21
+ import pandas as pd
22
+ import xarray as xr
23
+
24
+ from pycontrails.core import models
25
+ from pycontrails.core.met import MetDataArray, MetDataset
26
+ from pycontrails.core.vector import GeoVectorDataset
27
+ from pycontrails.physics import constants, thermo, units
28
+ from pycontrails.utils.types import ArrayLike
29
+
30
+
31
+ def _rhi_over_q(air_temperature: ArrayLike, air_pressure: ArrayLike) -> ArrayLike:
32
+ """Compute the quotient ``RHi / q``."""
33
+
34
+ # Keep the air_temperature term before the air_pressure term
35
+ # If these are xr.DataArray, we want the return value to have the same coords
36
+ # as the temperature. If air_pressure comes first, the coords will be transposed.
37
+ return (constants.R_v / constants.R_d) / thermo.e_sat_ice(air_temperature) * air_pressure
38
+
39
+
40
+ class HumidityScaling(models.Model):
41
+ """Support for standardizing humidity scaling methodologies.
42
+
43
+ The method :meth:`scale` or :meth:`eval` should be called immediately
44
+ after interpolation over meteorology data.
45
+
46
+ .. versionadded:: 0.27.0
47
+ """
48
+
49
+ #: Variables required in addition to specific_humidity, air_temperature, and air_pressure
50
+ #: These are either :class:`ModelParams` specific to scaling, or variables that should
51
+ #: be extracted from :meth:`eval` parameter ``source``.
52
+ scaler_specific_keys: tuple[str, ...] = ()
53
+
54
+ @property
55
+ @abc.abstractmethod
56
+ def formula(self) -> str:
57
+ """Serializable formula for humidity scaler."""
58
+
59
+ @property
60
+ def description(self) -> dict[str, Any]:
61
+ """Get description for instance."""
62
+ params = {k: v for k in self.scaler_specific_keys if (v := self.params.get(k)) is not None}
63
+ return {"name": self.name, "formula": self.formula, **params}
64
+
65
+ # Used by pycontrails.utils.json.NumpyEncoder
66
+ # This ensures model parameters are serializable to JSON
67
+ to_json = description
68
+
69
+ @abc.abstractmethod
70
+ def scale(
71
+ self,
72
+ specific_humidity: ArrayLike,
73
+ air_temperature: ArrayLike,
74
+ air_pressure: ArrayLike,
75
+ **kwargs: ArrayLike,
76
+ ) -> tuple[ArrayLike, ArrayLike]:
77
+ r"""Compute scaled specific humidity and RHi.
78
+
79
+ See docstring for the implementing subclass for specific methodology.
80
+
81
+ Parameters
82
+ ----------
83
+ specific_humidity : ArrayLike
84
+ Unscaled specific relative humidity, [:math:`kg \ kg^{-1}`]. Typically,
85
+ this is interpolated meteorology data.
86
+ air_temperature : ArrayLike
87
+ Air temperature, [:math:`K`]. Typically, this is interpolated meteorology
88
+ data.
89
+ air_pressure : ArrayLike
90
+ Pressure, [:math:`Pa`]
91
+ kwargs : ArrayLike
92
+ Other keyword-only variables and model parameters used by the formula.
93
+
94
+ Returns
95
+ -------
96
+ specific_humidity : ArrayLike
97
+ Scaled specific humidity.
98
+ rhi : ArrayLike
99
+ Scaled relative humidity over ice.
100
+
101
+ See Also
102
+ --------
103
+ :meth:`eval`
104
+ """
105
+
106
+ @overload
107
+ def eval(self, source: GeoVectorDataset, **params: Any) -> GeoVectorDataset: ...
108
+
109
+ @overload
110
+ def eval(self, source: MetDataset, **params: Any) -> MetDataset: ...
111
+
112
+ @overload
113
+ def eval(self, source: None = ..., **params: Any) -> NoReturn: ...
114
+
115
+ def eval(
116
+ self, source: GeoVectorDataset | MetDataset | None = None, **params: Any
117
+ ) -> GeoVectorDataset | MetDataset:
118
+ """Scale specific humidity values on ``source``.
119
+
120
+ This method mutates the parameter ``source`` by modifying its
121
+ "specific_humidity" variable and by attaching an "rhi" variable. Set
122
+ model parameter ``copy_source=True`` to avoid mutating ``source``.
123
+
124
+ Parameters
125
+ ----------
126
+ source : GeoVectorDataset | MetDataset
127
+ Data with variables "specific_humidity", "air_temperature",
128
+ and any variables defined by :attr:`scaler_specific_keys`.
129
+ **params : Any
130
+ Overwrite model parameters before eval
131
+
132
+ Returns
133
+ -------
134
+ GeoVectorDataset | MetDataset
135
+ Source data with updated "specific_humidity" and "rhi". If ``source``
136
+ is :class:`GeoVectorDataset`, "air_pressure" data is also attached.
137
+
138
+ See Also
139
+ --------
140
+ :meth:`scale`
141
+ """
142
+ self.update_params(params)
143
+ if source is None:
144
+ raise TypeError("Parameter source must be GeoVectorDataset or MetDataset")
145
+ self.set_source(source)
146
+
147
+ if "rhi" in self.source:
148
+ warnings.warn(
149
+ "Variable 'rhi' already found on source to be scaled. This "
150
+ "is unexpected and may be the result of humidity scaling "
151
+ "being applied more than once."
152
+ )
153
+
154
+ p: np.ndarray | xr.DataArray
155
+ if isinstance(self.source, GeoVectorDataset):
156
+ p = self.source.setdefault("air_pressure", self.source.air_pressure)
157
+ else:
158
+ p = self.source.data["air_pressure"]
159
+
160
+ q = self.source.data["specific_humidity"]
161
+ T = self.source.data["air_temperature"]
162
+ kwargs = self._scale_kwargs()
163
+
164
+ q, rhi = self.scale(q, T, p, **kwargs)
165
+ self.source.update(specific_humidity=q, rhi=rhi)
166
+
167
+ return self.source
168
+
169
+ def _scale_kwargs(self) -> dict[str, Any]:
170
+ return {k: self.get_source_param(k) for k in self.scaler_specific_keys}
171
+
172
+
173
+ @dataclasses.dataclass
174
+ class ConstantHumidityScalingParams(models.ModelParams):
175
+ """Parameters for :class:`ConstantHumidityScaling`."""
176
+
177
+ #: Scale specific humidity by dividing it with adjustment factor per
178
+ #: :cite:`schumannContrailCirrusPrediction2012` eq. (9). Set to a constant
179
+ #: 0.9 in :cite:`schumannContrailCirrusPrediction2012` to account for sub-scale
180
+ #: variability of specific humidity. A value of 1.0 provides no scaling.
181
+ rhi_adj: float = 0.97
182
+
183
+
184
+ class ConstantHumidityScaling(HumidityScaling):
185
+ """Scale specific humidity by applying a constant uniform scaling.
186
+
187
+ This scalar simply applies the transformation..
188
+
189
+ rhi -> rhi / rhi_adj
190
+
191
+ where ``rhi_adj`` is a constant specified by :attr:`params` or overridden by
192
+ a variable or attribute on ``source`` in :meth:`eval`.
193
+
194
+ The convention to divide by ``rhi_adj`` instead of considering the more natural
195
+ product ``rhi_adj * rhi`` is somewhat arbitrary. In short, ``rhi_adj`` can be
196
+ thought of as the critical threshold for supersaturation.
197
+
198
+ References
199
+ ----------
200
+ - :cite:`schumannContrailCirrusPrediction2012`
201
+ - :cite:`reutterIceSupersaturatedRegions2020`
202
+ """
203
+
204
+ name = "constant_scale"
205
+ long_name = "Constant humidity scaling"
206
+ formula = "rhi -> rhi / rhi_adj"
207
+ default_params = ConstantHumidityScalingParams
208
+ scaler_specific_keys = ("rhi_adj",)
209
+
210
+ @override
211
+ def scale(
212
+ self,
213
+ specific_humidity: ArrayLike,
214
+ air_temperature: ArrayLike,
215
+ air_pressure: ArrayLike,
216
+ **kwargs: Any,
217
+ ) -> tuple[ArrayLike, ArrayLike]:
218
+ rhi_adj = kwargs.get("rhi_adj", self.params["rhi_adj"])
219
+ q = specific_humidity / rhi_adj
220
+ rhi = thermo.rhi(q, air_temperature, air_pressure)
221
+ return q, rhi
222
+
223
+
224
+ @dataclasses.dataclass
225
+ class ExponentialBoostHumidityScalingParams(ConstantHumidityScalingParams):
226
+ """Parameters for :class:`ExponentialBoostHumidityScaling`."""
227
+
228
+ #: Boost RHi values exceeding 1 as described in :cite:`teohAviationContrailClimate2022`.
229
+ #: In :meth:`eval`, this can be overridden by a keyword argument with the same name.
230
+ rhi_boost_exponent: float = 1.7
231
+
232
+ #: Used to clip overinflated unrealistic RHi values.
233
+ clip_upper: float = 1.7
234
+
235
+
236
+ class ExponentialBoostHumidityScaling(HumidityScaling):
237
+ """Scale humidity by composing constant scaling with exponential boosting.
238
+
239
+ This formula composes the transformations
240
+
241
+ #. constant scaling: ``rhi -> rhi / rhi_adj``
242
+ #. exponential boosting: ``rhi -> rhi ^ rhi_boost_exponent if rhi > 1``
243
+ #. clipping: ``rhi -> min(rhi, clip_upper)``
244
+
245
+ where ``rhi_adj``, ``rhi_boost_exponent``, and ``clip_upper`` are model :attr:`params`.
246
+
247
+ References
248
+ ----------
249
+ - :cite:`teohAviationContrailClimate2022`
250
+
251
+ See Also
252
+ --------
253
+ :class:`ExponentialBoostLatitudeCorrectionHumidityScaling`
254
+ """
255
+
256
+ name = "exponential_boost"
257
+ long_name = "Constant humidity scaling composed with exponential boosting"
258
+ formula = "rhi -> (rhi / rhi_adj) ^ rhi_boost_exponent"
259
+ default_params = ExponentialBoostHumidityScalingParams
260
+ scaler_specific_keys = "rhi_adj", "rhi_boost_exponent", "clip_upper"
261
+
262
+ @override
263
+ def scale(
264
+ self,
265
+ specific_humidity: ArrayLike,
266
+ air_temperature: ArrayLike,
267
+ air_pressure: ArrayLike,
268
+ **kwargs: Any,
269
+ ) -> tuple[ArrayLike, ArrayLike]:
270
+ # Get coefficients
271
+ rhi_adj = kwargs["rhi_adj"]
272
+ rhi_boost_exponent = kwargs["rhi_boost_exponent"]
273
+ clip_upper = kwargs["clip_upper"]
274
+
275
+ # Compute uncorrected RHi
276
+ rhi_over_q = _rhi_over_q(air_temperature, air_pressure)
277
+ rhi = specific_humidity * rhi_over_q
278
+
279
+ # Correct RHi
280
+ rhi /= rhi_adj
281
+
282
+ # Find ISSRs
283
+ is_issr = rhi >= 1.0
284
+
285
+ # Apply boosting to ISSRs
286
+ if isinstance(rhi, xr.DataArray):
287
+ rhi = rhi.where(~is_issr, rhi**rhi_boost_exponent)
288
+ rhi = rhi.clip(max=clip_upper)
289
+
290
+ else:
291
+ boosted_rhi = rhi[is_issr] ** rhi_boost_exponent
292
+ boosted_rhi.clip(max=clip_upper, out=boosted_rhi)
293
+ rhi[is_issr] = boosted_rhi
294
+
295
+ # Recompute specific_humidity from corrected rhi
296
+ specific_humidity = rhi / rhi_over_q
297
+
298
+ # Return the pair
299
+ return specific_humidity, rhi
300
+
301
+
302
+ @dataclasses.dataclass
303
+ class _SigmoidCoefficients:
304
+ """Coefficients for a sigmoid function.
305
+
306
+ These coefficients are used in the following manner::
307
+
308
+ a_opt = a1 / (1 + np.exp(a3 * (lat - a4))) + a2
309
+ b_opt = b1 / (1 + np.exp(b3 * (lat - b4))) + b2
310
+
311
+ """
312
+
313
+ a1: float
314
+ a2: float
315
+ a3: float
316
+ a4: float
317
+ b1: float
318
+ b2: float
319
+ b3: float
320
+ b4: float
321
+
322
+
323
+ @functools.cache
324
+ def _load_sigmoid_coef(q_method: str | None, level_type: str) -> _SigmoidCoefficients:
325
+ available = ("pressure", "model")
326
+ if level_type not in available:
327
+ msg = f"Invalid 'level_type' value '{level_type}'. Must be one of {available}."
328
+ raise ValueError(msg)
329
+
330
+ if level_type == "model":
331
+ if q_method is not None:
332
+ msg = "Parameter 'q_method' must be None for model-level data."
333
+ raise ValueError(msg)
334
+ return _SigmoidCoefficients(
335
+ a1=0.026302083473778933,
336
+ a2=0.9651041666272301,
337
+ a3=2.250107435038734,
338
+ a4=36.549393263544914,
339
+ b1=0.489062500000000095,
340
+ b2=2.210937499999999,
341
+ b3=4.182743010484767,
342
+ b4=17.53378893219587,
343
+ )
344
+
345
+ if q_method is None:
346
+ return _SigmoidCoefficients(
347
+ a1=0.062621,
348
+ a2=0.95224,
349
+ a3=0.45893,
350
+ a4=39.254,
351
+ b1=1.4706,
352
+ b2=1.4325,
353
+ b3=0.44312,
354
+ b4=18.755,
355
+ )
356
+ if q_method == "cubic-spline":
357
+ return _SigmoidCoefficients(
358
+ a1=0.056999,
359
+ a2=0.93418,
360
+ a3=1.7135,
361
+ a4=35.894,
362
+ b1=1.4709,
363
+ b2=1.4312,
364
+ b3=0.55845,
365
+ b4=21.549,
366
+ )
367
+ raise NotImplementedError(f"Unsupported q_method: {q_method}")
368
+
369
+
370
+ @dataclasses.dataclass
371
+ class ExponentialBoostLatitudeCorrectionParams(models.ModelParams):
372
+ """Parameters for :class:`ExponentialBoostLatitudeCorrectionHumidityScaling`."""
373
+
374
+ #: The ERA5 vertical level type. Must be one of ``"pressure"`` or ``"model"``.
375
+ level_type: str = "pressure"
376
+
377
+
378
+ class ExponentialBoostLatitudeCorrectionHumidityScaling(HumidityScaling):
379
+ """Correct RHi values derived from ECMWF ERA5 HRES.
380
+
381
+ Unlike other RHi correction factors, this function applies a custom latitude-based
382
+ term and has been tuned for global application.
383
+
384
+ This formula composes the transformations
385
+
386
+ #. constant scaling: ``rhi -> rhi / rhi_adj``
387
+ #. exponential boosting: ``rhi -> rhi ^ rhi_boost_exponent if rhi > 1``
388
+ #. clipping: ``rhi -> min(rhi, rhi_max)``
389
+
390
+ where ``rhi_adj`` and ``rhi_boost_exponent`` depend on ``latitude`` to minimize
391
+ error between ERA5 HRES and IAGOS in-situ data.
392
+
393
+ For each waypoint, ``rhi_max`` ensures that the corrected RHi does not exceed the
394
+ maximum value according to thermodynamics:
395
+
396
+ - ``rhi_max = p_liq(T) / p_ice(T)`` for ``T > 235 K``,
397
+ (Pruppacher and Klett, 1997)
398
+ - ``rhi_max = 1.67 + (1.45 - 1.67) * (T - 190.) / (235. - 190.)`` for ``T < 235 K``
399
+ (Karcher and Lohmann, 2002; Tompkins et al., 2007)
400
+
401
+ The RHi correction addresses the known limitations of the ERA5 HRES humidity fields,
402
+ ensuring that the ISSR coverage area and RHi-distribution is consistent with in-situ
403
+ measurements from the IAGOS dataset. Generally, the correction:
404
+
405
+ #. reduces the ISSR coverage area near the equator,
406
+ #. increases the ISSR coverage area at higher latitudes, and
407
+ #. accounts for localized regions with very high ice supersaturation (RHi > 120%).
408
+
409
+ This methodology is an extension of Teoh et al. (2022) and has not yet been
410
+ peer-reviewed/published.
411
+
412
+ The ERA5 HRES <> IAGOS fitting uses a sigmoid curve to capture significant
413
+ changes in tropopause height at 20 - 50 degrees latitude.
414
+
415
+ The method :meth:`eval` requires a ``latitude`` keyword argument.
416
+
417
+ References
418
+ ----------
419
+ - :cite:`teohAviationContrailClimate2022`
420
+ - Kärcher, B. and Lohmann, U., 2002. A parameterization of cirrus cloud formation: Homogeneous
421
+ freezing of supercooled aerosols. Journal of Geophysical Research: Atmospheres, 107(D2),
422
+ pp.AAC-4.
423
+ - Pruppacher, H.R. and Klett, J.D. (1997) Microphysics of Clouds and Precipitation. 2nd Edition,
424
+ Kluwer Academic, Dordrecht, 954 p.
425
+ - Tompkins, A.M., Gierens, K. and Rädel, G., 2007. Ice supersaturation in the ECMWF integrated
426
+ forecast system. Quarterly Journal of the Royal Meteorological Society: A journal of the
427
+ atmospheric sciences, applied meteorology and physical oceanography, 133(622), pp.53-63.
428
+
429
+ See Also
430
+ --------
431
+ :class:`ExponentialBoostHumidityScaling`
432
+ """
433
+
434
+ name = "exponential_boost_latitude_customization"
435
+ long_name = "Latitude specific humidity scaling composed with exponential boosting"
436
+ formula = "rhi -> (rhi / rhi_adj) ^ rhi_boost_exponent"
437
+ default_params = ExponentialBoostLatitudeCorrectionParams
438
+ scaler_specific_keys = ("latitude",)
439
+
440
+ def __init__(
441
+ self,
442
+ met: MetDataset | None = None,
443
+ params: dict[str, Any] | None = None,
444
+ **params_kwargs: Any,
445
+ ) -> None:
446
+ if (params is None or "level_type" not in params) and ("level_type" not in params_kwargs):
447
+ msg = (
448
+ "The default level_type will change from 'pressure' to 'model' "
449
+ "in a future release. To silence this warning, provide a 'level_type' "
450
+ "value when instantiating ExponentialBoostLatitudeCorrectionHumidityScaling."
451
+ )
452
+ warnings.warn(msg, DeprecationWarning)
453
+ super().__init__(met, params, **params_kwargs)
454
+
455
+ def _scale_kwargs(self) -> dict[str, Any]:
456
+ q_method = self.params["interpolation_q_method"]
457
+ level_type = self.params["level_type"]
458
+ return {**super()._scale_kwargs(), "q_method": q_method, "level_type": level_type}
459
+
460
+ @override
461
+ def scale(
462
+ self,
463
+ specific_humidity: ArrayLike,
464
+ air_temperature: ArrayLike,
465
+ air_pressure: ArrayLike,
466
+ **kwargs: Any,
467
+ ) -> tuple[ArrayLike, ArrayLike]:
468
+ # Get sigmoid coefficients
469
+ q_method = kwargs["q_method"]
470
+ level_type = kwargs["level_type"]
471
+ coef = _load_sigmoid_coef(q_method, level_type)
472
+
473
+ # Use the dtype of specific_humidity to determine the precision of the
474
+ # the calculation. If working with gridded data here, latitude will have
475
+ # float64 precision.
476
+ latitude = kwargs["latitude"]
477
+ if latitude.dtype != specific_humidity.dtype:
478
+ latitude = latitude.astype(specific_humidity.dtype)
479
+ lat_abs = np.abs(latitude)
480
+
481
+ # Compute uncorrected RHi
482
+ rhi_over_q = _rhi_over_q(air_temperature, air_pressure)
483
+ rhi = specific_humidity * rhi_over_q
484
+
485
+ # Calculate the rhi_adj factor and correct RHi
486
+ rhi_adj = coef.a1 / (1.0 + np.exp(coef.a3 * (lat_abs - coef.a4))) + coef.a2
487
+ rhi /= rhi_adj
488
+
489
+ # Find ISSRs
490
+ is_issr = rhi >= 1.0
491
+
492
+ # Limit RHi to maximum value allowed by physics
493
+ rhi_max = _calc_rhi_max(air_temperature)
494
+
495
+ # Apply boosting only to ISSRs
496
+ if isinstance(rhi, xr.DataArray):
497
+ boost_exp = coef.b1 / (1.0 + np.exp(coef.b3 * (lat_abs - coef.b4))) + coef.b2
498
+ rhi = rhi.where(~is_issr, rhi**boost_exp)
499
+ rhi = rhi.clip(max=rhi_max)
500
+
501
+ else:
502
+ boost_exp = coef.b1 / (1.0 + np.exp(coef.b3 * (lat_abs[is_issr] - coef.b4))) + coef.b2
503
+ rhi[is_issr] = rhi[is_issr] ** boost_exp
504
+ rhi.clip(max=rhi_max, out=rhi)
505
+
506
+ # Recompute specific_humidity from corrected rhi
507
+ specific_humidity = rhi / rhi_over_q
508
+
509
+ # Return the pair
510
+ return specific_humidity, rhi
511
+
512
+
513
+ def _calc_rhi_max(air_temperature: ArrayLike) -> ArrayLike:
514
+ p_ice: ArrayLike
515
+ p_liq: ArrayLike
516
+
517
+ if isinstance(air_temperature, xr.DataArray):
518
+ p_ice = thermo.e_sat_ice(air_temperature)
519
+ p_liq = thermo.e_sat_liquid(air_temperature)
520
+ return xr.where(
521
+ air_temperature < 235.0,
522
+ 1.67 + (1.45 - 1.67) * (air_temperature - 190.0) / (235.0 - 190.0),
523
+ p_liq / p_ice,
524
+ )
525
+
526
+ low = air_temperature < 235.0
527
+ air_temperature_low = air_temperature[low]
528
+ air_temperature_high = air_temperature[~low]
529
+
530
+ p_ice = thermo.e_sat_ice(air_temperature_high)
531
+ p_liq = thermo.e_sat_liquid(air_temperature_high)
532
+
533
+ out = np.empty_like(air_temperature)
534
+ out[low] = 1.67 + (1.45 - 1.67) * (air_temperature_low - 190.0) / (235.0 - 190.0)
535
+ out[~low] = p_liq / p_ice
536
+ return out
537
+
538
+
539
+ @dataclasses.dataclass
540
+ class HumidityScalingByLevelParams(models.ModelParams):
541
+ """Parameters for :class:`HumidityScalingByLevel`."""
542
+
543
+ #: Fraction of troposphere for mid-troposphere humidity scaling.
544
+ #: Default value suggested in :cite:`schumannContrailCirrusPrediction2012`.
545
+ rhi_adj_mid_troposphere: float = 0.8
546
+
547
+ #: Fraction of troposphere for stratosphere humidity scaling.
548
+ #: Default value suggested in :cite:`schumannContrailCirrusPrediction2012`.
549
+ rhi_adj_stratosphere: float = 1.0
550
+
551
+ #: Adjustment factor for mid-troposphere humidity scaling. Default value
552
+ #: of 0.8 taken from :cite:`schumannContrailCirrusPrediction2012`.
553
+ mid_troposphere_threshold: float = 0.8
554
+
555
+ #: Adjustment factor for stratosphere humidity scaling. Default value
556
+ #: of 1.0 taken from :cite:`schumannContrailCirrusPrediction2012`.
557
+ stratosphere_threshold: float = 1.0
558
+
559
+
560
+ class HumidityScalingByLevel(HumidityScaling):
561
+ """Apply custom scaling to specific_humidity by pressure level.
562
+
563
+ This implements the original humidity scaling scheme suggested in
564
+ :cite:`schumannContrailCirrusPrediction2012`. In particular, see eq. (9)
565
+ and the surrounding text, quoted below.
566
+
567
+ Hence, the critical value RHic is usually taken different and below 100%
568
+ in NWP models. In the ECMWF model, this value is..
569
+
570
+ RHic = 0.8, (9)
571
+
572
+ in the mid-troposphere, 1.0 in the stratosphere and follows
573
+ a smooth transition with pressure altitude between these two
574
+ values in the upper 20 % of the troposphere. For simplicity
575
+ of further analysis, we divide the input value of q by RHic
576
+ initially.
577
+
578
+ See :class:`ConstantHumidityScaling` for the simple method described
579
+ above.
580
+
581
+ The diagram below shows the piecewise-linear ``rhi_adj`` factor by
582
+ level. In particular, ``rhi_adj`` is constant at the stratosphere and above,
583
+ linearly changes from the mid-troposphere to the stratosphere, and is
584
+ constant at the mid-troposphere and below.
585
+
586
+ ::
587
+
588
+ _________ stratosphere rhi_adj = 1.0
589
+ /
590
+ /
591
+ /
592
+ _________/ mid-troposphere rhi_adj = 0.8
593
+
594
+ References
595
+ ----------
596
+ - :cite:`schumannContrailCirrusPrediction2012`
597
+ """
598
+
599
+ name = "constant_scale_by_level"
600
+ long_name = "Constant humidity scaling by level"
601
+ formula = "rhi -> rhi / rhi_adj"
602
+ default_params = HumidityScalingByLevelParams
603
+ scaler_specific_keys = (
604
+ "rhi_adj_mid_troposphere",
605
+ "rhi_adj_stratosphere",
606
+ "mid_troposphere_threshold",
607
+ "stratosphere_threshold",
608
+ )
609
+
610
+ @override
611
+ def scale(
612
+ self,
613
+ specific_humidity: ArrayLike,
614
+ air_temperature: ArrayLike,
615
+ air_pressure: ArrayLike,
616
+ **kwargs: Any,
617
+ ) -> tuple[ArrayLike, ArrayLike]:
618
+ rhi_adj_mid_troposphere = kwargs["rhi_adj_mid_troposphere"]
619
+ rhi_adj_stratosphere = kwargs["rhi_adj_stratosphere"]
620
+ mid_troposphere_threshold = kwargs["mid_troposphere_threshold"]
621
+ stratosphere_threshold = kwargs["stratosphere_threshold"]
622
+
623
+ thresholds = np.array([stratosphere_threshold, mid_troposphere_threshold])
624
+ xp = units.m_to_pl(constants.h_tropopause * thresholds)
625
+
626
+ # np.interp expects a sorted parameter `xp`
627
+ # The calculation will get bungled if this isn't the case
628
+ if xp[0] > xp[1]:
629
+ raise ValueError(
630
+ "Attribute 'stratosphere_threshold' must exceed "
631
+ "attribute 'mid_troposphere_threshold'."
632
+ )
633
+
634
+ level = air_pressure / 100.0
635
+ fp = [rhi_adj_stratosphere, rhi_adj_mid_troposphere]
636
+ rhi_adj = np.interp(level, xp=xp, fp=fp)
637
+
638
+ q = specific_humidity / rhi_adj
639
+ rhi = thermo.rhi(q, air_temperature, air_pressure)
640
+ return q, rhi
641
+
642
+
643
+ @functools.cache
644
+ def _load_quantiles(level_type: str) -> pd.DataFrame:
645
+ """Load precomputed ERA5 and IAGOS quantiles.
646
+
647
+ Parameters
648
+ ----------
649
+ level_type : {"pressure", "model"}
650
+ Select whether to load precomputed quantiles from pressure- vs
651
+ model-level ERA5 data.
652
+
653
+ Returns
654
+ -------
655
+ pd.DataFrame
656
+ A DataFrame with 801 rows and 34 columns. The index is parameterized
657
+ by the quantile, and the columns are parameterized by the met source
658
+ and the interpolation methodology. The IAOGS quantiles are in the
659
+ ``("iagos", "iagos")`` column.
660
+ """
661
+ path = pathlib.Path(__file__).parent / "quantiles" / f"era5-{level_type}-level-quantiles.pq"
662
+ df = pd.read_parquet(path)
663
+ df.attrs["path"] = str(path)
664
+ return df
665
+
666
+
667
+ def histogram_matching(
668
+ era5_rhi: ArrayLike,
669
+ product_type: str,
670
+ level_type: str,
671
+ member: int | None,
672
+ q_method: str | None,
673
+ ) -> npt.NDArray[np.floating]:
674
+ """Map ERA5-derived RHi to its corresponding IAGOS quantile via histogram matching.
675
+
676
+ This matching is performed on a **single** ERA5 ensemble member.
677
+
678
+ Parameters
679
+ ----------
680
+ era5_rhi : ArrayLike
681
+ ERA5-derived RHi values for the given ensemble member.
682
+ product_type : {"reanalysis", "ensemble_members"}
683
+ The ERA5 product type.
684
+ level_type : {"pressure", "model"}
685
+ Select whether to perform quantile mapping based on quantiles from
686
+ pressure- or model-level ERA5 data. Selecting ``level_type == "model"``
687
+ when ``product_type == "ensemble_members"`` will produce a warning
688
+ and change ``product_type`` to ``"reanalysis"``.
689
+ member : int | None
690
+ The ERA5 ensemble member to use. Must be in the range ``[0, 10)``.
691
+ Only used if ``product_type == "ensemble_members"``.
692
+ q_method : {None, "cubic-spline", "log-q-log-p"}
693
+ The interpolation method.
694
+
695
+ Returns
696
+ -------
697
+ npt.NDArray[np.floating]
698
+ The IAGOS quantiles corresponding to the ERA5-derived RHi values. Returned
699
+ as a numpy array with the same shape and dtype as ``era5_rhi``.
700
+ """
701
+ if level_type not in ["pressure", "model"]:
702
+ msg = f"Invalid 'level_type' value '{level_type}'. Must be one of ['pressure', 'model']."
703
+ raise ValueError(msg)
704
+ df = _load_quantiles(level_type)
705
+ iagos_quantiles = df[("iagos", "iagos")]
706
+
707
+ if product_type == "ensemble_members" and level_type == "model":
708
+ msg = (
709
+ "No quantiles available for model-level ensemble data. "
710
+ "Switching to product_type = 'reanalysis'."
711
+ )
712
+ warnings.warn(msg)
713
+ product_type = "reanalysis"
714
+
715
+ if product_type == "ensemble_members":
716
+ col = f"ensemble{member}", q_method or "linear-q"
717
+ elif product_type == "reanalysis":
718
+ col = "reanalysis", q_method or "linear-q"
719
+ else:
720
+ msg = (
721
+ f"Invalid 'product_type' value '{product_type}'. "
722
+ "Must be one of ['reanalysis', 'ensemble_members']."
723
+ )
724
+ raise ValueError(msg)
725
+
726
+ try:
727
+ era5_quantiles = df[col]
728
+ except KeyError:
729
+ assert q_method is not None
730
+ models.raise_invalid_q_method_error(q_method)
731
+
732
+ out = np.interp(era5_rhi, era5_quantiles, iagos_quantiles)
733
+
734
+ # Preserve the dtype (np.interp returns float64)
735
+ return out.astype(era5_rhi.dtype, copy=False)
736
+
737
+
738
+ def histogram_matching_all_members(
739
+ era5_rhi_all_members: npt.NDArray[np.floating], member: int, q_method: str
740
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
741
+ """Recalibrate ERA5-derived RHi values to IAGOS quantiles by histogram matching.
742
+
743
+ This recalibration requires values for **all** ERA5 ensemble members. Currently, the
744
+ number of ensemble members is hard-coded to 10.
745
+
746
+ Parameters
747
+ ----------
748
+ era5_rhi_all_members : npt.NDArray[np.floating]
749
+ ERA5-derived RHi values for all ensemble members. This array should have shape ``(n, 10)``.
750
+ member : int
751
+ The ERA5 ensemble member to use. Must be in the range ``[0, 10)``.
752
+ q_method : {None, "cubic-spline", "log-q-log-p"}
753
+ The interpolation method.
754
+
755
+ Returns
756
+ -------
757
+ ensemble_mean_rhi : npt.NDArray[np.floating]
758
+ The mean RHi values after histogram matching over all ensemble members.
759
+ This is an array of shape ``(n,)``.
760
+ ensemble_member_rhi : npt.NDArray[np.floating]
761
+ The RHi values after histogram matching for the given ensemble member.
762
+ This is an array of shape ``(n,)``.
763
+ """
764
+
765
+ n_members = 10
766
+ assert era5_rhi_all_members.shape[1] == n_members
767
+
768
+ # Perform histogram matching on the given ensemble member
769
+ ensemble_member_rhi = histogram_matching(
770
+ era5_rhi_all_members[:, member], "ensemble_members", "pressure", member, q_method
771
+ )
772
+
773
+ # Perform histogram matching on all other ensemble members
774
+ # Add up the results into a single 'ensemble_mean_rhi' array
775
+ ensemble_mean_rhi: npt.NDArray[np.floating] = 0.0 # type: ignore[assignment]
776
+ for r in range(n_members):
777
+ if r == member:
778
+ ensemble_mean_rhi += ensemble_member_rhi
779
+ else:
780
+ ensemble_mean_rhi += histogram_matching(
781
+ era5_rhi_all_members[:, r], "ensemble_members", "pressure", r, q_method
782
+ )
783
+
784
+ # Divide by the number of ensemble members to get the mean
785
+ ensemble_mean_rhi /= float(n_members)
786
+
787
+ return ensemble_mean_rhi, ensemble_member_rhi
788
+
789
+
790
+ def eckel_scaling(
791
+ ensemble_mean_rhi: npt.NDArray[np.floating],
792
+ ensemble_member_rhi: npt.NDArray[np.floating],
793
+ q_method: str,
794
+ ) -> npt.NDArray[np.floating]:
795
+ """Apply Eckel scaling to the given RHi values.
796
+
797
+ Parameters
798
+ ----------
799
+ ensemble_mean_rhi : npt.NDArray[np.floating]
800
+ The ensemble mean RHi values. This should be a 1D array with the same shape as
801
+ ``ensemble_member_rhi``.
802
+ ensemble_member_rhi : npt.NDArray[np.floating]
803
+ The RHi values for a single ensemble member.
804
+ q_method : {None, "cubic-spline", "log-q-log-p"}
805
+ The interpolation method.
806
+
807
+ Returns
808
+ -------
809
+ npt.NDArray[np.floating]
810
+ The scaled RHi values. Values are manually clipped at 0 to ensure
811
+ only non-negative values are returned.
812
+
813
+ References
814
+ ----------
815
+ :cite:`eckelCalibratedProbabilisticQuantitative1998`
816
+ """
817
+
818
+ # https://journals.ametsoc.org/view/journals/wefo/27/1/waf-d-11-00015_1.xml
819
+ # https://doi.org/10.1016/B978-0-12-812372-0.00003-0
820
+
821
+ if q_method is None:
822
+ eckel_a = -6.365384483974193e-05
823
+ eckel_c = 2.731157095387021
824
+ elif q_method == "cubic-spline":
825
+ eckel_a = -6.268024189244961e-05
826
+ eckel_c = 2.6843757126334302
827
+ elif q_method == "log-q-log-p":
828
+ eckel_a = -5.8690498260424506e-05
829
+ eckel_c = 2.679501356493337
830
+ else:
831
+ models.raise_invalid_q_method_error(q_method)
832
+
833
+ out = (ensemble_mean_rhi - eckel_a) + eckel_c * (ensemble_member_rhi - ensemble_mean_rhi)
834
+ out.clip(min=0.0, out=out)
835
+ return out
836
+
837
+
838
+ @dataclasses.dataclass
839
+ class HistogramMatchingParams(models.ModelParams):
840
+ """Parameters for :class:`HistogramMatching`."""
841
+
842
+ #: The ERA5 product. Must be one of ``"reanalysis"`` or ``"ensemble_members"``.
843
+ product_type: str = "reanalysis"
844
+
845
+ #: The ERA5 vertical level type. Must be one of ``"pressure"`` or ``"model"``.
846
+ level_type: str = "pressure"
847
+
848
+ #: The ERA5 ensemble member to use. Must be in the range ``[0, 10)``.
849
+ #: Only used if ``product_type`` is ``"ensemble_members"``.
850
+ member: int | None = None
851
+
852
+
853
+ class HistogramMatching(HumidityScaling):
854
+ """Scale humidity by histogram matching to IAGOS RHi quantiles."""
855
+
856
+ name = "histogram_matching"
857
+ long_name = "IAGOS RHi histogram matching"
858
+ formula = "era5_quantiles -> iagos_quantiles"
859
+ default_params = HistogramMatchingParams
860
+
861
+ def __init__(
862
+ self,
863
+ met: MetDataset | None = None,
864
+ params: dict[str, Any] | None = None,
865
+ **params_kwargs: Any,
866
+ ) -> None:
867
+ if (params is None or "level_type" not in params) and (
868
+ params_kwargs is None or "level_type" not in params_kwargs
869
+ ):
870
+ msg = (
871
+ "The default level_type will change from 'pressure' to 'model' "
872
+ "in a future release. To silence this warning, "
873
+ "provide a 'level_type' value when instantiating HistogramMatching."
874
+ )
875
+ warnings.warn(msg, DeprecationWarning)
876
+ super().__init__(met, params, **params_kwargs)
877
+
878
+ @override
879
+ def scale(
880
+ self,
881
+ specific_humidity: ArrayLike,
882
+ air_temperature: ArrayLike,
883
+ air_pressure: ArrayLike,
884
+ **kwargs: Any,
885
+ ) -> tuple[ArrayLike, ArrayLike]:
886
+ rhi_over_q = _rhi_over_q(air_temperature, air_pressure)
887
+ rhi = rhi_over_q * specific_humidity
888
+
889
+ rhi_1 = histogram_matching(
890
+ rhi,
891
+ self.params["product_type"],
892
+ self.params["level_type"],
893
+ self.params["member"],
894
+ self.params["interpolation_q_method"],
895
+ )
896
+
897
+ # If the input is an xarray DataArray, return an xarray DataArray
898
+ if isinstance(rhi, xr.DataArray):
899
+ rhi_1 = xr.DataArray( # type: ignore[assignment]
900
+ rhi_1,
901
+ dims=rhi.dims,
902
+ coords=rhi.coords,
903
+ name="rhi",
904
+ attrs=rhi.attrs,
905
+ )
906
+
907
+ q_1 = rhi_1 / rhi_over_q
908
+
909
+ return q_1, rhi_1 # type: ignore[return-value]
910
+
911
+
912
+ @dataclasses.dataclass
913
+ class HistogramMatchingWithEckelParams(models.ModelParams):
914
+ """Parameters for :class:`HistogramMatchingWithEckel`.
915
+
916
+ .. warning::
917
+ Experimental. This may change or be removed in a future release.
918
+ """
919
+
920
+ #: A length-10 list of ERA5 ensemble members.
921
+ #: Each element is a :class:`MetDataArray` holding specific humidity
922
+ #: values for a single ensemble member. If None, a ValueError will be
923
+ #: raised at model instantiation time. The order of the list must be
924
+ #: consistent with the order of the ERA5 ensemble members.
925
+ ensemble_specific_humidity: list[MetDataArray] | None = None
926
+
927
+ #: The specific member used. Must be in the range [0, 10). If None,
928
+ #: a ValueError will be raised at model instantiation time.
929
+ member: int | None = None
930
+
931
+ #: If a log transform has already been applied to each member of
932
+ #: ``ensemble_specific_humidity``, set this to True.
933
+ log_applied: bool = False
934
+
935
+
936
+ class HistogramMatchingWithEckel(HumidityScaling):
937
+ """Scale humidity by histogram matching to IAGOS RHi quantiles.
938
+
939
+ This method also applies the Eckel scaling to the recalibrated RHi values.
940
+
941
+ Unlike other specific humidity scaling methods, this method requires met data
942
+ and performs interpolation at evaluation time.
943
+
944
+ .. warning::
945
+ Experimental. This may change or be removed in a future release.
946
+
947
+ References
948
+ ----------
949
+ :cite:`eckelCalibratedProbabilisticQuantitative1998`
950
+ """
951
+
952
+ name = "histogram_matching_with_eckel"
953
+ long_name = "IAGOS RHi histogram matching with Eckel scaling"
954
+ formula = "era5_quantiles -> iagos_quantiles -> recalibrated_rhi"
955
+ default_params = HistogramMatchingWithEckelParams
956
+
957
+ n_members = 10 # hard-coded elsewhere
958
+
959
+ def _validate_params(self) -> None:
960
+ member = self.params["member"]
961
+ assert member in range(self.n_members)
962
+
963
+ ensemble_specific_humidity = self.params["ensemble_specific_humidity"]
964
+ assert len(ensemble_specific_humidity) == self.n_members
965
+ for member, mda in enumerate(ensemble_specific_humidity):
966
+ with contextlib.suppress(KeyError):
967
+ assert mda.data["number"] == member
968
+
969
+ @overload
970
+ def eval(self, source: GeoVectorDataset, **params: Any) -> GeoVectorDataset: ...
971
+
972
+ @overload
973
+ def eval(self, source: MetDataset | None = ..., **params: Any) -> NoReturn: ...
974
+
975
+ def eval(
976
+ self, source: GeoVectorDataset | MetDataset | None = None, **params: Any
977
+ ) -> GeoVectorDataset | MetDataset:
978
+ """Scale specific humidity by histogram matching to IAGOS RHi quantiles.
979
+
980
+ This method assumes ``source`` is equipped with the following variables:
981
+
982
+ - air_temperature
983
+ - specific_humidity: Humidity values for the ``params["member"]`` ERA5 ensemble member.
984
+ """
985
+
986
+ self.update_params(params)
987
+ self._validate_params()
988
+ self.set_source(source)
989
+ self.source = self.require_source_type(GeoVectorDataset)
990
+
991
+ if "rhi" in self.source:
992
+ warnings.warn(
993
+ "Variable 'rhi' already found on source to be scaled. This "
994
+ "is unexpected and may be the result of humidity scaling "
995
+ "being applied more than once."
996
+ )
997
+
998
+ # Create a 2D array of specific humidity values for all ensemble members
999
+ # The specific humidity values for the current member are taken from the source
1000
+ # This matches patterns used in other humidity scaling methods
1001
+ # The remaining values are interpolated from the ERA5 ensemble members
1002
+ q = self.source.data["specific_humidity"]
1003
+ q2d = np.empty((len(self.source), self.n_members), dtype=q.dtype)
1004
+
1005
+ interp_kwargs = self.interp_kwargs
1006
+ q_method = interp_kwargs.pop("q_method")
1007
+
1008
+ ensemble_specific_humidity: list[MetDataArray] = self.params["ensemble_specific_humidity"]
1009
+ member: int = self.params["member"]
1010
+ log_applied: bool = self.params["log_applied"]
1011
+
1012
+ for i, mda in enumerate(ensemble_specific_humidity):
1013
+ if i == member:
1014
+ q2d[:, i] = q
1015
+ continue
1016
+
1017
+ q2d[:, i] = models.interpolate_gridded_specific_humidity(
1018
+ mda, self.source, q_method, log_applied, **interp_kwargs
1019
+ )
1020
+
1021
+ p = self.source.setdefault("air_pressure", self.source.air_pressure)
1022
+ T = self.source.data["air_temperature"]
1023
+
1024
+ q, rhi = self.scale(q2d, T, p)
1025
+ self.source.update(specific_humidity=q, rhi=rhi)
1026
+
1027
+ return self.source
1028
+
1029
+ @override
1030
+ def scale( # type: ignore[override]
1031
+ self,
1032
+ specific_humidity: npt.NDArray[np.floating],
1033
+ air_temperature: npt.NDArray[np.floating],
1034
+ air_pressure: npt.NDArray[np.floating],
1035
+ **kwargs: Any,
1036
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
1037
+ """Scale specific humidity values via histogram matching and Eckel scaling.
1038
+
1039
+ Unlike the method on the base class, the method assumes each of the input
1040
+ arrays are :class:`np.ndarray` and not :class:`xr.DataArray` objects.
1041
+
1042
+ Parameters
1043
+ ----------
1044
+ specific_humidity : npt.NDArray[np.floating]
1045
+ A 2D array of specific humidity values for all ERA5 ensemble members.
1046
+ The shape of this array must be ``(n, 10)``, where ``n`` is the number
1047
+ of observations and ``10`` is the number of ERA5 ensemble members.
1048
+ air_temperature : npt.NDArray[np.floating]
1049
+ A 1D array of air temperature values with shape ``(n,)``.
1050
+ air_pressure : npt.NDArray[np.floating]
1051
+ A 1D array of air pressure values with shape ``(n,)``.
1052
+ kwargs: Any
1053
+ Unused, kept for compatibility with the base class.
1054
+
1055
+ Returns
1056
+ -------
1057
+ specific_humidity : npt.NDArray[np.floating]
1058
+ The recalibrated specific humidity values. A 1D array with shape ``(n,)``.
1059
+ rhi : npt.NDArray[np.floating]
1060
+ The recalibrated RHi values. A 1D array with shape ``(n,)``.
1061
+ """
1062
+
1063
+ rhi_over_q = _rhi_over_q(air_temperature, air_pressure)
1064
+ rhi = rhi_over_q[:, np.newaxis] * specific_humidity
1065
+
1066
+ q_method = self.params["interpolation_q_method"]
1067
+
1068
+ ensemble_mean_rhi, ensemble_member_rhi = histogram_matching_all_members(
1069
+ rhi, self.params["member"], q_method
1070
+ )
1071
+ rhi_1 = eckel_scaling(ensemble_mean_rhi, ensemble_member_rhi, q_method)
1072
+
1073
+ q_1 = rhi_1 / rhi_over_q
1074
+
1075
+ return q_1, rhi_1