|
| 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