pycontrails 0.53.0__cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

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

Potentially problematic release.


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

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