Skip to content

Commit 8b134fa

Browse files
committed
Merge remote-tracking branch 'upstream/master' into improver1538_tabular_ingestion_functions
* upstream/master: Generate calibrated forecasts from EMOS with an alternative percentile set (metoppv#1587) MOBT-77: Weather symbols to represent an extended period (metoppv#1552) Workaround for slow scipy truncnorm by using the version from 1.3.3 (metoppv#1576) Support for a static additional predictor within the EMOS plugins (metoppv#1564) Fix negative grid spacing (metoppv#1583) Increase leniency of EMOS application (metoppv#1577)
2 parents 8896e72 + ca028e3 commit 8b134fa

25 files changed

Lines changed: 1726 additions & 256 deletions

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ below:
5555
* Daniel Mentiplay (Bureau of Meteorology, Australia)
5656
* Stephen Moseley (Met Office, UK)
5757
* Meabh NicGuidhir (Met Office, UK)
58+
* Carwyn Pelley (Met Office, UK)
5859
* Tim Pillinger (Met Office, UK)
5960
* Fiona Rust (Met Office, UK)
6061
* Chris Sampson (Met Office, UK)

improver/calibration/ensemble_calibration.py

Lines changed: 247 additions & 84 deletions
Large diffs are not rendered by default.

improver/calibration/utilities.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,29 @@ def merge_land_and_sea(calibrated_land_only: Cube, uncalibrated: Cube) -> None:
285285
calibrated_land_only.data = new_data
286286

287287

288+
def _ceiling_fp(cube: Cube) -> np.ndarray:
289+
"""Find the forecast period points rounded up to the next hour.
290+
291+
Args:
292+
cube:
293+
Cube with a forecast_period coordinate.
294+
295+
Returns:
296+
The forecast period points in units of hours after
297+
rounding the points up to the next hour.
298+
"""
299+
coord = cube.coord("forecast_period").copy()
300+
coord.convert_units("hours")
301+
return np.ceil(coord.points)
302+
303+
288304
def forecast_coords_match(first_cube: Cube, second_cube: Cube) -> None:
289305
"""
290-
Determine if two cubes have equivalent forecast_periods and that the hours
291-
of the forecast_reference_time coordinates match. Only the point of the
292-
forecast reference time coordinate is checked to ensure that a calibration
293-
/ coefficient cube matches the forecast cube, as appropriate.
306+
Determine if two cubes have equivalent forecast_periods and
307+
forecast_reference_time coordinates with an accepted leniency.
308+
The forecast period is rounded up to the next hour to
309+
support calibrating subhourly forecasts with coefficients taken from on
310+
the hour. For forecast reference time, only the hour is checked.
294311
295312
Args:
296313
first_cube:
@@ -302,8 +319,8 @@ def forecast_coords_match(first_cube: Cube, second_cube: Cube) -> None:
302319
ValueError: The two cubes are not equivalent.
303320
"""
304321
mismatches = []
305-
if first_cube.coord("forecast_period") != second_cube.coord("forecast_period"):
306-
mismatches.append("forecast_period")
322+
if _ceiling_fp(first_cube) != _ceiling_fp(second_cube):
323+
mismatches.append("rounded forecast_period hours")
307324

308325
if get_frt_hours(first_cube.coord("forecast_reference_time")) != get_frt_hours(
309326
second_cube.coord("forecast_reference_time")

improver/cli/apply_emos_coefficients.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def process(
5656
random_seed: int = None,
5757
ignore_ecc_bounds=False,
5858
predictor="mean",
59+
percentiles: cli.comma_separated_list = None,
5960
):
6061
"""Applying coefficients for Ensemble Model Output Statistics.
6162
@@ -108,6 +109,8 @@ def process(
108109
the location parameter when estimating the EMOS coefficients.
109110
Currently the ensemble mean ("mean") and the ensemble
110111
realizations ("realizations") are supported as the predictors.
112+
percentiles (List[float]):
113+
The set of percentiles used to create the calibrated forecast.
111114
112115
Returns:
113116
iris.cube.Cube:
@@ -139,7 +142,7 @@ def process(
139142
msg = "The land_sea_mask cube does not have the name 'land_binary_mask'"
140143
raise ValueError(msg)
141144

142-
calibration_plugin = ApplyEMOS()
145+
calibration_plugin = ApplyEMOS(percentiles=percentiles)
143146
result = calibration_plugin(
144147
cube,
145148
coefficients,

improver/cli/wxcode.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,6 @@ def process(
7575
from improver.wxcode.weather_symbols import WeatherSymbols
7676

7777
if not cubes:
78-
raise RuntimeError(
79-
"Not enough input arguments. " "See help for more information."
80-
)
78+
raise RuntimeError("Not enough input arguments. See help for more information.")
8179

8280
return WeatherSymbols(wxtree, model_id_attr=model_id_attr)(CubeList(cubes))

improver/cli/wxcode_modal.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
# -----------------------------------------------------------------------------
4+
# (C) British Crown Copyright 2017-2021 Met Office.
5+
# All rights reserved.
6+
#
7+
# Redistribution and use in source and binary forms, with or without
8+
# modification, are permitted provided that the following conditions are met:
9+
#
10+
# * Redistributions of source code must retain the above copyright notice, this
11+
# list of conditions and the following disclaimer.
12+
#
13+
# * Redistributions in binary form must reproduce the above copyright notice,
14+
# this list of conditions and the following disclaimer in the documentation
15+
# and/or other materials provided with the distribution.
16+
#
17+
# * Neither the name of the copyright holder nor the names of its
18+
# contributors may be used to endorse or promote products derived from
19+
# this software without specific prior written permission.
20+
#
21+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
24+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
25+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
26+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
27+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
28+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
29+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
30+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31+
# POSSIBILITY OF SUCH DAMAGE.
32+
"""CLI to generate modal weather symbols over periods."""
33+
34+
from improver import cli
35+
36+
37+
@cli.clizefy
38+
@cli.with_output
39+
def process(*cubes: cli.inputcube):
40+
"""Generates a modal weather symbol for the period covered by the input
41+
weather symbol cubes. Where there are different weather codes available
42+
for night and day, the modal code returned is always a day code, regardless
43+
of the times covered by the input files.
44+
45+
Args:
46+
cubes (iris.cube.CubeList):
47+
A cubelist containing weather symbols cubes that cover the period
48+
over which a modal symbol is desired.
49+
50+
Returns:
51+
iris.cube.Cube:
52+
A cube of modal weather symbols over a period.
53+
"""
54+
from improver.wxcode.modal_code import ModalWeatherCode
55+
56+
if not cubes:
57+
raise RuntimeError("Not enough input arguments. See help for more information.")
58+
59+
return ModalWeatherCode()(cubes)
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# -*- coding: utf-8 -*-
2+
# -----------------------------------------------------------------------------
3+
# (C) British Crown Copyright 2017-2021 Met Office.
4+
# All rights reserved.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice, this
10+
# list of conditions and the following disclaimer.
11+
#
12+
# * Redistributions in binary form must reproduce the above copyright notice,
13+
# this list of conditions and the following disclaimer in the documentation
14+
# and/or other materials provided with the distribution.
15+
#
16+
# * Neither the name of the copyright holder nor the names of its
17+
# contributors may be used to endorse or promote products derived from
18+
# this software without specific prior written permission.
19+
#
20+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
24+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30+
# POSSIBILITY OF SUCH DAMAGE.
31+
"""
32+
This module defines the truncnorm as per scipy v1.3.3 to overcome performance
33+
issue introduced in later versions:
34+
- https://github.com/scipy/scipy/issues/12370
35+
- https://github.com/scipy/scipy/issues/12733
36+
37+
"""
38+
import numpy as np
39+
import scipy.special as sc
40+
from scipy.stats._distn_infrastructure import rv_continuous
41+
42+
# ============================================================================
43+
# | Copyright SciPy |
44+
# | Code from this point unto the termination banner is copyright SciPy. |
45+
# | |
46+
# | Copyright © 2001, 2002 Enthought, Inc. |
47+
# | All rights reserved. |
48+
# | |
49+
# | Copyright © 2003-2019 SciPy Developers. |
50+
# | All rights reserved. |
51+
# | |
52+
# | Redistribution and use in source and binary forms, with or without |
53+
# | modification, are permitted provided that the following conditions are |
54+
# | met: |
55+
# | |
56+
# | Redistributions of source code must retain the above copyright notice, |
57+
# | this list of conditions and the following disclaimer. |
58+
# | |
59+
# | - Redistributions in binary form must reproduce the above copyright |
60+
# | notice, this list of conditions and the following disclaimer in the |
61+
# | documentation and/or other materials provided with the distribution. |
62+
# | - Neither the name of Enthought nor the names of the SciPy Developers |
63+
# | may be used to endorse or promote products derived from this software |
64+
# | without specific prior written permission. |
65+
# | |
66+
# | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
67+
# | “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
68+
# | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A |
69+
# | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR |
70+
# | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
71+
# | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
72+
# | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
73+
# | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF |
74+
# | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING |
75+
# | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
76+
# | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
77+
# | |
78+
# | Further details can be found at scipy.org/scipylib/license.html |
79+
# ============================================================================
80+
81+
# Source: https://github.com/scipy/scipy/blob/v1.3.3/scipy/stats/_continuous_\
82+
# distns.py
83+
84+
85+
_norm_pdf_C = np.sqrt(2 * np.pi)
86+
_norm_pdf_logC = np.log(_norm_pdf_C)
87+
88+
89+
def _norm_pdf(x):
90+
return np.exp(-(x ** 2) / 2.0) / _norm_pdf_C
91+
92+
93+
def _norm_logpdf(x):
94+
return -(x ** 2) / 2.0 - _norm_pdf_logC
95+
96+
97+
def _norm_cdf(x):
98+
return sc.ndtr(x)
99+
100+
101+
def _norm_ppf(q):
102+
return sc.ndtri(q)
103+
104+
105+
def _norm_sf(x):
106+
return _norm_cdf(-x)
107+
108+
109+
def _norm_isf(q):
110+
return -_norm_ppf(q)
111+
112+
113+
class truncnorm_gen(rv_continuous):
114+
r"""A truncated normal continuous random variable.
115+
116+
%(before_notes)s
117+
118+
Notes
119+
-----
120+
The standard form of this distribution is a standard normal truncated to
121+
the range [a, b] --- notice that a and b are defined over the domain of the
122+
standard normal. To convert clip values for a specific mean and standard
123+
deviation, use::
124+
125+
a, b = (myclip_a - my_mean) / my_std, (myclip_b - my_mean) / my_std
126+
127+
`truncnorm` takes :math:`a` and :math:`b` as shape parameters.
128+
129+
%(after_notes)s
130+
131+
%(example)s
132+
133+
"""
134+
135+
def _argcheck(self, a, b):
136+
return a < b
137+
138+
def _get_support(self, a, b):
139+
return a, b
140+
141+
def _get_norms(self, a, b):
142+
_nb = _norm_cdf(b)
143+
_na = _norm_cdf(a)
144+
_sb = _norm_sf(b)
145+
_sa = _norm_sf(a)
146+
_delta = np.where(a > 0, _sa - _sb, _nb - _na)
147+
with np.errstate(divide="ignore"):
148+
return _na, _nb, _sa, _sb, _delta, np.log(_delta)
149+
150+
def _pdf(self, x, a, b):
151+
ans = self._get_norms(a, b)
152+
_delta = ans[4]
153+
return _norm_pdf(x) / _delta
154+
155+
def _logpdf(self, x, a, b):
156+
ans = self._get_norms(a, b)
157+
_logdelta = ans[5]
158+
return _norm_logpdf(x) - _logdelta
159+
160+
def _cdf(self, x, a, b):
161+
ans = self._get_norms(a, b)
162+
_na, _delta = ans[0], ans[4]
163+
return (_norm_cdf(x) - _na) / _delta
164+
165+
def _ppf(self, q, a, b):
166+
# XXX Use _lazywhere...
167+
ans = self._get_norms(a, b)
168+
_na, _nb, _sa, _sb = ans[:4]
169+
ppf = np.where(
170+
a > 0,
171+
_norm_isf(q * _sb + _sa * (1.0 - q)),
172+
_norm_ppf(q * _nb + _na * (1.0 - q)),
173+
)
174+
return ppf
175+
176+
177+
truncnorm = truncnorm_gen(name="truncnorm")
178+
179+
180+
# ============================================================================
181+
# | END SciPy copyright |
182+
# ============================================================================

improver/ensemble_copula_coupling/ensemble_copula_coupling.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from numpy import ndarray
4343
from scipy import stats
4444

45+
import improver.ensemble_copula_coupling._scipy_continuous_distns as scipy_cont_distns
4546
from improver import BasePlugin
4647
from improver.calibration.utilities import convert_cube_data_to_2d
4748
from improver.ensemble_copula_coupling.utilities import (
@@ -727,14 +728,18 @@ def __init__(
727728
a lower bound of zero should be [0, np.inf].
728729
729730
"""
730-
try:
731-
self.distribution = getattr(stats, distribution)
732-
except AttributeError as err:
733-
msg = (
734-
"The distribution requested {} is not a valid distribution "
735-
"in scipy.stats. {}".format(distribution, err)
736-
)
737-
raise AttributeError(msg)
731+
if distribution == "truncnorm":
732+
# Use scipy v1.3.3 truncnorm
733+
self.distribution = scipy_cont_distns.truncnorm
734+
else:
735+
try:
736+
self.distribution = getattr(stats, distribution)
737+
except AttributeError as err:
738+
msg = (
739+
"The distribution requested {} is not a valid distribution "
740+
"in scipy.stats. {}".format(distribution, err)
741+
)
742+
raise AttributeError(msg)
738743

739744
if shape_parameters is None:
740745
if self.distribution.name == "truncnorm":

improver/regrid/grid.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ def calculate_input_grid_spacing(cube_in: Cube) -> Tuple[float, float]:
6969
lon_spacing = calculate_grid_spacing(cube_in, "degree", axis="x", rtol=4.0e-5)
7070
lat_spacing = calculate_grid_spacing(cube_in, "degree", axis="y", rtol=4.0e-5)
7171

72-
if lon_spacing < 0 or lat_spacing < 0:
72+
y_coord = cube_in.coord(axis="y").points
73+
x_coord = cube_in.coord(axis="x").points
74+
if x_coord[-1] < x_coord[0] or y_coord[-1] < y_coord[0]:
7375
raise ValueError("Input grid coordinates are not ascending.")
7476
return lat_spacing, lon_spacing
7577

improver/utilities/spatial.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ def calculate_grid_spacing(
8080
cube: Cube, units: Union[Unit, str], axis: str = "x", rtol: float = 1.0e-5
8181
) -> float:
8282
"""
83-
Returns the grid spacing of a given spatial axis
83+
Returns the grid spacing of a given spatial axis. This will be positive for
84+
axes that stride negatively.
8485
8586
Args:
8687
cube:
@@ -100,7 +101,7 @@ def calculate_grid_spacing(
100101
"""
101102
coord = cube.coord(axis=axis).copy()
102103
coord.convert_units(units)
103-
diffs = np.diff(coord.points)
104+
diffs = np.abs(np.diff(coord.points))
104105
diffs_mean = np.mean(diffs)
105106

106107
if not np.allclose(diffs, diffs_mean, rtol=rtol, atol=0.0):

0 commit comments

Comments
 (0)