Skip to content

Commit 919efdb

Browse files
authored
Add diagnostic to calculate hour of maximum precipitation (#4334)
1 parent 5f2ab1e commit 919efdb

7 files changed

Lines changed: 422 additions & 4 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.. _api.esmvaltool.diag_scripts.hydrology.hour_of_max_precipitation:
2+
3+
Hour of Maximum Precipitation
4+
=============================
5+
6+
.. automodule:: esmvaltool.diag_scripts.hydrology.hour_of_max_precipitation
7+
:no-members:
8+
:no-inherited-members:
9+
:no-show-inheritance:

doc/sphinx/source/api/esmvaltool.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ Diagnostic Scripts
3333
esmvaltool.diag_scripts.portrait_plot
3434
esmvaltool.diag_scripts.climate_metrics.tcre
3535
esmvaltool.diag_scripts.climate_metrics.zec
36+
esmvaltool.diag_scripts.hydrology.hour_of_max_precipitation
186 KB
Loading

doc/sphinx/source/recipes/recipe_model_evaluation.rst

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,21 @@ Recipes are stored in `recipes/model_evaluation`
2222
* recipe_model_evaluation_basics.yml
2323
* recipe_model_evaluation_clouds_clim.yml
2424
* recipe_model_evaluation_clouds_cycles.yml
25-
* recipe_model_evaluation_precip_zonal.yml
25+
* recipe_model_evaluation_precip_diurnal_cycle.yml
2626
* recipe_model_evaluation_precip_hist.yml
27+
* recipe_model_evaluation_precip_zonal.yml
2728

2829
Diagnostics are stored in `diag_scripts/monitor/`
2930

3031
* :ref:`multi_datasets.py
3132
<api.esmvaltool.diag_scripts.monitor.multi_datasets>`:
3233
Monitoring diagnostic to show multiple datasets in one plot (incl. biases).
33-
* :ref:`plot_histogram.py <api.esmvaltool.diag_scripts.plot_histogram>`
34-
Plotting of 1d histograms with precomputed histogram data ('histogram' preprocessor).
34+
* :ref:`plot_histogram.py <api.esmvaltool.diag_scripts.plot_histogram>`:
35+
Plotting of 1D histograms with precomputed histogram data (``histogram``
36+
preprocessor).
37+
* :ref:`hydrology/hour_of_max_precipitation.py
38+
<api.esmvaltool.diag_scripts.hydrology.hour_of_max_precipitation>`:
39+
Calculating and plotting hour of maximum precipitation.
3540

3641

3742
User settings
@@ -105,5 +110,12 @@ Example plots
105110
:align: center
106111
:width: 50%
107112

108-
Hisotgram of precipitation rates from MPI-ESM1-2-HR and ERA5 reanalysis 3-hourly
113+
Histogram of precipitation rates from MPI-ESM1-2-HR and ERA5 reanalysis 3-hourly
109114
data over the Tropics (30°S-30°N) for the years 1979-1980.
115+
116+
.. figure:: /recipes/figures/model_evaluation/hr_max_pr.png
117+
:align: center
118+
:width: 14cm
119+
120+
Hour of maximum precipitation from ERA5 in the tropics (30°S-30°N) for the
121+
months June, July, August 2000.
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
"""Calculate and plot hour of daily maximum precipitation.
2+
3+
Description
4+
-----------
5+
This diagnostics calculates the hour of daily maximum precipitation and plots
6+
it. The input data needs to be subdaily and of shape (hour, latitude,
7+
longitude). The hour dimension should be in local solar time (see
8+
:func:`~esmvalcore.preprocessor.local_solar_time`).
9+
10+
Author
11+
------
12+
Manuel Schlund (DLR, Germany)
13+
14+
Configuration options in recipe
15+
-------------------------------
16+
caption: str, optional
17+
Figure caption used for provenance tracking. By default, uses "Global map
18+
of the hour of the daily maximum precipitation for {alias}.".
19+
cbar_label: str, optional (default: "Hour of daily maximum precipitation")
20+
Colorbar label.
21+
cbar_kwargs: dict, optional
22+
Optional keyword arguments for :func:`matplotlib.pyplot.colorbar`. By
23+
default, uses ``{orientation: "horizontal"}``.
24+
figure_kwargs: dict, optional
25+
Optional keyword arguments for :func:`matplotlib.pyplot.figure`. By
26+
default, uses ``constrained_layout: true, ticks: [0, 3, 6, 9, 12, 15, 18,
27+
21]``.
28+
matplotlib_rc_params: dict, optional
29+
Optional :class:`matplotlib.RcParams` used to customize matplotlib plots.
30+
Options given here will be passed to :func:`matplotlib.rc_context` and used
31+
for all plots produced with this diagnostic.
32+
method: str, optional (default: `"dft"`)
33+
Method to determine the hour of daily maximum precipitation. Possible
34+
options:
35+
36+
- If `"dft"`, apply a discrete Fourier transform (DFT) to the data and use
37+
the peak of the first component (see
38+
https://mdtf-diagnostics.readthedocs.io/en/latest/sphinx_pods/precip_diurnal_cycle.html).
39+
- If `"harmonic_fit"`, fit a 12hr- plus 24hr-harmonic to the input data and
40+
use the peak of the 24hr-harmonic (see Dai, 2024;
41+
https://doi.org/10.1007/s00382-024-07182-6). This should give
42+
identical/very similar results to `"dft"`, but is much slower.
43+
- If `"max"`, simply use the maximum in the input data.
44+
plot_kwargs: dict, optional
45+
Optional keyword arguments for :func:`iris.plot.pcolormesh`. By default,
46+
uses ``cmap: twilight``.
47+
projection: str, optional (default: None)
48+
Projection used for the plot. Needs to be a valid projection class of
49+
:mod:`cartopy.crs`. Keyword arguments can be specified using the option
50+
``projection_kwargs``.
51+
projection_kwargs: dict, optional
52+
Optional keyword arguments for the projection given by ``projection``. For
53+
map plots, the default keyword arguments ``{central_longitude: 10}`` are
54+
used.
55+
pyplot_kwargs: dict, optional
56+
Optional calls to functions of :mod:`matplotlib.pyplot`. Dictionary keys
57+
are functions of :mod:`matplotlib.pyplot`. Dictionary values are used as
58+
argument(s) for these functions (if values are dictionaries, these are
59+
interpreted as keyword arguments; otherwise a single argument is assumed).
60+
String arguments can contain format strings (e.g., ``"{dataset}
61+
({project})"``).
62+
savefig_kwargs: dict, optional
63+
Optional keyword arguments for :func:`matplotlib.pyplot.savefig`. By
64+
default, uses ``bbox_inches: tight, dpi: 300, orientation: landscape``.
65+
seaborn_settings: dict, optional
66+
Options for :func:`seaborn.set_theme` (affects all plots). By default, uses
67+
``style: ticks``.
68+
threshold: float, optional (default: 0.0)
69+
Mask grid points where precipitation is lower than the given threshold.
70+
71+
"""
72+
73+
import logging
74+
import warnings
75+
from copy import deepcopy
76+
from pathlib import Path
77+
from typing import Any
78+
79+
import cartopy.crs as ccrs
80+
import iris
81+
import iris.plot
82+
import matplotlib.pyplot as plt
83+
import numpy as np
84+
import seaborn as sns
85+
from iris.cube import Cube
86+
from iris.warnings import IrisVagueMetadataWarning
87+
from matplotlib.figure import Figure
88+
from scipy.optimize import curve_fit
89+
90+
from esmvaltool.diag_scripts.shared import run_diagnostic
91+
from esmvaltool.diag_scripts.shared._base import save_data, save_figure
92+
93+
logger = logging.getLogger(Path(__file__).stem)
94+
95+
96+
def _harmonic_function(
97+
x_val: float,
98+
constant: float,
99+
amplitude_24: float,
100+
phase_24: float,
101+
amplitude_12: float,
102+
phase_12: float,
103+
) -> float:
104+
"""Harmonic function which can be fitted to diurnal cycle."""
105+
diurnal_component = amplitude_24 * np.sin(
106+
2 * np.pi * (x_val - phase_24) / 24.0,
107+
)
108+
semidiurnal_component = amplitude_12 * np.sin(
109+
2 * np.pi * (x_val - phase_12) / 12.0,
110+
)
111+
return constant + diurnal_component + semidiurnal_component
112+
113+
114+
def _get_max_of_24hr_harmonic(
115+
x_data: np.ma.MaskedArray,
116+
y_data: np.ma.MaskedArray,
117+
) -> float:
118+
"""Get maximum of 24hr-harmonic fit."""
119+
# curve_fit cannot handle masked data
120+
if np.ma.is_masked(y_data):
121+
return np.nan
122+
params = curve_fit(_harmonic_function, x_data, y_data, nan_policy="omit")[
123+
0
124+
]
125+
amplitude_24hr = params[1]
126+
phase_24hr = params[2]
127+
128+
# Since we are using sin() as fit function here, the maximum of the fit
129+
# function is located 1/4 of a periodic length (i.e., π/2; here: 6hrs) to
130+
# the right/left (depending on the sign of the amplitude) of the phase (=
131+
# first zero of sin()). Since the phase can be outside [0, 24] due to the
132+
# periodicity of sin(), make sure to return the maximum within [0, 24].
133+
maximum = phase_24hr + 6.0 if amplitude_24hr > 0 else phase_24hr - 6.0
134+
return maximum % 24.0
135+
136+
137+
_v_get_max_of_24hr_harmonic = np.vectorize(
138+
_get_max_of_24hr_harmonic,
139+
signature="(t),(t)->()",
140+
)
141+
142+
143+
def _calculate_hour_of_max_precipitation(
144+
cube: Cube,
145+
cfg: dict[str, Any],
146+
) -> Cube:
147+
"""Calculate hour of daily maximum daily precipitation."""
148+
hour_dim = cube.coord_dims("hour")[0]
149+
150+
# Mask values mean diurnal cycle is smaller than threshold
151+
with warnings.catch_warnings():
152+
warnings.filterwarnings(
153+
"ignore",
154+
category=IrisVagueMetadataWarning,
155+
module="iris",
156+
)
157+
mean_diurnal_cycle = cube.collapsed("hour", iris.analysis.MEAN)
158+
mask = mean_diurnal_cycle.data < cfg["threshold"]
159+
broadcasted_mask = np.broadcast_to(
160+
np.expand_dims(mask, axis=hour_dim),
161+
cube.shape,
162+
)
163+
cube = cube.copy(np.ma.masked_array(cube.data, mask=broadcasted_mask))
164+
165+
# Calculate hour of daily maximum precipitation
166+
if cfg["method"] == "dft":
167+
dft = np.fft.rfft(cube.data, axis=hour_dim)
168+
169+
# The phase here is the phase of a cosine, so this is identical to the
170+
# maximum.
171+
max_component_1 = np.arctan2(-dft[1].imag, dft[1].real)
172+
173+
# Transform from radians to hours and make sure we end up in [0, 24]
174+
max_24hrs = (max_component_1 * 24.0 / 2.0 / np.pi) % 24.0
175+
hour_of_max_precipitation_data = np.ma.array(max_24hrs, mask=mask)
176+
elif cfg["method"] == "harmonic_fit":
177+
# np.vectorized assumes that the core dimension is the rightmost
178+
# dimension
179+
new_order = [d for d in range(cube.ndim) if d != hour_dim] + [hour_dim]
180+
hour_of_max_precipitation_data = _v_get_max_of_24hr_harmonic(
181+
cube.coord("hour").points,
182+
cube.data.transpose(new_order),
183+
)
184+
hour_of_max_precipitation_data = np.ma.masked_invalid(
185+
hour_of_max_precipitation_data,
186+
)
187+
elif cfg["method"] == "max":
188+
hour_of_max_precipitation_data = np.ma.masked_array(
189+
cube.coord("hour").points[np.argmax(cube.data, axis=hour_dim)],
190+
mask=mask,
191+
)
192+
else:
193+
supported_methods = (
194+
"dft",
195+
"harmonic_fit",
196+
"max",
197+
)
198+
msg = f"Expected one of {supported_methods} for 'method', got '{cfg['method']}'"
199+
raise ValueError(msg)
200+
201+
return mean_diurnal_cycle.copy(hour_of_max_precipitation_data)
202+
203+
204+
def _get_default_cfg(cfg: dict) -> dict:
205+
"""Get default options for configuration dictionary."""
206+
cfg = deepcopy(cfg)
207+
208+
cfg.setdefault(
209+
"caption",
210+
"Global map of the hour of the daily maximum precipitation for {alias}.",
211+
)
212+
cfg.setdefault("cbar_label", "Hour of daily maximum precipitation")
213+
cfg.setdefault(
214+
"cbar_kwargs",
215+
{"orientation": "horizontal", "ticks": [0, 3, 6, 9, 12, 15, 18, 21]},
216+
)
217+
cfg.setdefault("figure_kwargs", {"constrained_layout": True})
218+
cfg.setdefault("matplotlib_rc_params", {})
219+
cfg.setdefault("method", "dft")
220+
cfg.setdefault("plot_kwargs", {"cmap": "twilight"})
221+
cfg.setdefault("projection", "Robinson")
222+
cfg.setdefault("projection_kwargs", {"central_longitude": 10})
223+
cfg.setdefault("pyplot_kwargs", {})
224+
cfg.setdefault(
225+
"savefig_kwargs",
226+
{
227+
"bbox_inches": "tight",
228+
"dpi": 300,
229+
"orientation": "landscape",
230+
},
231+
)
232+
cfg.setdefault("seaborn_settings", {"style": "ticks"})
233+
cfg.setdefault("threshold", 0.0)
234+
235+
supported_methods = (
236+
"dft",
237+
"harmonic_fit",
238+
"max",
239+
)
240+
241+
if cfg["method"] not in supported_methods:
242+
msg = f"Expected one of {supported_methods} for 'method', got '{cfg['method']}'"
243+
raise ValueError(msg)
244+
245+
return cfg
246+
247+
248+
def _get_projection(cfg: dict[str, Any]) -> Any:
249+
"""Get plot projection."""
250+
projection = cfg["projection"]
251+
projection_kwargs = cfg["projection_kwargs"]
252+
253+
if not hasattr(ccrs, projection):
254+
msg = f"Got invalid projection '{projection}', expected class of cartopy.crs"
255+
raise AttributeError(msg)
256+
257+
return getattr(ccrs, projection)(**projection_kwargs)
258+
259+
260+
def _get_provenance_record(
261+
ancestors: list[str],
262+
caption: str,
263+
) -> dict[str, Any]:
264+
"""Get provenance record."""
265+
return {
266+
"ancestors": ancestors,
267+
"authors": ["schlund_manuel"],
268+
"caption": caption,
269+
"plot_types": ["map"],
270+
"realms": ["atmos"],
271+
"references": ["dai24climdyn"],
272+
"themes": ["phys"],
273+
}
274+
275+
276+
def _create_plot(cube: Cube, dataset: dict, cfg: dict[str, Any]) -> Figure:
277+
"""Plot map."""
278+
fig = plt.figure(**cfg["figure_kwargs"])
279+
axes = fig.add_subplot(projection=_get_projection(cfg))
280+
plot_kwargs = cfg["plot_kwargs"]
281+
plot_kwargs["axes"] = axes
282+
map_plot = iris.plot.pcolormesh(cube, **plot_kwargs)
283+
axes.set_title(dataset["alias"])
284+
_process_pyplot_kwargs(cfg["pyplot_kwargs"], dataset)
285+
cbar = plt.colorbar(map_plot, ax=axes, **cfg["cbar_kwargs"])
286+
cbar.set_label(cfg["cbar_label"])
287+
return fig
288+
289+
290+
def _process_pyplot_kwargs(pyplot_kwargs: Any, dataset: dict) -> None:
291+
"""Process functions for :mod:`matplotlib.pyplot`."""
292+
for func, arg in pyplot_kwargs.items():
293+
if arg is None:
294+
getattr(plt, func)()
295+
elif isinstance(arg, dict):
296+
getattr(plt, func)(**arg)
297+
elif isinstance(arg, str):
298+
getattr(plt, func)(arg.format(**dataset))
299+
else:
300+
getattr(plt, func)(arg)
301+
302+
303+
def main(cfg: dict) -> None:
304+
"""Run diagnostic."""
305+
cfg = _get_default_cfg(cfg)
306+
sns.set_theme(**cfg["seaborn_settings"])
307+
308+
for dataset in cfg["input_data"].values():
309+
filename = dataset["filename"]
310+
basename = Path(filename).stem
311+
caption = cfg["caption"].format(**dataset)
312+
provenance_record = _get_provenance_record([filename], caption)
313+
314+
# Calculation
315+
logger.info("Loading %s", filename)
316+
cube = iris.load_cube(filename)
317+
cube = _calculate_hour_of_max_precipitation(cube, cfg)
318+
save_data(basename, provenance_record, cfg, cube)
319+
320+
# Plot
321+
logger.info("Plotting map for %s", dataset["alias"])
322+
figure = _create_plot(cube, dataset, cfg)
323+
save_figure(
324+
basename,
325+
provenance_record,
326+
cfg,
327+
figure=figure,
328+
**cfg["savefig_kwargs"],
329+
)
330+
331+
332+
if __name__ == "__main__":
333+
with run_diagnostic() as config:
334+
main(config)

0 commit comments

Comments
 (0)