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.
- pycontrails/__init__.py +70 -0
- pycontrails/_version.py +34 -0
- pycontrails/core/__init__.py +30 -0
- pycontrails/core/aircraft_performance.py +679 -0
- pycontrails/core/airports.py +228 -0
- pycontrails/core/cache.py +889 -0
- pycontrails/core/coordinates.py +174 -0
- pycontrails/core/fleet.py +483 -0
- pycontrails/core/flight.py +2185 -0
- pycontrails/core/flightplan.py +228 -0
- pycontrails/core/fuel.py +140 -0
- pycontrails/core/interpolation.py +702 -0
- pycontrails/core/met.py +2931 -0
- pycontrails/core/met_var.py +387 -0
- pycontrails/core/models.py +1321 -0
- pycontrails/core/polygon.py +549 -0
- pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
- pycontrails/core/vector.py +2249 -0
- pycontrails/datalib/__init__.py +12 -0
- pycontrails/datalib/_met_utils/metsource.py +746 -0
- pycontrails/datalib/ecmwf/__init__.py +73 -0
- pycontrails/datalib/ecmwf/arco_era5.py +345 -0
- pycontrails/datalib/ecmwf/common.py +114 -0
- pycontrails/datalib/ecmwf/era5.py +554 -0
- pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
- pycontrails/datalib/ecmwf/hres.py +804 -0
- pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
- pycontrails/datalib/ecmwf/ifs.py +287 -0
- pycontrails/datalib/ecmwf/model_levels.py +435 -0
- pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
- pycontrails/datalib/ecmwf/variables.py +268 -0
- pycontrails/datalib/geo_utils.py +261 -0
- pycontrails/datalib/gfs/__init__.py +28 -0
- pycontrails/datalib/gfs/gfs.py +656 -0
- pycontrails/datalib/gfs/variables.py +104 -0
- pycontrails/datalib/goes.py +757 -0
- pycontrails/datalib/himawari/__init__.py +27 -0
- pycontrails/datalib/himawari/header_struct.py +266 -0
- pycontrails/datalib/himawari/himawari.py +667 -0
- pycontrails/datalib/landsat.py +589 -0
- pycontrails/datalib/leo_utils/__init__.py +5 -0
- pycontrails/datalib/leo_utils/correction.py +266 -0
- pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
- pycontrails/datalib/leo_utils/search.py +250 -0
- pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
- pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
- pycontrails/datalib/leo_utils/vis.py +59 -0
- pycontrails/datalib/sentinel.py +650 -0
- pycontrails/datalib/spire/__init__.py +5 -0
- pycontrails/datalib/spire/exceptions.py +62 -0
- pycontrails/datalib/spire/spire.py +604 -0
- pycontrails/ext/bada.py +42 -0
- pycontrails/ext/cirium.py +14 -0
- pycontrails/ext/empirical_grid.py +140 -0
- pycontrails/ext/synthetic_flight.py +431 -0
- pycontrails/models/__init__.py +1 -0
- pycontrails/models/accf.py +425 -0
- pycontrails/models/apcemm/__init__.py +8 -0
- pycontrails/models/apcemm/apcemm.py +983 -0
- pycontrails/models/apcemm/inputs.py +226 -0
- pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
- pycontrails/models/apcemm/utils.py +437 -0
- pycontrails/models/cocip/__init__.py +29 -0
- pycontrails/models/cocip/cocip.py +2742 -0
- pycontrails/models/cocip/cocip_params.py +305 -0
- pycontrails/models/cocip/cocip_uncertainty.py +291 -0
- pycontrails/models/cocip/contrail_properties.py +1530 -0
- pycontrails/models/cocip/output_formats.py +2270 -0
- pycontrails/models/cocip/radiative_forcing.py +1260 -0
- pycontrails/models/cocip/radiative_heating.py +520 -0
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
- pycontrails/models/cocip/wake_vortex.py +396 -0
- pycontrails/models/cocip/wind_shear.py +120 -0
- pycontrails/models/cocipgrid/__init__.py +9 -0
- pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
- pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
- pycontrails/models/dry_advection.py +602 -0
- pycontrails/models/emissions/__init__.py +21 -0
- pycontrails/models/emissions/black_carbon.py +599 -0
- pycontrails/models/emissions/emissions.py +1353 -0
- pycontrails/models/emissions/ffm2.py +336 -0
- pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
- pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
- pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
- pycontrails/models/extended_k15.py +1327 -0
- pycontrails/models/humidity_scaling/__init__.py +37 -0
- pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
- pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
- pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
- pycontrails/models/issr.py +210 -0
- pycontrails/models/pcc.py +326 -0
- pycontrails/models/pcr.py +154 -0
- pycontrails/models/ps_model/__init__.py +18 -0
- pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
- pycontrails/models/ps_model/ps_grid.py +701 -0
- pycontrails/models/ps_model/ps_model.py +1000 -0
- pycontrails/models/ps_model/ps_operational_limits.py +525 -0
- pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
- pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
- pycontrails/models/sac.py +442 -0
- pycontrails/models/tau_cirrus.py +183 -0
- pycontrails/physics/__init__.py +1 -0
- pycontrails/physics/constants.py +117 -0
- pycontrails/physics/geo.py +1138 -0
- pycontrails/physics/jet.py +968 -0
- pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
- pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
- pycontrails/physics/thermo.py +551 -0
- pycontrails/physics/units.py +472 -0
- pycontrails/py.typed +0 -0
- pycontrails/utils/__init__.py +1 -0
- pycontrails/utils/dependencies.py +66 -0
- pycontrails/utils/iteration.py +13 -0
- pycontrails/utils/json.py +187 -0
- pycontrails/utils/temp.py +50 -0
- pycontrails/utils/types.py +163 -0
- pycontrails-0.58.0.dist-info/METADATA +180 -0
- pycontrails-0.58.0.dist-info/RECORD +122 -0
- pycontrails-0.58.0.dist-info/WHEEL +6 -0
- pycontrails-0.58.0.dist-info/licenses/LICENSE +178 -0
- pycontrails-0.58.0.dist-info/licenses/NOTICE +43 -0
- 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
|