From d56453c8fa2297dc8628bd91bb61530042087f14 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 31 Jan 2025 12:07:50 +0100 Subject: [PATCH 01/66] init wip pet spei diags --- esmvaltool/diag_scripts/droughts/pet.R | 223 ++++++++++++++++ esmvaltool/diag_scripts/droughts/spei.R | 199 +++++++++++++++ esmvaltool/diag_scripts/droughts/utils.R | 308 +++++++++++++++++++++++ 3 files changed, 730 insertions(+) create mode 100644 esmvaltool/diag_scripts/droughts/pet.R create mode 100644 esmvaltool/diag_scripts/droughts/spei.R create mode 100644 esmvaltool/diag_scripts/droughts/utils.R diff --git a/esmvaltool/diag_scripts/droughts/pet.R b/esmvaltool/diag_scripts/droughts/pet.R new file mode 100644 index 0000000000..e3b04955be --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/pet.R @@ -0,0 +1,223 @@ +#' @importFrom checkmate makeAssertCollection +#' @importFrom stats cycle end frequency start ts optim +#' @importFrom zoo as.yearmon rollapply + +# This file is based on code from diag_save_spei_all.R, but only contains the +# parts to calculate PET. The idea of calculating PET in a seperate diagnostic +# is to be more flexible in the choice of PET calculation methods. It saves +# the calculated PET with correct units and metadata to be used as an ancestor +# for diag_spei.R or diag_spei.py, but also allows to use pet as a variable from +# a preprocessed dataset in the same way. +# +# Some functions that are used by diag_spei.R too are moved to a shared utils.R +# +# NOTE: Masking is done for each dataset instead of using the mask from the +# reference dataset everytime. +# +# NOTE: renamed variables to use shortnames/named lists instead of var1, +# tmp1, tmp2, tmp3.. which differ for pet_types +# +# NOTE: added pet_type Penman, which use best available data and let SPEI +# library approximate everything else +# +# NOTE: Loop over datasets and use params and meta dicts whenever possible to +# save some loops, switches and many extra variables. +# +# NOTE: add latitude and longitude as valid dim coords in addition to lat/lon +# +# NOTE: added weigel_katja, ruhe_lukas to authors +# +# NOTE: Provenance is written for each output file within the loop over datasets +# instead of afterwards (was it a bug?) +# +# NOTE: Precipitation is removed from input data for hargreaves method, since it +# cause failures within SPEI functions. Hargreaves.R line 494 wrong shapes +# +# NOTE: Remove pr as reference, since pr is not mandatory input for all methods. +# +# Authors: [Peter Berg, Katja Weigel, Lukas Ruhe] + +library(yaml) +library(ncdf4) +library(SPEI) +library(R.utils) + +setwd(dirname(commandArgs(asValues=TRUE)$file)) +source("utils.R") + + +calculate_hargreaves <- function(metas, xprov) { + data <- list(tasmin=NULL, tasmax=NULL, rsdt=NULL) #, pr=NULL) + for (meta in metas) { + if (meta$short_name %in% names(data)) { + print(paste("read", meta$short_name)) + data[[meta$short_name]] <- get_var_from_nc(meta) + if (meta$short_name == "tasmin") { + data$lat <- get_var_from_nc(meta, custom_var="lat") + } + print(paste("Shape of", meta$short_name)) + print(dim(data[[meta$short_name]])) + } else { + print(paste("Variable", meta$short_name, "not used for hargreaves")) + } + } + dpet <- data$tasmin * NA + for (i in 1:dim(dpet)[2]) { + pet_tmp <- hargreaves( + t(data$tasmin[, i, ]), + t(data$tasmax[, i, ]), + lat = rep(data$lat[i], dim(dpet)[1]), + Pre = t_or_null(data$pr[, i, ]), + Ra = t_or_null(data$rsdt[, i, ]), + na.rm = TRUE + ) + d2 <- dim(pet_tmp) + pet_tmp <- as.numeric(pet_tmp) + dim(pet_tmp) <- d2 + dpet[, i, ] <- t(pet_tmp) + } + return(dpet) +} + +calculate_thornthwaite <- function(metas, xprov) { + for (meta in metas) { + if (meta$short_name == "tas") { + tas = get_var_from_nc(meta) + lat = get_var_from_nc(meta, custom_var="lat") + xprov$ancestors <- append(xprov$ancestors, meta$filename) + } else { + print(paste("Variable", meta$short_name, "not used for Thornthwaite.")) + } + } + dpet <- tas * NA + for (i in 1:dim(dpet)[2]){ + tmp <- tas[, i, ] + pet_tmp <- thornthwaite( + t(tmp), rep(lat[i], dim(dpet)[1]), na.rm = TRUE + ) + d2 <- dim(pet_tmp) + pet_tmp <- as.numeric(pet_tmp) + dim(pet_tmp) <- d2 + dpet[, i, ] <- t(pet_tmp) + } + return(dpet) +} + + +calculate_penman <- function(metas, xprov) { + data <- list( + tasmin = NULL, + tasmax = NULL, + clt = NULL, + sfcWind = NULL, + ps = NULL, + psl = NULL, + hurs = NULL, + rsds = NULL, + rsdt=NULL) + # load relevant variables + for(meta in metas){ + if (meta$short_name %in% names(data)){ + print(meta$filename) + data[[meta$short_name]] <- get_var_from_nc(meta) + xprov$ancestors <- append(xprov$ancestors, meta$filename) + if(meta$short_name == "tasmin"){ + data$lat <- get_var_from_nc(meta, custom_var="lat") + } + } else { + print("variable not used for penman:") + print(meta$short_name) + } + } + # calculate pet for each latitude + dpet <- data$tasmin * NA + for (i in 1:dim(dpet)[2]){ + pet_tmp <- penman( + t(data$tasmin[, i, ]), + t(data$tasmax[, i, ]), + t(data$sfcWind[, i, ]), + lat = rep(data$lat[i], dim(dpet)[1]), + CC = t_or_null(data$clt[, i, ]), + P = t_or_null(data$ps[, i, ]), + P0 = t_or_null(data$psl[, i, ]), + RH = t_or_null(data$hurs[, i, ]), + Ra = t_or_null(data$rsdt[, i, ]), + Rs = t_or_null(data$rsds[, i, ]), + na.rm = TRUE, + # method="FAO", + crop = "tall" # TODO: read from params with fallback? + ) + d2 <- dim(pet_tmp) + pet_tmp <- as.numeric(pet_tmp) + dim(pet_tmp) <- d2 + dpet[, i, ] <- t(pet_tmp) + } + return(dpet) +} + + +# ---------------------------------------------------------------------------- # +# Script starts here --------------------------------------------------------- # +# ---------------------------------------------------------------------------- # + +params <- read_yaml(commandArgs(trailingOnly = TRUE)[1]) +dir.create(params$work_dir, recursive = TRUE) +dir.create(params$plot_dir, recursive = TRUE) +fillfloat <- 1.e+20 +as.single(fillfloat) +provenance_file <- paste0(params$run_dir, "/", "diagnostic_provenance.yml") +provenance <- list() +meta <- list() # collect output files metadata +meta_file <- paste0(params$work_dir, "/metadata.yml") + +print("--- Load metadata") +ancestor_meta <- list() +for (anc in params$input_files){ + if (!endsWith(anc, 'metadata.yml')){anc <- paste0(anc, "/metadata.yml")} + ancestor_meta <- append(ancestor_meta, read_yaml(anc)) +} +grouped_meta <- group_meta(ancestor_meta) + +print("--- Process each dataset") +for (dataset in names(grouped_meta)){ + metas <- grouped_meta[[dataset]] # list of files for this dataset + xprov$ancestors <- list() + switch(params$pet_type, + Penman = {pet <- calculate_penman(metas, xprov)}, + Thornthwaite = {pet <- calculate_thornthwaite(metas, xprov)}, + Hargreaves = {pet <- calculate_hargreaves(metas, xprov)}, + stop("pet_type must be one of: Penman, Hargreaves, Thornthwaite") + ) + mask <- get_merged_mask(metas) + for (t in 1:dim(pet)[3]){ + # print(t) + tmp <- pet[, , t] + print(dim(tmp)) + tmp[is.na(mask)] <- NA + print(dim(tmp)) + pet[, , t] <- tmp + } + + # postprocess and write PET + first_meta = metas[[names(metas)[1]]] + filename <- write_nc_file_like( + params, first_meta, pet, fillfloat, + short_name="evspsblpot", + long_name="Potential Evapotranspiration", + units="mm month-1") # temp default units to be converted in python + #units="kg m-2 s-1") + input_meta = select_var(metas, "tasmin") # TODO: create duplicate()? + input_meta$filename = filename + input_meta$short_name = "evspsblpot" + input_meta$long_name = "Potential Evapotranspiration" + input_meta$units = "mm month-1" + meta[[filename]] <- input_meta + + xprov$caption <- "PET per grid point." + provenance[[filename]] <- xprov +} + + + +write_yaml(provenance, provenance_file) +write_yaml(meta, meta_file) \ No newline at end of file diff --git a/esmvaltool/diag_scripts/droughts/spei.R b/esmvaltool/diag_scripts/droughts/spei.R new file mode 100644 index 0000000000..7c4d8081f9 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/spei.R @@ -0,0 +1,199 @@ +# DESCRIPTION: +# +# This file is based on code from diag_save_spei_all.R and should replace +# diag_spei.R at some point. It is more flexible and provides the same +# functionality as the previous diag_spei.R as a special case. +# Calculation of PET and plotting of results is covered in seperate diagnostics. +# +# Authors: [Peter Berg, Katja Weigel, Lukas Ruhe] +# +# CHANGELOG: +# +# NOTE: modified PET to work with ancestors. Maybe this breaks internal PET, +# when ancestors are not set. +# +# NOTE: with given ancestors the metadata files are not longer part of +# settings.yml. Variables from ancestors can be added in recipe (i.e. pet/pr) +# +# NOTE: In the output folder are now (ESMValTool update) additinal xml and yml +# files which need to be skipped when manually loaded. +# UPDATE: reduced this diag to work only with ancestors producing metadata.yml +# or preprocessed variables. +# +# NOTE: Is the reference dataset used to apply the mask (NaNs) to all other +# datasets? What should be chosen for reference? and why? +# UPDATE: refrence_dataset will be read from script settings instead of dataset +# UPDATE2: individual masks and refperiods should be fine. Reference dataset +# completly removed. +# +# NOTE: All metadata is kept in a named list with model keys. Removed some loops +# and variables. In most functions meta just replaces yml[m][1]. +# +# NOTE: A similar correction for time unit was hardcoded for each +# variable. Changed it to a function that is called multiple times. #DRY +# +# UPDATE: cleaned up getnc/getpetnc -> get_var_from_nc() getpetnc +# UPDATE: gettimenc is another special case of get_var_from_nc() #DRY +# +# NOTE: latitude is taken from reference dataset, but also for each ds during +# calculation. +# +# NOTE: Common functions moved to utils.R this includes general read and write +# functions for nc files that replace ncwrite, ncwritespei, ncwritepet, getpetnc +# gettimenc... and general utility functions like default values for lists +# +# NOTE: move all if conditions for each refperiod param into a seperate function +# fill_refperiod. #DRY +# +# NOTE: added optional parameter `short_name_pet` to use variables other than +# evspsblpot from recipe. +# +# NOTE: set log-Logistic as default distribution if nothing is given in the +# recipe. Missing distribution raised an unclear error before. +# +# OPTIONS: +# +# write_coeffs: boolean, default FALSE +# write xi, alpha and kappa to netcdf files +# write_wb: boolean, default FALSE +# write water balance to netcdf file +# short_name_pet: string, default "evspsblpot" +# short name of the variable to use as PET (i.e. ET) +# distributionn: string, default "log-Logistic" +# type of distribution used for SPEI calibration. +# Options: "Gamma", "log-Logistic", "Pearson III" +# refstart_year: integer, default first year of time series +# refstart_month: integer, default 1 +# refend_year: integer, default last year of time series +# refend_month: integer, default 12 + +library(yaml) +library(ncdf4) +library(SPEI) +library(R.utils) + +setwd(dirname(commandArgs(asValues=TRUE)$file)) +source("utils.R") + +fill_refperiod <- function(params, tsvec) { + params <- list_default(params, "refstart_year", tsvec[1]) + params <- list_default(params, "refstart_month", tsvec[2]) + params <- list_default(params, "refend_year", tsvec[3]) + params <- list_default(params, "refend_month", tsvec[4]) +} + +# ---------------------------------------------------------------------------- # +# Script starts here --------------------------------------------------------- # +# ---------------------------------------------------------------------------- # +params <- read_yaml(commandArgs(trailingOnly = TRUE)[1]) +params <- list_default(params, "write_coeffs", FALSE) +params <- list_default(params, "write_wb", FALSE) +params <- list_default(params, "short_name_pet", "evspsblpot") +params <- list_default(params, "distribution", "log-Logistic") +dir.create(params$work_dir, recursive = TRUE) +dir.create(params$plot_dir, recursive = TRUE) +fillfloat <- 1.e+20 +as.single(fillfloat) +provenance_file <- paste0(params$run_dir, "/", "diagnostic_provenance.yml") +meta_file <- paste0(params$work_dir, "/metadata.yml") +provenance <- list() +meta <- list() # collect output files metadata + +print("--- Load input meta") +ancestor_meta <- list() +for (anc in params$input_files){ + if (!endsWith(anc, 'metadata.yml')){anc <- paste0(anc, "/metadata.yml")} + ancestor_meta <- append(ancestor_meta, read_yaml(anc)) +} +grouped_meta <- group_meta(ancestor_meta) + +print("--- Process each dataset") +for (dataset in names(grouped_meta)){ + print(paste("-- processing dataset:", dataset)) + metas <- grouped_meta[[dataset]] # list of files for this dataset + mask <- get_merged_mask(metas) # use mask per dataset + # START SPEI CALC + pr_meta <- select_var(metas, "pr") + pr <- get_var_from_nc(pr_meta) + lat <- get_var_from_nc(pr_meta, custom_var="lat") + tsvec <- get_var_from_nc(pr_meta, custom_var="time") + pet <- get_var_from_nc(select_var(metas, params$short_name_pet)) + pme <- pr - pet + if (params$write_wb) { + filename_wb <- write_nc_file_like(params, pr_meta, pme, fillfloat, short_name="wb") + } + + fill_refperiod(params, tsvec) + pme_spei <- pme * NA + coeffs <- array(numeric(), c(3, dim(pme)[1], dim(pme)[2], 12)) # xi, alpha, kappa + for (i in 1:dim(pme)[1]){ + wh <- which(!is.na(mask[i,])) + if (length(wh) > 1){ + tmp <- pme[i, wh,] + ts_data <- ts(t(tmp), freq=12, start=c(tsvec[1], tsvec[2])) + spei_results <- spei( + ts_data, + params$smooth_month, + na.rm = TRUE, + distribution = params$distribution, + ref.start = c(params$refstart_year, params$refstart_month), + ref.end = c(params$refend_year, params$refend_month) + ) + coeffs[, i, wh, ] <- spei_results$coefficients + pme_spei[i, wh, ] <- t(spei_results$fitted) + } + } + pme_spei[pme_spei > 10000] <- NA # replaced with fillfloat in write function + filename <- write_nc_file_like(params, pr_meta, pme_spei, fillfloat) + if (params$write_coeffs) { + filename_xi <- write_nc_file_like( + params, pr_meta, coeffs[1, , ,], fillfloat, + short_name = "xi", + moty=TRUE) + meta[[filename_xi]] <- list( + filename=filename_xi, + short_name="xi", + dataset=dataset) + filename_alpha <- write_nc_file_like( + params, pr_meta, coeffs[2, , ,], fillfloat, + short_name = "alpha", + moty = TRUE) + meta[[filename_alpha]] <- list( + filename=filename_xi, + short_name="alpha", + dataset=dataset) + filename_kappa <- write_nc_file_like( + params, pr_meta, coeffs[3, , ,], fillfloat, + short_name = "kappa", + moty=TRUE) + meta[[filename_kappa]] <- list( + filename=filename_kappa, + short_name="kappa", + dataset=dataset) + } + + print("-- prepare metadata for output") + meta[[filename]] <- list( + filename = filename, + short_name = "spei", + dataset = dataset) + xprov$caption <- "SPEI index per grid point." + # generate metadata.yml + input_meta = select_var(metas, "pr") + input_meta$filename = filename + input_meta$short_name = "spei" + input_meta$long_name = "SPEI" + input_meta$units = "1" + input_meta$index = "SPEI" + meta[[filename]] <- input_meta + # meta[[filename]][["index"]] <- "SPEI" + for (t in 1:dim(pme)[3]) { + tmp <- pme_spei[, , t] + tmp[is.na(mask)] <- NA + pme_spei[, , t] <- tmp + } +} # end of big dataset loop + +provenance[[filename]] <- xprov +write_yaml(provenance, provenance_file) +write_yaml(meta, meta_file) \ No newline at end of file diff --git a/esmvaltool/diag_scripts/droughts/utils.R b/esmvaltool/diag_scripts/droughts/utils.R new file mode 100644 index 0000000000..5d51d33b56 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/utils.R @@ -0,0 +1,308 @@ +# This file contains utility functions that are available in all +# R diagnostics related to drought indices. + +# NOTE: Several codeblocks applying masks to combinations of variables are +# replaced by reusable get_merged_mask function. +# +# NOTE: Variables are converted to Units required by SPEI library when loaded +# time -> start_year, start_month, end_year, end_month +# lat -> fails > 90 +# tas/min/max -> kelvin to celsius +# pr -> to mm/month TODO: same for PET when output is fixed +# sfcWind -> U10 to U2 +# psl -> hPa to kPa +# rsdt/rsds -> Wm-2 to MJm-2d-1 +# +# TODO: The PET produced by R diag seems to be in the "correct" unit for SPEI, +# but does not match the units of pr and evspsbl(pot), which are loaded from +# native data. PET should be converted before saved to nc files and +# converted back at loading time. +# +# TODO: same files are opened multiple times. Does keeping the file open +# improve performance? +# +# NOTE: `fillfloat` and `fillvalue` are variables that are used both for the +# same purpose but can have different values? fillvalue is read from reference +# data _FillValue but not used anywhere. Now fillfloat is used to write +# _FillValue attribute of the new created file. + +# ---------------------------------------------------------------------------- # +# Variables ------------------------------------------------------------------ # +# ---------------------------------------------------------------------------- # + +xprov <- list( + ancestors = list(""), + authors = list("berg_peter", "weigel_katja", "ruhe_lukas"), + references = list("vicente10jclim"), + projects = list("c3s-magic"), + caption = "", + statistics = list("other"), + realms = list("atmos"), + themes = list("phys"), + domains = list("global") +) + +# ---------------------------------------------------------------------------- # +# funcitons ------------------------------------------------------------------ # +# ---------------------------------------------------------------------------- # + +convert_to_monthly <- function(id, v) { + # converts kg/m2/s to mm/month depending on the calendar of the nc files + # time coordinate. Assuming a density of 1000 kg m-3 + tcal <- ncatt_get(id, "time", attname = "calendar") + if (tcal$value == "360_day") return(v* 30 * 24 * 3600.) + time <- ncvar_get(id, "time") + tunits <- ncatt_get(id, "time", attname = "units") + tustr <- strsplit(tunits$value, " ") + stdate <- as.Date(time[1], origin = unlist(tustr)[3]) + nddate <- as.Date(time[length(time)], origin = unlist(tustr)[3]) + if (tcal$value == "365_day") { # Correct for missing leap years in nddate + diff <- as.numeric(nddate - stdate, units = "days") + dcorr <- floor( (diff / 365 - diff / 365.25) * 365.25) + nddate <- nddate + dcorr + } + cnt <- 1 + monarr <- c(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + date <- stdate + while (date <= nddate){ + year <- as.numeric(substr(date, 1, 4)) + lpyear <- leap_year(year) + month <- as.numeric(substr(date, 6, 7)) + mdays <- monarr[month] + pdays <- mdays + if (month == 2 & lpyear == TRUE){ + pdays <- 29 + mdays <- if (tcal$value != "365_day") 29 else 28 + } + v[,,cnt] <- v[,,cnt] * mdays * 24 * 3600. + date <- date + pdays + cnt <- cnt + 1 + } + return(v) +} + +convert_to_monthly_simple <- function(id, v) { + # converts kg/m2/s to mm/month + return(v * 30 * 24 * 3600.) +} + + +convert_to_cf <- function(id, v) { + # converts mm/mon back to kg/m2/s assuming the input ncfile to have the + # correct calendar set. (inverse of `convert_to_monthly`) + tcal <- ncatt_get(id, "time", attname = "calendar") + if (tcal$value == "360_day") return(v / 30 / 24 / 3600.) + time <- ncvar_get(id, "time") + tunits <- ncatt_get(id, "time", attname = "units") + tustr <- strsplit(tunits$value, " ") + stdate <- as.Date(time[1], origin = unlist(tustr)[3]) + nddate <- as.Date(time[length(time)], origin = unlist(tustr)[3]) + if (tcal$value == "365_day") { # Correct for missing leap years in nddate + diff <- as.numeric(nddate - stdate, units = "days") + dcorr <- floor( (diff / 365 - diff / 365.25) * 365.25) + nddate <- nddate + dcorr + } + cnt <- 1 + monarr <- c(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + date <- stdate + while (date <= nddate){ + year <- as.numeric(substr(date, 1, 4)) + lpyear <- leap_year(year) + month <- as.numeric(substr(date, 6, 7)) + mdays <- monarr[month] + pdays <- mdays + if (month == 2 & lpyear == TRUE){ + pdays <- 29 + mdays <- if (tcal$value != "365_day") 29 else 28 + } + v[,,cnt] <- v[,,cnt] / mdays / 24 / 3600. + date <- date + pdays + cnt <- cnt + 1 + } + return(v) +} + +get_merged_mask <- function(metas, variables="all") { + first <- TRUE + for(meta in metas){ + var <- get_var_from_nc(meta) + msk <- apply(var, c(1,2), FUN = mean, na.rm = TRUE) + msk[msk > 1e29] <- NA + if(first){ + mask <- msk + first <- FALSE + } else { + mask[is.na(msk)] <- NA + } + } + mask[mask > 1e29] <- NA # why again? + mask[!is.na(mask)] <- 1 + return(mask) +} + + +get_var_from_nc <- function(meta, custom_var=FALSE) { + # read data for a specific variable from a nc file + # NOTE: for some variables the data is converted to match units for SPEI + if (custom_var != FALSE) var <- custom_var + else var <- meta$short_name + id <- nc_open(meta$filename, readunlim=FALSE) + data <- ncvar_get(id, var) + # convert to required units + if (var == "time") { + tcal <- ncatt_get(id, "time", attname = "calendar") + tunits <- ncatt_get(id, "time", attname = "units") + tustr <- strsplit(tunits$value, " ") + stdate <- as.Date(data[1], origin = unlist(tustr)[3]) + nddate <- as.Date(data[length(data)], origin = unlist(tustr)[3]) + syear <- as.numeric(substr(stdate, 1, 4)) + smonth <- as.numeric(substr(stdate, 6, 7)) + eyear <- as.numeric(substr(nddate, 1, 4)) + emonth <- as.numeric(substr(nddate, 6, 7)) + return(c(syear,smonth,eyear,emonth)) + } else if (var == "lat") { + if (max(data) > 90){ + print(paste0("Latitude must be [-90,90]: min=", + min(data), " max=", max(data))) + stop("Aborting!") + } + } else if (var %in% list("tas", "tasmin", "tasmax")) { + data <- data - 273.15 + } else if (var %in% list("rsdt", "rsds")) { + data <- data * (86400.0 / 1e6) # W/(m2) to MJ/(m2 d) + } else if (var %in% list("pr", "evspsbl")) { + # TODO: pet convert temp removed + # TODO: don't convert only by name, check units or attributes + data <- convert_to_monthly(id, data) + } else if (var == "sfcWind") { + data <- data * (4.87/(log(67.9 * 10.0 - 5.42))) # U10m to U2m (*0.74778) + } else if (var == "psl") { + data <- data * 0.001 # Pa to kPa + } else if (var == "ps") { + data <- data * 0.001 # Pa to kPa + } + nc_close(id) + return(data) +} + + +group_meta <- function(metadata) { + grouped <- list() + for (meta in metadata) { + if (! meta$dataset %in% names(grouped)) { + grouped[[meta$dataset]] <- list() + } + entry <- list(meta) + names(entry) <- list(meta$filename) + entry[meta$filename] <- list(meta) + grouped[[meta$dataset]] <- append(grouped[[meta$dataset]], entry) + } + return(grouped) +} + + +leap_year <- function(year) { + return( + ifelse( + (year %% 4 == 0 & year %% 100 != 0) | year %% 400 == 0, + TRUE, FALSE + ) + ) +} + +list_default <- function(list, key, default) { + # TODO: use `key %in% names` instead of is.null? would allow explicit NULL + # list[[key]] <- if(is.null(list[[key]])) default else list[[key]] + print("defaulting") + if (!key %in% names(list)) { + print("key not existing") + print(key) + list[[key]] <- default + # print(list) + } + return(list) +} + +select_var <- function(metas, short_name) { + # NOTE: metas should be a list of metadata entries, but R seems to handle it + # differently if this list ist of length 1. + for (meta in metas) { + if (meta$short_name == short_name) { + return(meta) + } + } + stop(paste("No metadata found for", short_name, "in", meta$dataset)) +} + + +t_or_null <- function(arr) { + if (is.null(arr)) { + return(NULL) + } else { + return(t(arr)) + } +} + +update_short_name <- function(params, meta, short_name) { + # NOTE: this expects meta from reference data and replaces short_name and + # its occurance in the filename by string replacement. + # A more general get_output_filename(meta) that reconstruct fname + # would be better. + fname <- strsplit(meta$filename, "/")[[1]] + pcs <- strsplit(fname[length(fname)], "_")[[1]] + pcs[which(pcs == meta$short_name)] <- short_name + onam <- paste0(params$work_dir, "/", paste(pcs, collapse = "_")) + new_meta <- meta + new_meta$filename <- onam + new_meta$short_name <- short_name + return(new_meta) +} + + +whfcn <- function(x, ilow, ihigh){ # TODO: rename function + return(length(which(x >= ilow & x < ihigh))) +} + + +write_nc_file_like <- function( + params, meta, data, fillfloat, + short_name="spei", + units="1", + long_name="Standardized Precipitation-Evapotranspiration Index", + moty=FALSE +) { + # loads a nc file from meta as reference to copy dimensions and attributes + # from. Data, short_name and units from arguments will be used to update and + # save the file. evspsblpot will be converted from monthly to cf units. + # NOTE: cf convert skipped for debugging + new_meta <- update_short_name(params, meta, short_name) + ncid_in <- nc_open(meta$filename) + xdim <- ncid_in$dim[["lon"]] + if (length(xdim)==0) xdim <- ncid_in$dim[["longitude"]] + ydim <- ncid_in$dim[["lat"]] + if (length(ydim)==0) ydim <- ncid_in$dim[["latitude"]] + if (moty) { + tdim = ncdim_def("month", units, 1:12, longname="Month of the Year") + } else { + tdim <- ncid_in$dim[["time"]] + } + allatt <- ncatt_get(ncid_in, meta$short_name) + var_data <- ncvar_def(short_name, units, list(xdim, ydim, tdim), fillfloat) + idw <- nc_create(new_meta$filename, var_data) + # convert units back to kg/m2/s before writing file + # if (short_name == "evspsblpot") { + # # NOTE: fillfloat will be converted too. + # data <- convert_to_cf(idw, data) + # } + data[is.infinite(data)] <- fillfloat + data[is.na(data)] <- fillfloat + ncvar_put(idw, short_name, data) + # NOTE: updated loop to use names instead of counter + globat <- ncatt_get(ncid_in, 0) + for (thisattname in names(globat)){ + ncatt_put(idw, 0, thisattname, globat[[thisattname]]) + } + nc_close(idw) + nc_close(ncid_in) + return(new_meta$filename) +} \ No newline at end of file From 15ad5455f742a6459ef6fbccf923ec96c2f4883f Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 31 Jan 2025 15:26:59 +0100 Subject: [PATCH 02/66] move cdd to droughts folder --- .../source/recipes/recipe_consecdrydays.rst | 8 +- doc/sphinx/source/recipes/recipe_droughts.rst | 107 ++++++++++++++++++ .../diag_cdd.py => droughts/cdd.py} | 0 .../recipe_cdd.yml} | 15 +-- 4 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 doc/sphinx/source/recipes/recipe_droughts.rst rename esmvaltool/diag_scripts/{droughtindex/diag_cdd.py => droughts/cdd.py} (100%) rename esmvaltool/recipes/{recipe_consecdrydays.yml => droughts/recipe_cdd.yml} (75%) diff --git a/doc/sphinx/source/recipes/recipe_consecdrydays.rst b/doc/sphinx/source/recipes/recipe_consecdrydays.rst index 8235158bf8..9701ebc2b2 100644 --- a/doc/sphinx/source/recipes/recipe_consecdrydays.rst +++ b/doc/sphinx/source/recipes/recipe_consecdrydays.rst @@ -19,13 +19,13 @@ user defined number of days. Available recipes and diagnostics --------------------------------- -Recipes are stored in recipes/ +Recipes are stored in recipes/droughts/ - * recipe_consecdrydays.yml + * recipe_cdd.yml -Diagnostics are stored in diag_scripts/droughtindex/ +Diagnostics are stored in diag_scripts/droughts/ - * diag_cdd.py: calculates the longest period of consecutive dry days, and + * cdd.py: calculates the longest period of consecutive dry days, and the frequency of dry day periods longer than a user defined length diff --git a/doc/sphinx/source/recipes/recipe_droughts.rst b/doc/sphinx/source/recipes/recipe_droughts.rst new file mode 100644 index 0000000000..92d520f027 --- /dev/null +++ b/doc/sphinx/source/recipes/recipe_droughts.rst @@ -0,0 +1,107 @@ + +.. _recipes_spei: + +Droughts +======== + +Consecutive Dry Days (CDD) +-------------------------- + +Meteorological drought can in its simplest form be described by a lack of +precipitation. The Consecutive Dry Days (CDD) diagnostic calculates the longest +period frequency of dry days based on user defined thresholds. + +More details and usage examples can be found in the +:ref:`CDD recipe documentation `. + +Standardized Precipitation-Evapotranspiration Index (SPEI) +---------------------------------------------------------- + +Meteorological droughts are often described using the Standardized Precipitation +Index (SPI; McKee et al, 1993), which in a standardized way describes local +precipitation anomalies. + +Because SPI does not account for evaporation from the ground, it lacks one it +lacks one component of the water fluxes at the surface and is therefore not +compatible with the concept of hydrological or agricultural drought. The +Standardized Precipitation-Evapotranspiration Index (SPEI; Vicente-Serrano et +al., 2010) has been developed to also account for temperature effects on the +surface water fluxes, by estimating the Potential Evapo-Transpiration (PET). + +More details and usage examples can be found in the +:ref:`SPEI recipe documentation `. + +Available recipes and diagnostics +--------------------------------- + + +Recipes stored in ``recipes/droughts/`` + + * recipe_cdd.yml + * recipe_spi.yml + * recipe_spei.yml + + +Diagnostics for index calculation:stored in ``diag_scripts/droughts/`` + + * cdd.py: calculate Consecutive Dry Days + * pet.R: calculate Potential Evapo-Transpiration + * spi.R: calculate Standardized Precipitation Index + * spei.R: calculate Standardized Evapo-Transpiration Index + + +User settings +------------- +The configuration that can be used in the recipe is documented in the +corresponding API documentation for python diagnostics (linked in diagnostic +list above). The R diagnostics are documented in +:ref:`SPEI recipe documentation `. + + + +Variables +--------- + +* pr (atmos, daily/monthly mean, time latitude longitude) +* tas (atmos, monthly mean, time latitude longitude) +* tasmin (atmos, monthly mean, time latitude longitude) +* tasmax (atmos, monthly mean, time latitude longitude) +* sfcWind (atmos, monthly mean, time latitude longitude) +* rsds (atmos, monthly mean, time latitude longitude) +* clt (atmos, monthly mean, time latitude longitude) +* hurs (atmos, monthly mean, time latitude longitude) +* ps (atmos, monthly mean, time latitude longitude) + + + +References +---------- +* McKee, T. B., Doesken, N. J., & Kleist, J. (1993). The relationship of drought frequency and duration to time scales. In Proceedings of the 8th Conference on Applied Climatology (Vol. 17, No. 22, pp. 179-183). Boston, MA: American Meteorological Society. + +* Vicente-Serrano, S. M., Beguería, S., & López-Moreno, J. I. (2010). A multiscalar drought index sensitive to global warming: the standardized precipitation evapotranspiration index. Journal of climate, 23(7), 1696-1718. + + +Example plots +------------- + +.. _fig_consecdrydays: +.. figure:: /recipes/figures/consecdrydays/consec_example_freq.png + :align: center + :width: 14cm + + Example of the number of occurrences with consecutive dry days of more than five days in the period 2001 to 2002 for the CMIP5 model bcc-csm1-1-m. + + +.. _fig_spei: +.. figure:: /recipes/figures/spei/histogram_spei.png + :align: center + :width: 14cm + + (top) Probability distribution of the standardized precipitation-evapotranspiration index of a sub-set of the CMIP5 models, and (bottom) bias relative to the CRU reference data set. + +.. _fig_spi: +.. figure:: /recipes/figures/spei/histogram_spi.png + :align: center + :width: 14cm + + (top) Probability distribution of the standardized precipitation index of a sub-set of the CMIP5 models, and (bottom) bias relative to the CRU reference data set. diff --git a/esmvaltool/diag_scripts/droughtindex/diag_cdd.py b/esmvaltool/diag_scripts/droughts/cdd.py similarity index 100% rename from esmvaltool/diag_scripts/droughtindex/diag_cdd.py rename to esmvaltool/diag_scripts/droughts/cdd.py diff --git a/esmvaltool/recipes/recipe_consecdrydays.yml b/esmvaltool/recipes/droughts/recipe_cdd.yml similarity index 75% rename from esmvaltool/recipes/recipe_consecdrydays.yml rename to esmvaltool/recipes/droughts/recipe_cdd.yml index 0de3357be5..d3222eddec 100644 --- a/esmvaltool/recipes/recipe_consecdrydays.yml +++ b/esmvaltool/recipes/droughts/recipe_cdd.yml @@ -3,34 +3,29 @@ --- documentation: title: Consecutive dry days - description: | - Recipe to calculate consecutive dry days - + Example recipe to calculate consecutive dry days authors: - berg_peter - projects: - c3s-magic - maintainer: - - unmaintained - + - lindenlaub_lukas references: - acknow_project datasets: - - {dataset: bcc-csm1-1-m, project: CMIP5, mip: day, exp: historical, ensemble: r1i1p1, start_year: 2001, end_year: 2002} + - {dataset: bcc-csm1-1-m, project: CMIP5, mip: day, exp: historical, + ensemble: r1i1p1, start_year: 2001, end_year: 2002} diagnostics: - dry_days: description: Calculating number of dry days. variables: pr: scripts: consecutive_dry_days: - script: droughtindex/diag_cdd.py + script: droughts/cdd.py dryindex: 'cdd' plim: 1 frlim: 5 From 51fca60c0434a29a68264415e2a1d65345d75252 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 4 Feb 2025 16:09:07 +0100 Subject: [PATCH 03/66] force nc4, spi specialcase of spei, save any coefficients --- esmvaltool/diag_scripts/droughts/pet.R | 4 +- esmvaltool/diag_scripts/droughts/spei.R | 135 +++++++++++++---------- esmvaltool/diag_scripts/droughts/utils.R | 33 +++++- 3 files changed, 105 insertions(+), 67 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/pet.R b/esmvaltool/diag_scripts/droughts/pet.R index e3b04955be..27d5d79729 100644 --- a/esmvaltool/diag_scripts/droughts/pet.R +++ b/esmvaltool/diag_scripts/droughts/pet.R @@ -25,7 +25,7 @@ # # NOTE: add latitude and longitude as valid dim coords in addition to lat/lon # -# NOTE: added weigel_katja, ruhe_lukas to authors +# NOTE: added weigel_katja, lindenlaub_lukas to authors # # NOTE: Provenance is written for each output file within the loop over datasets # instead of afterwards (was it a bug?) @@ -35,7 +35,7 @@ # # NOTE: Remove pr as reference, since pr is not mandatory input for all methods. # -# Authors: [Peter Berg, Katja Weigel, Lukas Ruhe] +# Authors: [Peter Berg, Katja Weigel, Lukas Lindenlaub] library(yaml) library(ncdf4) diff --git a/esmvaltool/diag_scripts/droughts/spei.R b/esmvaltool/diag_scripts/droughts/spei.R index 7c4d8081f9..316a36a5b8 100644 --- a/esmvaltool/diag_scripts/droughts/spei.R +++ b/esmvaltool/diag_scripts/droughts/spei.R @@ -5,7 +5,7 @@ # functionality as the previous diag_spei.R as a special case. # Calculation of PET and plotting of results is covered in seperate diagnostics. # -# Authors: [Peter Berg, Katja Weigel, Lukas Ruhe] +# Authors: [Peter Berg, Katja Weigel, Lukas Lindenlaub] # # CHANGELOG: # @@ -51,10 +51,14 @@ # NOTE: set log-Logistic as default distribution if nothing is given in the # recipe. Missing distribution raised an unclear error before. # +# NOTE: forced to write netcdf-4 format (v4) to ensure compatibility with +# other tools +# # OPTIONS: # # write_coeffs: boolean, default FALSE # write xi, alpha and kappa to netcdf files +# TODO: hardcoded for this 3 parameters. Only works for log-Logistic yet. # write_wb: boolean, default FALSE # write water balance to netcdf file # short_name_pet: string, default "evspsblpot" @@ -75,33 +79,33 @@ library(R.utils) setwd(dirname(commandArgs(asValues=TRUE)$file)) source("utils.R") -fill_refperiod <- function(params, tsvec) { - params <- list_default(params, "refstart_year", tsvec[1]) - params <- list_default(params, "refstart_month", tsvec[2]) - params <- list_default(params, "refend_year", tsvec[3]) - params <- list_default(params, "refend_month", tsvec[4]) +fill_refperiod <- function(cfg, tsvec) { + cfg <- list_default(cfg, "refstart_year", tsvec[1]) + cfg <- list_default(cfg, "refstart_month", tsvec[2]) + cfg <- list_default(cfg, "refend_year", tsvec[3]) + cfg <- list_default(cfg, "refend_month", tsvec[4]) } # ---------------------------------------------------------------------------- # # Script starts here --------------------------------------------------------- # # ---------------------------------------------------------------------------- # -params <- read_yaml(commandArgs(trailingOnly = TRUE)[1]) -params <- list_default(params, "write_coeffs", FALSE) -params <- list_default(params, "write_wb", FALSE) -params <- list_default(params, "short_name_pet", "evspsblpot") -params <- list_default(params, "distribution", "log-Logistic") -dir.create(params$work_dir, recursive = TRUE) -dir.create(params$plot_dir, recursive = TRUE) +cfg <- read_yaml(commandArgs(trailingOnly = TRUE)[1]) +cfg <- list_default(cfg, "write_coeffs", FALSE) +cfg <- list_default(cfg, "write_wb", FALSE) +cfg <- list_default(cfg, "short_name_pet", "evspsblpot") +cfg <- list_default(cfg, "distribution", "log-Logistic") +dir.create(cfg$work_dir, recursive = TRUE) +dir.create(cfg$plot_dir, recursive = TRUE) fillfloat <- 1.e+20 as.single(fillfloat) -provenance_file <- paste0(params$run_dir, "/", "diagnostic_provenance.yml") -meta_file <- paste0(params$work_dir, "/metadata.yml") +provenance_file <- paste0(cfg$run_dir, "/", "diagnostic_provenance.yml") +meta_file <- paste0(cfg$work_dir, "/metadata.yml") provenance <- list() meta <- list() # collect output files metadata print("--- Load input meta") ancestor_meta <- list() -for (anc in params$input_files){ +for (anc in cfg$input_files){ if (!endsWith(anc, 'metadata.yml')){anc <- paste0(anc, "/metadata.yml")} ancestor_meta <- append(ancestor_meta, read_yaml(anc)) } @@ -112,20 +116,38 @@ for (dataset in names(grouped_meta)){ print(paste("-- processing dataset:", dataset)) metas <- grouped_meta[[dataset]] # list of files for this dataset mask <- get_merged_mask(metas) # use mask per dataset - # START SPEI CALC pr_meta <- select_var(metas, "pr") pr <- get_var_from_nc(pr_meta) lat <- get_var_from_nc(pr_meta, custom_var="lat") tsvec <- get_var_from_nc(pr_meta, custom_var="time") - pet <- get_var_from_nc(select_var(metas, params$short_name_pet)) - pme <- pr - pet - if (params$write_wb) { - filename_wb <- write_nc_file_like(params, pr_meta, pme, fillfloat, short_name="wb") + pet_meta <- select_var(metas, cfg$short_name_pet, strict=FALSE) + if (is.null(pet_meta)) { + print("PET not found, calculating SPI") + cfg$indexname <- "SPI" + pme <- pr + } else { + cfg$indexname <- "SPEI" + pet <- get_var_from_nc(pet_meta) + pme <- pr - pet + } + if (cfg$write_wb) { + filename_wb <- write_nc_file_like(cfg, pr_meta, pme, fillfloat, short_name="wb") } - fill_refperiod(params, tsvec) + fill_refperiod(cfg, tsvec) pme_spei <- pme * NA - coeffs <- array(numeric(), c(3, dim(pme)[1], dim(pme)[2], 12)) # xi, alpha, kappa + # coeffs <- array(numeric(), c(3, dim(pme)[1], dim(pme)[2], 12)) # xi, alpha, kappa + # get dimensions and attributes from pr to save coeffs in similar format + # prepare cube fot coeffs + # if (cfg$write_coeffs) { + # dims <- get_dims_from_nc(pr_meta) + # month_dim <- ncdim_def("month", 1, 1:12, longname="Month of the Year") + # dims <- append(dims, list(month_dim)) + # attrs <- get_attrs_from_nc(pr_meta) + # coeffs <- list() + # } + coeffs <- list() + # TODO: save from results$coefficients (dimension depends on distribution) for (i in 1:dim(pme)[1]){ wh <- which(!is.na(mask[i,])) if (length(wh) > 1){ @@ -133,58 +155,51 @@ for (dataset in names(grouped_meta)){ ts_data <- ts(t(tmp), freq=12, start=c(tsvec[1], tsvec[2])) spei_results <- spei( ts_data, - params$smooth_month, + cfg$smooth_month, na.rm = TRUE, - distribution = params$distribution, - ref.start = c(params$refstart_year, params$refstart_month), - ref.end = c(params$refend_year, params$refend_month) + distribution = cfg$distribution, + ref.start = c(cfg$refstart_year, cfg$refstart_month), + ref.end = c(cfg$refend_year, cfg$refend_month) ) - coeffs[, i, wh, ] <- spei_results$coefficients pme_spei[i, wh, ] <- t(spei_results$fitted) + if (cfg$write_coeffs) { + for (c_name in rownames(spei_results$coefficients)){ + if (is.null(coeffs[[c_name]])){ + coeffs[[c_name]] <- array(NA, dim=c(dim(pme)[1], dim(pme)[2], 12)) + } + coeffs[[c_name]][i, wh, ] <- spei_results$coefficients[c_name, ,] + } + } } } pme_spei[pme_spei > 10000] <- NA # replaced with fillfloat in write function - filename <- write_nc_file_like(params, pr_meta, pme_spei, fillfloat) - if (params$write_coeffs) { - filename_xi <- write_nc_file_like( - params, pr_meta, coeffs[1, , ,], fillfloat, - short_name = "xi", - moty=TRUE) - meta[[filename_xi]] <- list( - filename=filename_xi, - short_name="xi", - dataset=dataset) - filename_alpha <- write_nc_file_like( - params, pr_meta, coeffs[2, , ,], fillfloat, - short_name = "alpha", - moty = TRUE) - meta[[filename_alpha]] <- list( - filename=filename_xi, - short_name="alpha", - dataset=dataset) - filename_kappa <- write_nc_file_like( - params, pr_meta, coeffs[3, , ,], fillfloat, - short_name = "kappa", - moty=TRUE) - meta[[filename_kappa]] <- list( - filename=filename_kappa, - short_name="kappa", - dataset=dataset) + filename <- write_nc_file_like(cfg, pr_meta, pme_spei, fillfloat, short_name=cfg$indexname) + if (cfg$write_coeffs) { + print("Coeffs are:") + for (c_name in names(coeffs)){ + print(c_name) + filename_c <- write_nc_file_like(cfg, pr_meta, coeffs[[c_name]], fillfloat, short_name=c_name, moty=TRUE) + meta[[filename_c]] <- list( + filename = filename_c, + short_name = c_name, + dataset = dataset + ) + } } print("-- prepare metadata for output") meta[[filename]] <- list( filename = filename, - short_name = "spei", + short_name = tolower(cfg$indexname), dataset = dataset) - xprov$caption <- "SPEI index per grid point." + xprov$caption <- paste(cfg$indexname, " index per grid point.") # generate metadata.yml input_meta = select_var(metas, "pr") input_meta$filename = filename - input_meta$short_name = "spei" - input_meta$long_name = "SPEI" + input_meta$short_name = tolower(cfg$indexname) + input_meta$long_name = cfg$indexname input_meta$units = "1" - input_meta$index = "SPEI" + input_meta$index = cfg$indexname meta[[filename]] <- input_meta # meta[[filename]][["index"]] <- "SPEI" for (t in 1:dim(pme)[3]) { @@ -195,5 +210,5 @@ for (dataset in names(grouped_meta)){ } # end of big dataset loop provenance[[filename]] <- xprov -write_yaml(provenance, provenance_file) +# write_yaml(provenance, provenance_file) write_yaml(meta, meta_file) \ No newline at end of file diff --git a/esmvaltool/diag_scripts/droughts/utils.R b/esmvaltool/diag_scripts/droughts/utils.R index 5d51d33b56..f4e4f2ae47 100644 --- a/esmvaltool/diag_scripts/droughts/utils.R +++ b/esmvaltool/diag_scripts/droughts/utils.R @@ -32,7 +32,7 @@ xprov <- list( ancestors = list(""), - authors = list("berg_peter", "weigel_katja", "ruhe_lukas"), + authors = list("berg_peter", "weigel_katja", "lindenlaub_lukas"), references = list("vicente10jclim"), projects = list("c3s-magic"), caption = "", @@ -122,6 +122,24 @@ convert_to_cf <- function(id, v) { return(v) } +get_dims_from_nc <- function(meta) { + id <- nc_open(meta$filename) + xdim <- ncvar_dim(id, "lon") + if (length(xdim)==0) xdim <- ncvar_dim(id, "longitude") + ydim <- ncvar_dim(id, "lat") + if (length(ydim)==0) ydim <- ncvar_dim(id, "latitude") + tdim <- ncvar_dim(id, "time") + nc_close(id) + return(list(xdim, ydim, tdim)) +} + +get_attrs_from_nc <- function(meta) { + id <- nc_open(meta$filename) + allatt <- ncatt_get(id, meta$short_name) + nc_close(id) + return(allatt) +} + get_merged_mask <- function(metas, variables="all") { first <- TRUE for(meta in metas){ @@ -213,7 +231,6 @@ leap_year <- function(year) { list_default <- function(list, key, default) { # TODO: use `key %in% names` instead of is.null? would allow explicit NULL # list[[key]] <- if(is.null(list[[key]])) default else list[[key]] - print("defaulting") if (!key %in% names(list)) { print("key not existing") print(key) @@ -223,7 +240,7 @@ list_default <- function(list, key, default) { return(list) } -select_var <- function(metas, short_name) { +select_var <- function(metas, short_name, strict=TRUE) { # NOTE: metas should be a list of metadata entries, but R seems to handle it # differently if this list ist of length 1. for (meta in metas) { @@ -231,7 +248,12 @@ select_var <- function(metas, short_name) { return(meta) } } - stop(paste("No metadata found for", short_name, "in", meta$dataset)) + if (strict) { + stop(paste("No metadata found for", short_name, "in", meta$dataset)) + } else { + print(paste("No metadata found for", short_name, "in", meta$dataset)) + return(NULL) + } } @@ -276,6 +298,7 @@ write_nc_file_like <- function( # save the file. evspsblpot will be converted from monthly to cf units. # NOTE: cf convert skipped for debugging new_meta <- update_short_name(params, meta, short_name) + print(paste("create new file:", new_meta$filename)) ncid_in <- nc_open(meta$filename) xdim <- ncid_in$dim[["lon"]] if (length(xdim)==0) xdim <- ncid_in$dim[["longitude"]] @@ -288,7 +311,7 @@ write_nc_file_like <- function( } allatt <- ncatt_get(ncid_in, meta$short_name) var_data <- ncvar_def(short_name, units, list(xdim, ydim, tdim), fillfloat) - idw <- nc_create(new_meta$filename, var_data) + idw <- nc_create(new_meta$filename, var_data, force_v4=TRUE) # convert units back to kg/m2/s before writing file # if (short_name == "evspsblpot") { # # NOTE: fillfloat will be converted too. From dd0491d6f91acd2572aa92d8627dfd6cfd8c2303 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 4 Feb 2025 16:10:09 +0100 Subject: [PATCH 04/66] example recipe --- esmvaltool/recipes/droughts/recipe_spei.yml | 62 +++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 esmvaltool/recipes/droughts/recipe_spei.yml diff --git a/esmvaltool/recipes/droughts/recipe_spei.yml b/esmvaltool/recipes/droughts/recipe_spei.yml new file mode 100644 index 0000000000..f365ca9e46 --- /dev/null +++ b/esmvaltool/recipes/droughts/recipe_spei.yml @@ -0,0 +1,62 @@ + +# ESMValTool +# recipe_spei.yml +--- +documentation: + title: SPI and SPEI Drought Indices + + description: | + Example calculates for the SPI and SPEI drought indices + + authors: + - berg_peter + - lindenlaub_lukas + + maintainer: + - weigel_katja + + projects: + - c3s-magic + + references: + - acknow_project + +CMIP6: &cmip6 {project: CMIP6, mip: Amon, ensemble: r1i1p1f1, grid: gn} + +datasets: +- {<<: *cmip6, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS} + +preprocessors: + preprocessor: + regrid: + target_grid: reference_dataset + scheme: linear + +diagnostics: + diagnostic: + description: Calculating SPI and SPEI index + variables: + pr: &var + reference_dataset: ACCESS-CM2 + preprocessor: preprocessor + start_year: 2000 + end_year: 2005 + mip: Amon + exp: [historical] + tasmin: *var + tasmax: *var + scripts: + spi: + script: droughts/spei.R + ancestors: [pr] + distribution: Gamma + smooth_month: 6 + write_coeffs: True + pet: + pet_type: "Hargreaves" # "Penman_clt" + script: droughts/pet.R + spei: + script: droughts/spei.R + ancestors: [pr, pet] + distribution: log-Logistic + smooth_month: 6 From 891066ebd0e6cf4cc767ae9154848715d86d406e Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Thu, 6 Feb 2025 14:49:13 +0100 Subject: [PATCH 05/66] remove prints and comments --- doc/sphinx/source/recipes/index.rst | 1 + doc/sphinx/source/recipes/recipe_droughts.rst | 62 +++++++------------ esmvaltool/diag_scripts/droughts/spei.R | 20 +----- 3 files changed, 25 insertions(+), 58 deletions(-) diff --git a/doc/sphinx/source/recipes/index.rst b/doc/sphinx/source/recipes/index.rst index 33fbb13126..01eb8de78b 100644 --- a/doc/sphinx/source/recipes/index.rst +++ b/doc/sphinx/source/recipes/index.rst @@ -40,6 +40,7 @@ Atmosphere recipe_consecdrydays recipe_deangelis15nat recipe_diurnal_temperature_index + recipe_droughts recipe_eady_growth_rate recipe_extreme_events recipe_extreme_index diff --git a/doc/sphinx/source/recipes/recipe_droughts.rst b/doc/sphinx/source/recipes/recipe_droughts.rst index 92d520f027..a03d0e8096 100644 --- a/doc/sphinx/source/recipes/recipe_droughts.rst +++ b/doc/sphinx/source/recipes/recipe_droughts.rst @@ -1,5 +1,5 @@ -.. _recipes_spei: +.. _recipes_droughts: Droughts ======== @@ -12,7 +12,7 @@ precipitation. The Consecutive Dry Days (CDD) diagnostic calculates the longest period frequency of dry days based on user defined thresholds. More details and usage examples can be found in the -:ref:`CDD recipe documentation `. +:ref:`CDD recipe documentation `. Standardized Precipitation-Evapotranspiration Index (SPEI) ---------------------------------------------------------- @@ -29,33 +29,36 @@ al., 2010) has been developed to also account for temperature effects on the surface water fluxes, by estimating the Potential Evapo-Transpiration (PET). More details and usage examples can be found in the -:ref:`SPEI recipe documentation `. +:ref:`SPEI recipe documentation `. + Available recipes and diagnostics --------------------------------- -Recipes stored in ``recipes/droughts/`` +Recipes: + +* :ref:`droughts/recipe_cdd.yml ` +* :ref:`droughts/recipe_spei.yml ` +* :ref:`recipe_martin18grl.yml ` - * recipe_cdd.yml - * recipe_spi.yml - * recipe_spei.yml +Diagnostics are stored in ``diag_scripts/droughts/``. General index +calculattions are done by: -Diagnostics for index calculation:stored in ``diag_scripts/droughts/`` +* cdd.py: calculate Consecutive Dry Days +* pet.R: calculate Potential Evapo-Transpiration +* spei.R: calculate Standardized Evapo-Transpiration Index - * cdd.py: calculate Consecutive Dry Days - * pet.R: calculate Potential Evapo-Transpiration - * spi.R: calculate Standardized Precipitation Index - * spei.R: calculate Standardized Evapo-Transpiration Index +The recipes might use additional diagnostics, see the corresponding recipe +documentation for more details. User settings ------------- -The configuration that can be used in the recipe is documented in the -corresponding API documentation for python diagnostics (linked in diagnostic -list above). The R diagnostics are documented in -:ref:`SPEI recipe documentation `. +The configuration that can be used in the recipes is documented in the +corresponding recipe or diagnostics API documentation linked in the list above. +:ref:`SPEI recipe documentation `. @@ -76,32 +79,9 @@ Variables References ---------- +* Martin, E.R. (2018). Future Projections of Global Pluvial and Drought Event Characteristics. Geophysical Research Letters, 45, 11913-11920. + * McKee, T. B., Doesken, N. J., & Kleist, J. (1993). The relationship of drought frequency and duration to time scales. In Proceedings of the 8th Conference on Applied Climatology (Vol. 17, No. 22, pp. 179-183). Boston, MA: American Meteorological Society. * Vicente-Serrano, S. M., Beguería, S., & López-Moreno, J. I. (2010). A multiscalar drought index sensitive to global warming: the standardized precipitation evapotranspiration index. Journal of climate, 23(7), 1696-1718. - -Example plots -------------- - -.. _fig_consecdrydays: -.. figure:: /recipes/figures/consecdrydays/consec_example_freq.png - :align: center - :width: 14cm - - Example of the number of occurrences with consecutive dry days of more than five days in the period 2001 to 2002 for the CMIP5 model bcc-csm1-1-m. - - -.. _fig_spei: -.. figure:: /recipes/figures/spei/histogram_spei.png - :align: center - :width: 14cm - - (top) Probability distribution of the standardized precipitation-evapotranspiration index of a sub-set of the CMIP5 models, and (bottom) bias relative to the CRU reference data set. - -.. _fig_spi: -.. figure:: /recipes/figures/spei/histogram_spi.png - :align: center - :width: 14cm - - (top) Probability distribution of the standardized precipitation index of a sub-set of the CMIP5 models, and (bottom) bias relative to the CRU reference data set. diff --git a/esmvaltool/diag_scripts/droughts/spei.R b/esmvaltool/diag_scripts/droughts/spei.R index 316a36a5b8..645cedc396 100644 --- a/esmvaltool/diag_scripts/droughts/spei.R +++ b/esmvaltool/diag_scripts/droughts/spei.R @@ -122,7 +122,6 @@ for (dataset in names(grouped_meta)){ tsvec <- get_var_from_nc(pr_meta, custom_var="time") pet_meta <- select_var(metas, cfg$short_name_pet, strict=FALSE) if (is.null(pet_meta)) { - print("PET not found, calculating SPI") cfg$indexname <- "SPI" pme <- pr } else { @@ -136,18 +135,7 @@ for (dataset in names(grouped_meta)){ fill_refperiod(cfg, tsvec) pme_spei <- pme * NA - # coeffs <- array(numeric(), c(3, dim(pme)[1], dim(pme)[2], 12)) # xi, alpha, kappa - # get dimensions and attributes from pr to save coeffs in similar format - # prepare cube fot coeffs - # if (cfg$write_coeffs) { - # dims <- get_dims_from_nc(pr_meta) - # month_dim <- ncdim_def("month", 1, 1:12, longname="Month of the Year") - # dims <- append(dims, list(month_dim)) - # attrs <- get_attrs_from_nc(pr_meta) - # coeffs <- list() - # } coeffs <- list() - # TODO: save from results$coefficients (dimension depends on distribution) for (i in 1:dim(pme)[1]){ wh <- which(!is.na(mask[i,])) if (length(wh) > 1){ @@ -175,9 +163,7 @@ for (dataset in names(grouped_meta)){ pme_spei[pme_spei > 10000] <- NA # replaced with fillfloat in write function filename <- write_nc_file_like(cfg, pr_meta, pme_spei, fillfloat, short_name=cfg$indexname) if (cfg$write_coeffs) { - print("Coeffs are:") for (c_name in names(coeffs)){ - print(c_name) filename_c <- write_nc_file_like(cfg, pr_meta, coeffs[[c_name]], fillfloat, short_name=c_name, moty=TRUE) meta[[filename_c]] <- list( filename = filename_c, @@ -201,14 +187,14 @@ for (dataset in names(grouped_meta)){ input_meta$units = "1" input_meta$index = cfg$indexname meta[[filename]] <- input_meta - # meta[[filename]][["index"]] <- "SPEI" + meta[[filename]][["index"]] <- cfg$indexname for (t in 1:dim(pme)[3]) { tmp <- pme_spei[, , t] tmp[is.na(mask)] <- NA pme_spei[, , t] <- tmp } -} # end of big dataset loop +} # end of dataset loop provenance[[filename]] <- xprov -# write_yaml(provenance, provenance_file) +write_yaml(provenance, provenance_file) write_yaml(meta, meta_file) \ No newline at end of file From c0140bc809ce027812849b512f0f1b1c4e744947 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Thu, 6 Feb 2025 15:27:01 +0100 Subject: [PATCH 06/66] copy martin18 diags --- .../diag_scripts/droughts/collect_drought.py | 151 ++++ .../droughts/collect_drought_func.py | 650 ++++++++++++++++++ 2 files changed, 801 insertions(+) create mode 100644 esmvaltool/diag_scripts/droughts/collect_drought.py create mode 100644 esmvaltool/diag_scripts/droughts/collect_drought_func.py diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py new file mode 100644 index 0000000000..13184c3b0b --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Collects SPI or SPEI data comparing models and observations/reanalysis. + +Applies drought characteristics based on Martin (2018). + +############################################################################### +droughtindex/collect_drought_obs_multi.py +Author: Katja Weigel (IUP, Uni Bremen, Germany) +EVal4CMIP project +############################################################################### + +Description +----------- + Collects data produced by diag_save_spi.R or diad_save_spei_all.R + to plot/process them further. + +Configuration options +--------------------- + indexname: "SPI" or "SPEI" + +############################################################################### + +Updates: +- changed the filename pattern search to read the metadata.yml produced by + new spei.R diagnostic. +""" +import os +import glob +import iris +import numpy as np +import esmvaltool.diag_scripts.shared as e +import esmvaltool.diag_scripts.shared.names as n +from esmvaltool.diag_scripts.droughts.collect_drought_func import ( + _get_drought_data, _plot_multi_model_maps, _plot_single_maps, + get_latlon_index, plot_time_series_spei) + + +def _get_and_plot_obsmodel(cfg, cube, all_drought, all_drought_obs, + input_filenames): + """Calculate multi-model mean and compare it to observations.""" + lats = cube.coord('latitude').points + lons = cube.coord('longitude').points + all_drought_hist_mean = np.nanmean(all_drought, axis=-1) + perc_diff = ((all_drought_obs - all_drought_hist_mean) + / (all_drought_obs + all_drought_hist_mean) * 200) + + # Plot multi model means + _plot_multi_model_maps(cfg, all_drought_hist_mean, [lats, lons], + input_filenames, 'Historic') + _plot_multi_model_maps(cfg, all_drought_obs, [lats, lons], + input_filenames, 'Observations') + _plot_multi_model_maps(cfg, perc_diff, [lats, lons], + input_filenames, 'Difference') + + +def ini_time_series_plot(cfg, cube, area, filename): + """Set up cube for time series plot.""" + coords = ('longitude', 'latitude') + if area == 'Bremen': + index_lat = get_latlon_index(cube.coord('latitude').points, 52, 53) + index_lon = get_latlon_index(cube.coord('longitude').points, 7, 9) + elif area == 'Nigeria': + index_lat = get_latlon_index(cube.coord('latitude').points, 7, 9) + index_lon = get_latlon_index(cube.coord('longitude').points, 8, 10) + + cube_grid_areas = iris.analysis.cartography.area_weights( + cube[:, index_lat[0]:index_lat[-1] + 1, + index_lon[0]:index_lon[-1] + 1]) + cube4 = ((cube[:, index_lat[0]:index_lat[-1] + 1, + index_lon[0]:index_lon[-1] + + 1]).collapsed(coords, iris.analysis.MEAN, + weights=cube_grid_areas)) + + plot_time_series_spei(cfg, cube4, filename, area) + + +def main(cfg): + """Run the diagnostic. + + Parameters : + + ------------ + cfg : dict + Configuration dictionary of the recipe. + + """ + # Read input data + input_filenames = (cfg[n.INPUT_FILES])[0] + "/*_" + \ + (cfg['indexname']).lower() + "_*.nc" + first_run = 1 + iobs = 0 + + # For loop: "glob.iglob" findes all files which match the + # pattern of "input_filenames". + # It writes the resulting exact file name onto spei_file + # and runs the following indented lines for all possibilities + # for spei_file. + for iii, spei_file in enumerate(glob.iglob(input_filenames)): + # Loads the file into a special structure (IRIS cube) + cube = iris.load(spei_file)[0] + cube.coord('latitude').guess_bounds() + cube.coord('longitude').guess_bounds() + # time = cube.coord('time') + + # The data are 3D (time x latitude x longitude) + # To plot them, we need to reduce them to 2D or 1D + # First here is an average over time, i.e. data you need + # to plot the average over the time series of SPEI on a map + cube2 = cube.collapsed('time', iris.analysis.MEAN) + + # This is only possible because all data must be on the same grid + if first_run == 1: + files = os.listdir((cfg[n.INPUT_FILES])[0]) + ncfiles = list(filter(lambda f: f.endswith('.nc'), files)) + shape_all = cube2.data.shape + (4,) + \ + (len(ncfiles) - 1, ) + all_drought = np.full(shape_all, np.nan) + first_run = 0 + + ini_time_series_plot(cfg, cube, 'Bremen', spei_file) + ini_time_series_plot(cfg, cube, 'Nigeria', spei_file) + + drought_show = _get_drought_data(cfg, cube) + + # Distinguish between model and observations/reanalysis. + # Collest all model data in one array. + try: + dataset_name = cube.metadata.attributes['model_id'] + all_drought[:, :, :, iii - iobs] = drought_show.data + except KeyError: + try: + dataset_name = cube.metadata.attributes['source_id'] + all_drought[:, :, :, iii - iobs] = drought_show.data + except KeyError: + dataset_name = 'Observations' + all_drought_obs = drought_show.data + iobs = 1 + print(dataset_name) + _plot_single_maps(cfg, cube2, drought_show, 'Historic', spei_file) + + # Calculating multi model mean and plot it + _get_and_plot_obsmodel(cfg, cube, all_drought, all_drought_obs, + glob.glob(input_filenames)) + + +if __name__ == '__main__': + with e.run_diagnostic() as config: + main(config) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought_func.py b/esmvaltool/diag_scripts/droughts/collect_drought_func.py new file mode 100644 index 0000000000..908212c6c7 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/collect_drought_func.py @@ -0,0 +1,650 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Drought characteristics and plots based on Martin (2018). + +############################################################################### +droughtindex/collect_drought_obs_multi.py +Author: Katja Weigel, Kemisola Adeniyi (IUP, Uni Bremen, Germany) +EVal4CMIP project +############################################################################### + +Description +----------- + Functions for: + collect_drought_obs_multi.py and droughtindex/collect_drought_model.py. + +Configuration options +--------------------- + None + +############################################################################### + +""" + + +import logging +import os +from pprint import pformat +import numpy as np +import iris +from iris.analysis import Aggregator +import cartopy.crs as cart +import matplotlib.pyplot as plt +import matplotlib.dates as mda +from esmvaltool.diag_scripts.shared import (ProvenanceLogger, + get_diagnostic_filename, + get_plot_filename) + +logger = logging.getLogger(os.path.basename(__file__)) + + +def _get_data_hlp(axis, data, ilat, ilon): + """Get data_help dependend on axis.""" + if axis == 0: + data_help = (data[:, ilat, ilon])[:, 0] + elif axis == 1: + data_help = (data[ilat, :, ilon])[:, 0] + elif axis == 2: + data_help = data[ilat, ilon, :] + + return data_help + + +def _get_drought_data(cfg, cube): + """Prepare data and calculate characteristics.""" + # make a new cube to increase the size of the data array + # Make an aggregator from the user function. + spell_no = Aggregator('spell_count', + count_spells, + units_func=lambda units: 1) + new_cube = _make_new_cube(cube) + + # calculate the number of drought events and their average duration + drought_show = new_cube.collapsed('time', spell_no, + threshold=cfg['threshold']) + drought_show.rename('Drought characteristics') + # length of time series + time_length = len(new_cube.coord('time').points) / 12.0 + # Convert number of droughtevents to frequency (per year) + drought_show.data[:, :, 0] = drought_show.data[:, :, + 0] / time_length + return drought_show + + +def _provenance_map_spei(cfg, name_dict, spei, dataset_name): + """Set provenance for plot_map_spei.""" + caption = 'Global map of ' + \ + name_dict['drought_char'] + \ + ' [' + name_dict['unit'] + '] ' + \ + 'based on ' + cfg['indexname'] + '.' + + if cfg['indexname'].lower == "spei": + set_refs = ['martin18grl', 'vicente10jclim', ] + elif cfg['indexname'].lower == "spi": + set_refs = ['martin18grl', 'mckee93proc', ] + else: + set_refs = ['martin18grl', ] + + provenance_record = get_provenance_record([name_dict['input_filenames']], + caption, + ['global'], + set_refs) + + diagnostic_file = get_diagnostic_filename(cfg['indexname'] + '_map' + + name_dict['add_to_filename'] + + '_' + + dataset_name, cfg) + plot_file = get_plot_filename(cfg['indexname'] + '_map' + + name_dict['add_to_filename'] + + '_' + + dataset_name, cfg) + + logger.info("Saving analysis results to %s", diagnostic_file) + + cubesave = cube_to_save_ploted(spei, name_dict) + iris.save(cubesave, target=diagnostic_file) + + logger.info("Recording provenance of %s:\n%s", diagnostic_file, + pformat(provenance_record)) + with ProvenanceLogger(cfg) as provenance_logger: + provenance_logger.log(plot_file, provenance_record) + provenance_logger.log(diagnostic_file, provenance_record) + + +def _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames): + """Set provenance for plot_map_spei_multi.""" + caption = 'Global map of the multi-model mean of ' + \ + data_dict['drought_char'] + \ + ' [' + data_dict['unit'] + '] ' + \ + 'based on ' + cfg['indexname'] + '.' + + if cfg['indexname'].lower == "spei": + set_refs = ['martin18grl', 'vicente10jclim', ] + elif cfg['indexname'].lower == "spi": + set_refs = ['martin18grl', 'mckee93proc', ] + else: + set_refs = ['martin18grl', ] + + provenance_record = get_provenance_record(input_filenames, caption, + ['global'], + set_refs) + + diagnostic_file = get_diagnostic_filename(cfg['indexname'] + '_map' + + data_dict['filename'] + '_' + + data_dict['datasetname'], cfg) + plot_file = get_plot_filename(cfg['indexname'] + '_map' + + data_dict['filename'] + '_' + + data_dict['datasetname'], cfg) + + logger.info("Saving analysis results to %s", diagnostic_file) + + iris.save(cube_to_save_ploted(spei, data_dict), target=diagnostic_file) + + logger.info("Recording provenance of %s:\n%s", diagnostic_file, + pformat(provenance_record)) + with ProvenanceLogger(cfg) as provenance_logger: + provenance_logger.log(plot_file, provenance_record) + provenance_logger.log(diagnostic_file, provenance_record) + + +def _provenance_time_series_spei(cfg, data_dict): + """Provenance for time series plots.""" + caption = 'Time series of ' + \ + data_dict['var'] + \ + ' at' + data_dict['area'] + '.' + + if cfg['indexname'].lower == "spei": + set_refs = ['vicente10jclim', ] + elif cfg['indexname'].lower == "spi": + set_refs = ['mckee93proc', ] + else: + set_refs = ['martin18grl', ] + + provenance_record = get_provenance_record([data_dict['filename']], + caption, + ['reg'], set_refs, + plot_type='times') + + diagnostic_file = get_diagnostic_filename(cfg['indexname'] + + '_time_series_' + + data_dict['area'] + + '_' + + data_dict['dataset_name'], cfg) + plot_file = get_plot_filename(cfg['indexname'] + + '_time_series_' + + data_dict['area'] + + '_' + + data_dict['dataset_name'], cfg) + logger.info("Saving analysis results to %s", diagnostic_file) + + cubesave = cube_to_save_ploted_ts(data_dict) + iris.save(cubesave, target=diagnostic_file) + + logger.info("Recording provenance of %s:\n%s", diagnostic_file, + pformat(provenance_record)) + with ProvenanceLogger(cfg) as provenance_logger: + provenance_logger.log(plot_file, provenance_record) + provenance_logger.log(diagnostic_file, provenance_record) + + +def cube_to_save_ploted(var, data_dict): + """Create cube to prepare plotted data for saving to netCDF.""" + plot_cube = iris.cube.Cube(var, var_name=data_dict['var'], + long_name=data_dict['drought_char'], + units=data_dict['unit']) + plot_cube.add_dim_coord(iris.coords.DimCoord(data_dict['latitude'], + var_name='lat', + long_name='latitude', + units='degrees_north'), 0) + plot_cube.add_dim_coord(iris.coords.DimCoord(data_dict['longitude'], + var_name='lon', + long_name='longitude', + units='degrees_east'), 1) + + return plot_cube + + +def cube_to_save_ploted_ts(data_dict): + """Create cube to prepare plotted time series for saving to netCDF.""" + plot_cube = iris.cube.Cube(data_dict['data'], var_name=data_dict['var'], + long_name=data_dict['var'], + units=data_dict['unit']) + plot_cube.add_dim_coord(iris.coords.DimCoord(data_dict['time'], + var_name='time', + long_name='Time', + units='month'), 0) + + return plot_cube + + +def get_provenance_record(ancestor_files, caption, + domains, refs, plot_type='geo'): + """Get Provenance record.""" + record = { + 'caption': caption, + 'statistics': ['mean'], + 'domains': domains, + 'plot_type': plot_type, + 'themes': ['phys'], + 'authors': [ + 'weigel_katja', + 'adeniyi_kemisola', + ], + 'references': refs, + 'ancestors': ancestor_files, + } + return record + + +def _make_new_cube(cube): + """Make a new cube with an extra dimension for result of spell count.""" + new_shape = cube.shape + (4,) + new_data = iris.util.broadcast_to_shape(cube.data, new_shape, [0, 1, 2]) + new_cube = iris.cube.Cube(new_data) + new_cube.add_dim_coord(iris.coords.DimCoord( + cube.coord('time').points, long_name='time'), 0) + new_cube.add_dim_coord(iris.coords.DimCoord( + cube.coord('latitude').points, long_name='latitude'), 1) + new_cube.add_dim_coord(iris.coords.DimCoord( + cube.coord('longitude').points, long_name='longitude'), 2) + new_cube.add_dim_coord(iris.coords.DimCoord( + [0, 1, 2, 3], long_name='z'), 3) + return new_cube + + +def _plot_multi_model_maps(cfg, all_drought_mean, lats_lons, input_filenames, + tstype): + """Prepare plots for multi-model mean.""" + data_dict = {'latitude': lats_lons[0], + 'longitude': lats_lons[1], + 'model_kind': tstype + } + if tstype == 'Difference': + # RCP85 Percentage difference + data_dict.update({'data': all_drought_mean[:, :, 0], + 'var': 'diffnumber', + 'datasetname': 'Percentage', + 'drought_char': 'Number of drought events', + 'unit': '%', + 'filename': 'Percentage_difference_of_No_of_Events', + 'drought_numbers_level': np.arange(-100, 110, 10)}) + plot_map_spei_multi(cfg, data_dict, input_filenames, + colormap='rainbow') + + data_dict.update({'data': all_drought_mean[:, :, 1], + 'var': 'diffduration', + 'drought_char': 'Duration of drought events', + 'filename': 'Percentage_difference_of_Dur_of_Events', + 'drought_numbers_level': np.arange(-100, 110, 10)}) + plot_map_spei_multi(cfg, data_dict, input_filenames, + colormap='rainbow') + + data_dict.update({'data': all_drought_mean[:, :, 2], + 'var': 'diffseverity', + 'drought_char': 'Severity Index of drought events', + 'filename': 'Percentage_difference_of_Sev_of_Events', + 'drought_numbers_level': np.arange(-50, 60, 10)}) + plot_map_spei_multi(cfg, data_dict, input_filenames, + colormap='rainbow') + + data_dict.update({'data': all_drought_mean[:, :, 3], + 'var': 'diff' + (cfg['indexname']).lower(), + 'drought_char': 'Average ' + cfg['indexname'] + + ' of drought events', + 'filename': 'Percentage_difference_of_Avr_of_Events', + 'drought_numbers_level': np.arange(-50, 60, 10)}) + plot_map_spei_multi(cfg, data_dict, input_filenames, + colormap='rainbow') + else: + data_dict.update({'data': all_drought_mean[:, :, 0], + 'var': 'frequency', + 'unit': 'year-1', + 'drought_char': 'Number of drought events per year', + 'filename': tstype + '_No_of_Events_per_year', + 'drought_numbers_level': np.arange(0, 0.4, 0.05)}) + if tstype == 'Observations': + data_dict['datasetname'] = 'Mean' + else: + data_dict['datasetname'] = 'MultiModelMean' + plot_map_spei_multi(cfg, data_dict, input_filenames, + colormap='gnuplot') + + data_dict.update({'data': all_drought_mean[:, :, 1], + 'var': 'duration', + 'unit': 'month', + 'drought_char': 'Duration of drought events [month]', + 'filename': tstype + '_Dur_of_Events', + 'drought_numbers_level': np.arange(0, 6, 1)}) + plot_map_spei_multi(cfg, data_dict, input_filenames, + colormap='gnuplot') + + data_dict.update({'data': all_drought_mean[:, :, 2], + 'var': 'severity', + 'unit': '1', + 'drought_char': 'Severity Index of drought events', + 'filename': tstype + '_Sev_index_of_Events', + 'drought_numbers_level': np.arange(0, 9, 1)}) + plot_map_spei_multi(cfg, data_dict, input_filenames, + colormap='gnuplot') + namehlp = 'Average ' + cfg['indexname'] + ' of drought events' + namehlp2 = tstype + '_Average_' + cfg['indexname'] + '_of_Events' + data_dict.update({'data': all_drought_mean[:, :, 3], + 'var': (cfg['indexname']).lower(), + 'unit': '1', + 'drought_char': namehlp, + 'filename': namehlp2, + 'drought_numbers_level': np.arange(-2.8, -1.8, 0.2)}) + plot_map_spei_multi(cfg, data_dict, input_filenames, + colormap='gnuplot') + + +def _plot_single_maps(cfg, cube2, drought_show, tstype, input_filenames): + """Plot map of drought characteristics for individual models and times.""" + cube2.data = drought_show.data[:, :, 0] + name_dict = {'add_to_filename': tstype + '_No_of_Events_per_year', + 'name': tstype + ' Number of drought events per year', + 'var': 'frequency', + 'unit': 'year-1', + 'drought_char': 'Number of drought events per year', + 'input_filenames': input_filenames} + plot_map_spei(cfg, cube2, np.arange(0, 0.4, 0.05), + name_dict) + + # plot the average duration of drought events + cube2.data = drought_show.data[:, :, 1] + name_dict.update({'add_to_filename': tstype + '_Dur_of_Events', + 'name': tstype + ' Duration of drought events(month)', + 'var': 'duration', + 'unit': 'month', + 'drought_char': 'Number of drought events per year', + 'input_filenames': input_filenames}) + plot_map_spei(cfg, cube2, np.arange(0, 6, 1), name_dict) + + # plot the average severity index of drought events + cube2.data = drought_show.data[:, :, 2] + name_dict.update({'add_to_filename': tstype + '_Sev_index_of_Events', + 'name': tstype + ' Severity Index of drought events', + 'var': 'severity', + 'unit': '1', + 'drought_char': 'Number of drought events per year', + 'input_filenames': input_filenames}) + plot_map_spei(cfg, cube2, np.arange(0, 9, 1), name_dict) + + # plot the average spei of drought events + cube2.data = drought_show.data[:, :, 3] + + namehlp = tstype + '_Avr_' + cfg['indexname'] + '_of_Events' + namehlp2 = tstype + '_Average_' + cfg['indexname'] + '_of_Events' + name_dict.update({'add_to_filename': namehlp, + 'name': namehlp2, + 'var': 'severity', + 'unit': '1', + 'drought_char': 'Number of drought events per year', + 'input_filenames': input_filenames}) + plot_map_spei(cfg, cube2, np.arange(-2.8, -1.8, 0.2), name_dict) + + +def runs_of_ones_array_spei(bits, spei): + """Set 1 at beginning ond -1 at the end of events.""" + # make sure all runs of ones are well-bounded + bounded = np.hstack(([0], bits, [0])) + # get 1 at run starts and -1 at run ends + difs = np.diff(bounded) + run_starts, = np.where(difs > 0) + run_ends, = np.where(difs < 0) + spei_sum = np.full(len(run_starts), 0.5) + for iii, indexs in enumerate(run_starts): + spei_sum[iii] = np.sum(spei[indexs:run_ends[iii]]) + + return [run_ends - run_starts, spei_sum] + + +def count_spells(data, threshold, axis): + """Functions for Iris Aggregator to count spells.""" + if axis < 0: + # just cope with negative axis numbers + axis += data.ndim + data = data[:, :, 0, :] + if axis > 2: + axis = axis - 1 + + listshape = [] + inoax = [] + for iii, ishape in enumerate(data.shape): + if iii != axis: + listshape.append(ishape) + inoax.append(iii) + + listshape.append(4) + return_var = np.zeros(tuple(listshape)) + + for ilat in range(listshape[0]): + for ilon in range(listshape[1]): + data_help = _get_data_hlp(axis, data, ilat, ilon) + + if data_help.count() == 0: + return_var[ilat, ilon, 0] = data_help[0] + return_var[ilat, ilon, 1] = data_help[0] + return_var[ilat, ilon, 2] = data_help[0] + return_var[ilat, ilon, 3] = data_help[0] + else: + data_hits = data_help < threshold + [events, spei_sum] = runs_of_ones_array_spei(data_hits, + data_help) + + return_var[ilat, ilon, 0] = np.count_nonzero(events) + return_var[ilat, ilon, 1] = np.mean(events) + return_var[ilat, ilon, 2] = np.mean((spei_sum * events) / + (np.mean(data_help + [data_hits]) + * np.mean(events))) + return_var[ilat, ilon, 3] = np.mean(spei_sum / events) + + return return_var + + +def get_latlon_index(coords, lim1, lim2): + """Get index for given values between two limits (1D), e.g. lats, lons.""" + index = (np.where(np.absolute(coords - (lim2 + lim1) + / 2.0) <= (lim2 - lim1) + / 2.0))[0] + return index + + +def plot_map_spei_multi(cfg, data_dict, input_filenames, colormap='jet'): + """Plot contour maps for multi model mean.""" + spei = np.ma.array(data_dict['data'], mask=np.isnan(data_dict['data'])) + + # Get latitudes and longitudes from cube + lons = data_dict['longitude'] + if max(lons) > 180.0: + lons = np.where(lons > 180, lons - 360, lons) + # sort the array + index = np.argsort(lons) + lons = lons[index] + spei = spei[np.ix_(range(data_dict['latitude'].size), index)] + + # Plot data + # Create figure and axes instances + subplot_kw = {'projection': cart.PlateCarree(central_longitude=0.0)} + fig, axx = plt.subplots(figsize=(6.5, 4), subplot_kw=subplot_kw) + axx.set_extent([-180.0, 180.0, -90.0, 90.0], + cart.PlateCarree(central_longitude=0.0)) + + # Draw filled contours + cnplot = plt.contourf(lons, data_dict['latitude'], spei, + data_dict['drought_numbers_level'], + transform=cart.PlateCarree(central_longitude=0.0), + cmap=colormap, extend='both', corner_mask=False) + # Draw coastlines + axx.coastlines() + + # Add colorbar + cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation='horizontal') + + # Add colorbar title string + if data_dict['model_kind'] == 'Difference': + cbar.set_label(data_dict['model_kind'] + ' ' + + data_dict['drought_char'] + ' [%]') + else: + cbar.set_label(data_dict['model_kind'] + ' ' + + data_dict['drought_char']) + + # Set labels and title to each plot + axx.set_xlabel('Longitude') + axx.set_ylabel('Latitude') + axx.set_title(data_dict['datasetname'] + ' ' + data_dict['model_kind'] + + ' ' + data_dict['drought_char']) + + # Sets number and distance of x ticks + axx.set_xticks(np.linspace(-180, 180, 7)) + # Sets strings for x ticks + axx.set_xticklabels(['180°W', '120°W', '60°W', + '0°', '60°E', '120°E', + '180°E']) + # Sets number and distance of y ticks + axx.set_yticks(np.linspace(-90, 90, 7)) + # Sets strings for y ticks + axx.set_yticklabels(['90°S', '60°S', '30°S', '0°', + '30°N', '60°N', '90°N']) + + fig.tight_layout() + fig.savefig(get_plot_filename(cfg['indexname'] + '_map' + + data_dict['filename'] + '_' + + data_dict['datasetname'], cfg), dpi=300) + plt.close() + + _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames) + + +def plot_map_spei(cfg, cube, levels, name_dict): + """Plot contour map.""" + mask = np.isnan(cube.data) + spei = np.ma.array(cube.data, mask=mask) + np.ma.masked_less_equal(spei, 0) + + # Get latitudes and longitudes from cube + name_dict.update({'latitude': cube.coord('latitude').points}) + lons = cube.coord('longitude').points + lons = np.where(lons > 180, lons - 360, lons) + # sort the array + index = np.argsort(lons) + lons = lons[index] + name_dict.update({'longitude': lons}) + spei = spei[np.ix_(range(len(cube.coord('latitude').points)), index)] + + # Get data set name from cube + try: + dataset_name = cube.metadata.attributes['model_id'] + except KeyError: + try: + dataset_name = cube.metadata.attributes['source_id'] + except KeyError: + dataset_name = 'Observations' + + # Plot data + # Create figure and axes instances + subplot_kw = {'projection': cart.PlateCarree(central_longitude=0.0)} + fig, axx = plt.subplots(figsize=(8, 4), subplot_kw=subplot_kw) + axx.set_extent([-180.0, 180.0, -90.0, 90.0], + cart.PlateCarree(central_longitude=0.0)) + + # np.set_printoptions(threshold=np.nan) + + # Draw filled contours + cnplot = plt.contourf(lons, cube.coord('latitude').points, spei, + levels, + transform=cart.PlateCarree(central_longitude=0.0), + cmap='gnuplot', extend='both', corner_mask=False) + # Draw coastlines + axx.coastlines() + + # Add colorbar + cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation='horizontal') + + # Add colorbar title string + cbar.set_label(name_dict['name']) + + # Set labels and title to each plot + axx.set_xlabel('Longitude') + axx.set_ylabel('Latitude') + axx.set_title(dataset_name + ' ' + name_dict['name']) + + # Sets number and distance of x ticks + axx.set_xticks(np.linspace(-180, 180, 7)) + # Sets strings for x ticks + axx.set_xticklabels(['180°W', '120°W', '60°W', + '0°', '60°E', '120°E', + '180°E']) + # Sets number and distance of y ticks + axx.set_yticks(np.linspace(-90, 90, 7)) + # Sets strings for y ticks + axx.set_yticklabels(['90°S', '60°S', '30°S', '0°', + '30°N', '60°N', '90°N']) + + fig.tight_layout() + + fig.savefig(get_plot_filename(cfg['indexname'] + '_map' + + name_dict['add_to_filename'] + '_' + + dataset_name, cfg), dpi=300) + plt.close() + + _provenance_map_spei(cfg, name_dict, spei, dataset_name) + + +def plot_time_series_spei(cfg, cube, filename, add_to_filename=''): + """Plot time series.""" + # SPEI vector to plot + spei = cube.data + # Get time from cube + time = cube.coord('time').points + # Adjust (ncdf) time to the format matplotlib expects + add_m_delta = mda.datestr2num('1850-01-01 00:00:00') + time = time + add_m_delta + + # Get data set name from cube + try: + dataset_name = cube.metadata.attributes['model_id'] + except KeyError: + try: + dataset_name = cube.metadata.attributes['source_id'] + except KeyError: + dataset_name = 'Observations' + + data_dict = {'data': spei, + 'time': time, + 'var': cfg['indexname'], + 'dataset_name': dataset_name, + 'unit': '1', + 'filename': filename, + 'area': add_to_filename} + + fig, axx = plt.subplots(figsize=(16, 4)) + axx.plot_date(time, spei, '-', tz=None, xdate=True, ydate=False, + color='r', linewidth=4., linestyle='-', alpha=1., + marker='x') + axx.axhline(y=-2, color='k') + + # Plot labels and title + axx.set_xlabel('Time') + axx.set_ylabel(cfg['indexname']) + axx.set_title('Mean ' + cfg['indexname'] + ' ' + + data_dict['dataset_name'] + ' ' + + data_dict['area']) + + # Set limits for y-axis + axx.set_ylim(-4.0, 4.0) + + # Often improves the layout + fig.tight_layout() + # Save plot to file + fig.savefig(get_plot_filename(cfg['indexname'] + + '_time_series_' + + data_dict['area'] + + '_' + + data_dict['dataset_name'], cfg), dpi=300) + plt.close() + + _provenance_time_series_spei(cfg, data_dict) From b945c3632108ee842fb7dcb3ec3582ec155cdf79 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 10 Feb 2025 15:14:28 +0100 Subject: [PATCH 07/66] plot obs maps --- .../diag_scripts/droughts/collect_drought.py | 74 +++++++------------ 1 file changed, 25 insertions(+), 49 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index 13184c3b0b..ee9568f5fc 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -57,7 +57,10 @@ def _get_and_plot_obsmodel(cfg, cube, all_drought, all_drought_obs, def ini_time_series_plot(cfg, cube, area, filename): - """Set up cube for time series plot.""" + """Set up cube for time series plot. + TODO: This should be configurable in recipe. And maybe find nearest point + instead of crash if the resolution changes. + """ coords = ('longitude', 'latitude') if area == 'Bremen': index_lat = get_latlon_index(cube.coord('latitude').points, 52, 53) @@ -88,62 +91,35 @@ def main(cfg): """ # Read input data - input_filenames = (cfg[n.INPUT_FILES])[0] + "/*_" + \ - (cfg['indexname']).lower() + "_*.nc" - first_run = 1 - iobs = 0 - - # For loop: "glob.iglob" findes all files which match the - # pattern of "input_filenames". - # It writes the resulting exact file name onto spei_file - # and runs the following indented lines for all possibilities - # for spei_file. - for iii, spei_file in enumerate(glob.iglob(input_filenames)): - # Loads the file into a special structure (IRIS cube) - cube = iris.load(spei_file)[0] + input_data = cfg["input_data"].values() + proj_groups = e.group_metadata(input_data, 'project') + + all_drought = None + count = len(proj_groups["OBS"]) + print("Number of datasets: ", count) + # Loop over all OBS datasets and plot event characteristics + for iii, meta in enumerate(proj_groups["OBS"]): + print(meta) + cube = iris.load_cube(meta["filename"]) cube.coord('latitude').guess_bounds() cube.coord('longitude').guess_bounds() - # time = cube.coord('time') - - # The data are 3D (time x latitude x longitude) - # To plot them, we need to reduce them to 2D or 1D - # First here is an average over time, i.e. data you need - # to plot the average over the time series of SPEI on a map - cube2 = cube.collapsed('time', iris.analysis.MEAN) - - # This is only possible because all data must be on the same grid - if first_run == 1: - files = os.listdir((cfg[n.INPUT_FILES])[0]) - ncfiles = list(filter(lambda f: f.endswith('.nc'), files)) - shape_all = cube2.data.shape + (4,) + \ - (len(ncfiles) - 1, ) - all_drought = np.full(shape_all, np.nan) - first_run = 0 - - ini_time_series_plot(cfg, cube, 'Bremen', spei_file) - ini_time_series_plot(cfg, cube, 'Nigeria', spei_file) + cube_mean = cube.collapsed('time', iris.analysis.MEAN) + # we could use one cube(list) instead of np to keep lazyness and meta + if all_drought is None: + shape = cube_mean.data.shape + (4, count) + all_drought = np.full(shape, np.nan) + # ini_time_series_plot(cfg, cube, 'Bremen', meta["filename"]) + # ini_time_series_plot(cfg, cube, 'Nigeria', meta["filename"]) drought_show = _get_drought_data(cfg, cube) - # Distinguish between model and observations/reanalysis. # Collest all model data in one array. - try: - dataset_name = cube.metadata.attributes['model_id'] - all_drought[:, :, :, iii - iobs] = drought_show.data - except KeyError: - try: - dataset_name = cube.metadata.attributes['source_id'] - all_drought[:, :, :, iii - iobs] = drought_show.data - except KeyError: - dataset_name = 'Observations' - all_drought_obs = drought_show.data - iobs = 1 - print(dataset_name) - _plot_single_maps(cfg, cube2, drought_show, 'Historic', spei_file) + all_drought[:, :, :, iii] = drought_show.data + _plot_single_maps(cfg, cube_mean, drought_show, 'Historic', meta["filename"]) # Calculating multi model mean and plot it - _get_and_plot_obsmodel(cfg, cube, all_drought, all_drought_obs, - glob.glob(input_filenames)) + # _get_and_plot_obsmodel(cfg, cube, all_drought, all_drought_obs, + # glob.glob(input_filenames)) if __name__ == '__main__': From 7bbbc373e6186ea05e16d4b8219c2ed2a5a53a43 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 11 Feb 2025 14:42:36 +0100 Subject: [PATCH 08/66] add compare time periods (as in collect_drought_models) --- .../diag_scripts/droughts/collect_drought.py | 183 +++++++++--------- 1 file changed, 90 insertions(+), 93 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index ee9568f5fc..7a3a9a56a1 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -2,124 +2,121 @@ # -*- coding: utf-8 -*- -"""Collects SPI or SPEI data comparing models and observations/reanalysis. - -Applies drought characteristics based on Martin (2018). +"""Compares SPI or SPEI data from models with observations/reanalysis. ############################################################################### -droughtindex/collect_drought_obs_multi.py +droughs/collect_drought.py Author: Katja Weigel (IUP, Uni Bremen, Germany) EVal4CMIP project ############################################################################### Description ----------- - Collects data produced by diag_save_spi.R or diad_save_spei_all.R - to plot/process them further. + Collects data produced by spei.R to plot/process them further. + Applies drought characteristics based on Martin (2018). Configuration options --------------------- indexname: "SPI" or "SPEI" - -############################################################################### - -Updates: -- changed the filename pattern search to read the metadata.yml produced by - new spei.R diagnostic. + reference_dataset: Dataset name to use for comparison (excluded from MMM) + threshold: Threshold for binary classifiaction of a drought + compare_intervals: bool, false + If true, begin and end of the time periods are compared instead of + models and reference. + comparison_period: should be < (end_year - start_year)/2 + start_year: year, start of historical time series + end_year: year, end of future scenario """ -import os -import glob + import iris import numpy as np +import datetime as dt import esmvaltool.diag_scripts.shared as e -import esmvaltool.diag_scripts.shared.names as n from esmvaltool.diag_scripts.droughts.collect_drought_func import ( - _get_drought_data, _plot_multi_model_maps, _plot_single_maps, - get_latlon_index, plot_time_series_spei) - - -def _get_and_plot_obsmodel(cfg, cube, all_drought, all_drought_obs, - input_filenames): - """Calculate multi-model mean and compare it to observations.""" - lats = cube.coord('latitude').points - lons = cube.coord('longitude').points - all_drought_hist_mean = np.nanmean(all_drought, axis=-1) - perc_diff = ((all_drought_obs - all_drought_hist_mean) - / (all_drought_obs + all_drought_hist_mean) * 200) - - # Plot multi model means - _plot_multi_model_maps(cfg, all_drought_hist_mean, [lats, lons], - input_filenames, 'Historic') - _plot_multi_model_maps(cfg, all_drought_obs, [lats, lons], - input_filenames, 'Observations') - _plot_multi_model_maps(cfg, perc_diff, [lats, lons], - input_filenames, 'Difference') - - -def ini_time_series_plot(cfg, cube, area, filename): - """Set up cube for time series plot. - TODO: This should be configurable in recipe. And maybe find nearest point - instead of crash if the resolution changes. - """ - coords = ('longitude', 'latitude') - if area == 'Bremen': - index_lat = get_latlon_index(cube.coord('latitude').points, 52, 53) - index_lon = get_latlon_index(cube.coord('longitude').points, 7, 9) - elif area == 'Nigeria': - index_lat = get_latlon_index(cube.coord('latitude').points, 7, 9) - index_lon = get_latlon_index(cube.coord('longitude').points, 8, 10) - - cube_grid_areas = iris.analysis.cartography.area_weights( - cube[:, index_lat[0]:index_lat[-1] + 1, - index_lon[0]:index_lon[-1] + 1]) - cube4 = ((cube[:, index_lat[0]:index_lat[-1] + 1, - index_lon[0]:index_lon[-1] + - 1]).collapsed(coords, iris.analysis.MEAN, - weights=cube_grid_areas)) - - plot_time_series_spei(cfg, cube4, filename, area) + _get_drought_data, _plot_multi_model_maps, _plot_single_maps) + + +def _plot_models_vs_obs(cfg, cube, mmm, obs, fnames): + """Compare drought metrics of multi-model mean to observations.""" + latslons = [cube.coord(i).points for i in ["latitude", "longitude"]] + perc_diff = ((obs-mmm) / (obs + mmm) * 200) + _plot_multi_model_maps(cfg, mmm, latslons, fnames, 'Historic') + _plot_multi_model_maps(cfg, obs, latslons, fnames, 'Observations') + _plot_multi_model_maps(cfg, perc_diff, latslons, fnames, 'Difference') + + +def _plot_future_vs_past(cfg, cube, slices, fnames): + """Compare drought metrics of future and historic time slices.""" + latslons = [cube.coord(i).points for i in ["latitude", "longitude"]] + slices["Difference"] = ((slices["Future"] - slices["Historic"]) / + (slices["Future"] + slices["Historic"]) * 200) + for tstype in ['Historic', 'Future', 'Difference']: + _plot_multi_model_maps(cfg, slices[tstype], latslons, fnames, tstype) + + +def _set_tscube(cfg, cube, time, tstype): + """Time slice from a cube with start/end given by cfg.""" + print("sett time slice") + if tstype == 'Future': + print("future") + start_year = cfg["end_year"] - cfg["comparison_period"] + start = dt.datetime(start_year , 1, 15, 0, 0, 0) + end = dt.datetime(cfg['end_year'], 12, 16, 0, 0, 0) + elif tstype == 'Historic': + print("historic") + start = dt.datetime(cfg['start_year'], 1, 15, 0, 0, 0) + end_year = cfg["start_year"] + cfg["comparison_period"] + end = dt.datetime(end_year, 12, 16, 0, 0, 0) + print(start, end) + stime = time.nearest_neighbour_index(time.units.date2num(start)) + etime = time.nearest_neighbour_index(time.units.date2num(end)) + print(stime, etime) + print(cube) + tscube = cube[stime:etime, :, :] + return tscube def main(cfg): - """Run the diagnostic. - - Parameters : - - ------------ - cfg : dict - Configuration dictionary of the recipe. - - """ + """Run the diagnostic.""" # Read input data input_data = cfg["input_data"].values() - proj_groups = e.group_metadata(input_data, 'project') - - all_drought = None - count = len(proj_groups["OBS"]) - print("Number of datasets: ", count) - # Loop over all OBS datasets and plot event characteristics - for iii, meta in enumerate(proj_groups["OBS"]): - print(meta) - cube = iris.load_cube(meta["filename"]) + drought_data = [] + drought_slices = {"Historic": [], "Future": []} + fnames = [] # why do we need them? + ref_data = None + for iii, meta in enumerate(input_data): + fname = meta["filename"] + cube = iris.load_cube(fname) + fnames.append(fname) cube.coord('latitude').guess_bounds() cube.coord('longitude').guess_bounds() cube_mean = cube.collapsed('time', iris.analysis.MEAN) - # we could use one cube(list) instead of np to keep lazyness and meta - if all_drought is None: - shape = cube_mean.data.shape + (4, count) - all_drought = np.full(shape, np.nan) - # ini_time_series_plot(cfg, cube, 'Bremen', meta["filename"]) - # ini_time_series_plot(cfg, cube, 'Nigeria', meta["filename"]) - - drought_show = _get_drought_data(cfg, cube) - # Distinguish between model and observations/reanalysis. - # Collest all model data in one array. - all_drought[:, :, :, iii] = drought_show.data - _plot_single_maps(cfg, cube_mean, drought_show, 'Historic', meta["filename"]) - - # Calculating multi model mean and plot it - # _get_and_plot_obsmodel(cfg, cube, all_drought, all_drought_obs, - # glob.glob(input_filenames)) + if cfg.get("compare_intervals", False): + # calculate and plot metrics per time slice + for tstype in ['Historic', 'Future']: + ts_cube = _set_tscube(cfg, cube, cube.coord('time'), tstype) + drought_show = _get_drought_data(cfg, ts_cube) + drought_slices[tstype].append(drought_show.data) + _plot_single_maps(cfg, cube_mean, drought_show, tstype, fname) + else: + # calculate and plot metrics per dataset + drought_show = _get_drought_data(cfg, cube) + if meta["dataset"] == cfg["reference_dataset"]: + ref_data = drought_show.data + else: + drought_data.append(drought_show.data) + _plot_single_maps(cfg, cube_mean, drought_show, 'Historic', fname) + + if cfg.get("compare_intervals", False): + # calculate multi model mean for time slices + slices = {k: np.array(v) for k, v in drought_slices.items()} + mean_slices = {k: np.nanmean(v, axis=0) for k, v in slices.items()} + _plot_future_vs_past(cfg, cube, mean_slices, fnames) + else: + # calculate multi model mean and compare with reference dataset + drought_data = np.array(drought_data) + mmm = np.nanmean(np.array(drought_data), axis=0) + _plot_models_vs_obs(cfg, cube, mmm, ref_data, fnames) if __name__ == '__main__': From a9f7136f707f3787af90cb7631ef91a3b446bd80 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 11 Feb 2025 14:44:51 +0100 Subject: [PATCH 09/66] add provenance for all files --- esmvaltool/diag_scripts/droughts/spei.R | 42 +++++++++---------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/spei.R b/esmvaltool/diag_scripts/droughts/spei.R index 645cedc396..deb548c57b 100644 --- a/esmvaltool/diag_scripts/droughts/spei.R +++ b/esmvaltool/diag_scripts/droughts/spei.R @@ -131,6 +131,8 @@ for (dataset in names(grouped_meta)){ } if (cfg$write_wb) { filename_wb <- write_nc_file_like(cfg, pr_meta, pme, fillfloat, short_name="wb") + meta[[filename_wb]] <- list(filename=filename_wb, short_name="wb", dataset = dataset) + provenance[[filename_wb]] <- list(caption="Water balance per grid point.") } fill_refperiod(cfg, tsvec) @@ -161,40 +163,26 @@ for (dataset in names(grouped_meta)){ } } pme_spei[pme_spei > 10000] <- NA # replaced with fillfloat in write function + # TODO: check if we need to apply mask to pme_spei + # apply mask + # for (t in 1:dim(pme)[3]) { + # tmp <- pme_spei[, , t] + # tmp[is.na(mask)] <- NA + # pme_spei[, , t] <- tmp + # } filename <- write_nc_file_like(cfg, pr_meta, pme_spei, fillfloat, short_name=cfg$indexname) + new_meta = list(filename=filename, short_name=tolower(cfg$indexname), + long_name=cfg$indexname, units="1", dataset=dataset) + meta[[filename]] <- modifyList(pr_meta, new_meta) + provenance[[filename]] = list(caption=paste(cfg$indexname, " index per grid point.")) if (cfg$write_coeffs) { for (c_name in names(coeffs)){ filename_c <- write_nc_file_like(cfg, pr_meta, coeffs[[c_name]], fillfloat, short_name=c_name, moty=TRUE) - meta[[filename_c]] <- list( - filename = filename_c, - short_name = c_name, - dataset = dataset - ) + meta[[filename_c]] <- list(filename=filename_c, short_name=c_name, dataset=dataset) + provenance[[filename_c]] <- list(caption=paste(c_name, " (fitting parameter) per grid point.")) } } - - print("-- prepare metadata for output") - meta[[filename]] <- list( - filename = filename, - short_name = tolower(cfg$indexname), - dataset = dataset) - xprov$caption <- paste(cfg$indexname, " index per grid point.") - # generate metadata.yml - input_meta = select_var(metas, "pr") - input_meta$filename = filename - input_meta$short_name = tolower(cfg$indexname) - input_meta$long_name = cfg$indexname - input_meta$units = "1" - input_meta$index = cfg$indexname - meta[[filename]] <- input_meta - meta[[filename]][["index"]] <- cfg$indexname - for (t in 1:dim(pme)[3]) { - tmp <- pme_spei[, , t] - tmp[is.na(mask)] <- NA - pme_spei[, , t] <- tmp - } } # end of dataset loop -provenance[[filename]] <- xprov write_yaml(provenance, provenance_file) write_yaml(meta, meta_file) \ No newline at end of file From 43fb9b3717bd5091128b961c0cc19e2525852685 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 12 Feb 2025 10:17:28 +0100 Subject: [PATCH 10/66] adjust recipe to use spei.R (plots look the same) --- .../recipes/droughts/recipe_martin18grl.yml | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 esmvaltool/recipes/droughts/recipe_martin18grl.yml diff --git a/esmvaltool/recipes/droughts/recipe_martin18grl.yml b/esmvaltool/recipes/droughts/recipe_martin18grl.yml new file mode 100644 index 0000000000..0ef802e270 --- /dev/null +++ b/esmvaltool/recipes/droughts/recipe_martin18grl.yml @@ -0,0 +1,158 @@ +# ESMValTool +# recipe_martin18grl.yml +--- +documentation: + title: "Drought characteristics following Martin (2018)" + description: | + Calculate the SPI and counting drought events following Martin (2018). + authors: + - weigel_katja + - adeniyi_kemisola + + references: + - martin18grl + + maintainer: + - weigel_katja + + projects: + - eval4cmip + +preprocessors: + preprocessor1: + regrid: + target_grid: 2x2 + scheme: linear + preprocessor2: + regrid: + target_grid: 2x2 + scheme: linear + +diagnostics: + diagnostic1: + variables: + pr: + reference_dataset: MIROC-ESM + preprocessor: preprocessor1 + field: T2Ms + start_year: 1901 + end_year: 2000 + additional_datasets: + # - {dataset: ERA-Interim, project: OBS6, mip: Amon, type: reanaly, + # version: 1, start_year: 1979, end_year: 2005, tier: 3} + # - {dataset: MIROC-ESM, project: CMIP5, mip: Amon, exp: historical, + # ensemble: r1i1p1, start_year: 1979, end_year: 2005} + # - {dataset: GFDL-CM3, project: CMIP5, mip: Amon, + # exp: historical, ensemble: r1i1p1, + # start_year: 1979, end_year: 2005} + - {dataset: CRU, mip: Amon, project: OBS, type: reanaly, + version: TS4.02, tier: 2} + - {dataset: ACCESS1-0, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: ACCESS1-3, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: CNRM-CM5, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: BNU-ESM, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: GFDL-CM3, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: GFDL-ESM2G, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: GISS-E2-H, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: HadGEM2-CC, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: IPSL-CM5A-LR, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: IPSL-CM5A-MR, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: IPSL-CM5B-LR, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: MIROC-ESM, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: MPI-ESM-MR, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: MRI-ESM1, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + - {dataset: NorESM1-M, project: CMIP5, mip: Amon, exp: historical, + ensemble: r1i1p1} + + scripts: + script1: + script: droughts/spei.R + smooth_month: 6 + distribution: "Gamma" + spi_collect: + description: Wrapper to collect and plot previously calculated SPEI index + scripts: + spi_collect: + script: droughts/collect_drought.py + indexname: "SPI" + reference_dataset: CRU + # Threshold under which an event is defined as drought. + # Usually -2.0 for SPI and SPEI. + threshold: -2.0 + ancestors: ['diagnostic1/script1'] + + + diagnostic2: + variables: + pr: + reference_dataset: MIROC-ESM + preprocessor: preprocessor2 + field: T2Ms + mip: Amon + project: CMIP5 + exp: [historical, rcp85] + start_year: 1950 + end_year: 2100 + additional_datasets: + - {dataset: ACCESS1-0, ensemble: r1i1p1} + - {dataset: ACCESS1-3, ensemble: r1i1p1} + - {dataset: CNRM-CM5, ensemble: r1i1p1} + - {dataset: BNU-ESM, ensemble: r1i1p1} + - {dataset: GFDL-CM3, ensemble: r1i1p1} + - {dataset: GFDL-ESM2G, ensemble: r1i1p1} + - {dataset: GISS-E2-H, ensemble: r1i1p1} + - {dataset: HadGEM2-CC, ensemble: r1i1p1} + - {dataset: IPSL-CM5A-LR, ensemble: r1i1p1} + - {dataset: IPSL-CM5A-MR, ensemble: r1i1p1} + - {dataset: IPSL-CM5B-LR, ensemble: r1i1p1} + - {dataset: MIROC-ESM, ensemble: r1i1p1} + - {dataset: MPI-ESM-MR, ensemble: r1i1p1} + - {dataset: MRI-ESM1, exp: [esmHistorical, esmrcp85], ensemble: r1i1p1} + - {dataset: NorESM1-M, ensemble: r1i1p1} + # - {dataset: MIROC-ESM, project: CMIP5, mip: Amon, + # exp: [historical, rcp85], ensemble: r1i1p1, + # start_year: 1950, end_year: 2100} + # - {dataset: GFDL-CM3, project: CMIP5, mip: Amon, + # exp: [historical, rcp85], ensemble: r1i1p1, + # start_year: 1950, end_year: 2100} + # - {dataset: IPSL-CM5A-LR, project: CMIP5, mip: Amon, + # exp: [historical, rcp85], ensemble: r1i1p1, + # start_year: 1950, end_year: 2100} + # - {dataset: MRI-ESM1, project: CMIP5, mip: Amon, + # exp: [esmHistorical, esmrcp85], ensemble: r1i1p1, + # start_year: 1950, end_year: 2100} + scripts: + script2: + script: droughts/spei.R + smooth_month: 6 + distribution: "Gamma" + + spi_collect2: + description: Wrapper to collect and plot previously calculated SPI index + scripts: + spi_collect2: + script: droughts/collect_drought.py + start_year: 1950 + end_year: 2100 + # comparison_period should be < (end_year - start_year)/2 + comparison_period: 50 + compare_intervals: True + indexname: "SPI" + # Threshold under which an event is defined as drought. + # Usually -2.0 for SPI and SPEI. + threshold: -2.0 + ancestors: ['diagnostic2/script2'] From fd0cd6219efaabd049f894a585c5500e45511eb8 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 12 Feb 2025 13:27:54 +0100 Subject: [PATCH 11/66] droughts diagnostic documentation --- .../api/esmvaltool.diag_scripts.droughts.rst | 18 +++++++ .../collect_drought.rst | 10 ++++ doc/sphinx/source/api/esmvaltool.rst | 1 + .../diag_scripts/droughts/collect_drought.py | 51 +++++++++++-------- 4 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 doc/sphinx/source/api/esmvaltool.diag_scripts.droughts.rst create mode 100644 doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst diff --git a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts.rst b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts.rst new file mode 100644 index 0000000000..012c851101 --- /dev/null +++ b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts.rst @@ -0,0 +1,18 @@ + +.. _api.esmvaltool.diag_scripts.droughts: + +Drought Diagnostics +=================== + +The droughts module contains diagnostics for calculation and plotting of drought +indices and metrics. A general overview is given on the +:ref:`Droughts documentation page `. + + +Diagnostic scripts +------------------ + +.. toctree:: + :maxdepth: 1 + + esmvaltool.diag_scripts.droughts/collect_drought diff --git a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst new file mode 100644 index 0000000000..2bae251c6c --- /dev/null +++ b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst @@ -0,0 +1,10 @@ + +.. _api.esmvaltool.diag_scripts.droughts.collect_drought: + +Calculation and plotting of drought metrics following Martin (2018) +=================================================================== + +.. automodule:: esmvaltool.diag_scripts.droughts.collect_drought + :no-members: + :no-inherited-members: + :no-show-inheritance: \ No newline at end of file diff --git a/doc/sphinx/source/api/esmvaltool.rst b/doc/sphinx/source/api/esmvaltool.rst index 6eda5f5912..974fd8059a 100644 --- a/doc/sphinx/source/api/esmvaltool.rst +++ b/doc/sphinx/source/api/esmvaltool.rst @@ -23,6 +23,7 @@ Diagnostic Scripts .. toctree:: :maxdepth: 1 + esmvaltool.diag_scripts.droughts esmvaltool.diag_scripts.emergent_constraints esmvaltool.diag_scripts.mlr esmvaltool.diag_scripts.monitor diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index 7a3a9a56a1..b049a6a80f 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -1,31 +1,42 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- - - -"""Compares SPI or SPEI data from models with observations/reanalysis. - -############################################################################### -droughs/collect_drought.py -Author: Katja Weigel (IUP, Uni Bremen, Germany) -EVal4CMIP project -############################################################################### +# Author: Katja Weigel (IUP, Uni Bremen, Germany) +# EVal4CMIP project +"""Compares SPI/SPEI data from models with observations/reanalysis. Description ----------- - Collects data produced by spei.R to plot/process them further. - Applies drought characteristics based on Martin (2018). +This diagnostic applies drought charactristics based on Martin (2018) +to data produced by spei.R. This characteristics and differences to +a reference dataset or between different time periods are plotted for each +dataset and multi-model mean. +It expects multiple datasets for a particular index as input. The reference +dataset can be specified with ``reference_dataset`` and is not part of the +multi-model mean. Configuration options --------------------- - indexname: "SPI" or "SPEI" - reference_dataset: Dataset name to use for comparison (excluded from MMM) - threshold: Threshold for binary classifiaction of a drought - compare_intervals: bool, false - If true, begin and end of the time periods are compared instead of - models and reference. - comparison_period: should be < (end_year - start_year)/2 - start_year: year, start of historical time series - end_year: year, end of future scenario +indexname: str + The indexname is used to generate filenames, plot titles and captions. + Should be ``SPI`` or ``SPEI`` +reference_dataset: str + Dataset name to use for comparison (excluded from MMM). With + ``compare_intervals=True`` this option has no effect. +threshold: float, optional (default: -2.0) + Threshold for binary classifiaction of a drought. + Not yet implemented. +compare_intervals: bool, false + If true, begin and end of the time periods are compared instead of + models and reference. The lengths of begin and end period is given by + ``comparison_period``. +comparison_period: int + Number of years from begin and end of the full period to be compared. + Should be < (end_year - start_year)/2. + If ``compare_intervals=False`` this option has no effect. +start_year: int + This option is used to select the time slices for comparison if ``compare_intervals=True``. +end_year: int + This option is used to select the time slices for comparison if ``compare_intervals=True``. """ import iris From bf6685c6f284a1fb15de93d37bcf5c8f3578c81e Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 12 Feb 2025 14:38:54 +0100 Subject: [PATCH 12/66] recipe_spei documentation --- .../recipes/figures/spei/histogram_spei.png | Bin 51987 -> 0 bytes .../recipes/figures/spei/histogram_spi.png | Bin 52559 -> 0 bytes doc/sphinx/source/recipes/recipe_droughts.rst | 11 +-- doc/sphinx/source/recipes/recipe_spei.rst | 92 +++++++++++------- .../diag_scripts/droughts/collect_drought.py | 3 +- 5 files changed, 65 insertions(+), 41 deletions(-) delete mode 100644 doc/sphinx/source/recipes/figures/spei/histogram_spei.png delete mode 100644 doc/sphinx/source/recipes/figures/spei/histogram_spi.png diff --git a/doc/sphinx/source/recipes/figures/spei/histogram_spei.png b/doc/sphinx/source/recipes/figures/spei/histogram_spei.png deleted file mode 100644 index 9aed50720f95cc3b97076c526c5487ceffab8bcb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51987 zcmc$_Wn5Hm+ct_Kq5>k_ASm6^IU?N+QiDoKH%KE$m*mhxcS$!&O6Slp(#_CA&g{Ye zeZSAM-zUE9-{#9K7K=5}_r={v_887xz?(ExrqkXC$1UPQ=#5poJNwcfg z0u1E2G6b)aW?vDreXe3vuwi=1(gv^oSuFw!$*=jzAuWLz9mRFB*j(muHxKFB&k(^L ztbT<0E;O`riY;f%zei}=lB>_}kH*kmI{rQN8SQ%``u!PRG`YnG_Xp%?uMh7((Qwf4 z-S0pBAESkd+#kF}_wS~or(2H!2NB;xrj_kaJ0`U|!UI07L$ES*7?&B(z^4p&vm=u`9H!jT)kIncbg-4Y%c zd+W1GP#~TTb|sON?3jMw{qcL_42_362y|{a{_Cp6XA6cQ^ZGPMN-6=IctrcB*Ep>Z zZo~5d5;c2^PqF^^@SyY;*E2V38Vm2t?+*rwS1iM7t~KA;4?Yunj0PsbhhNiio^=P1 zEzsoQa5`q@;@BzUghd3XxR~5$Wy%D&opWrIm+rI$e|a z7UzZEmXnA)vC+hNnntP6LZ6B+-cAZ6g@$6wz(lKjbI3rUa{b*S9!u)yQ$R`Ir}%h2 zf*r8k@Lv_f8(~aaOHq>#8n-4*_j|rw^M5O%E%NA)ugDHAG{Z<+W10!$gp+INGAfCQ z1NJ1b(T<+}iS`nU%{UXMjR*~GY-UfjbW~(p-FDC1-~V7bnD>&^s9cP zMp}>io3PN6htuvl7Ni9m-q6>_#GSPQB=tk2vm;uHu^&lgBnF3kyK|t2-Rs1V6q*w+ zKjmQ5*}A?%U7@D&c9B9?ftdso(&&<~u5P3e4y#C(IAEtA2F0$L&@?3#;(KM?*K~8( zyMg!0!`>9@Pa!DmYo@a>1q&CYGf44AH6P^*GQKQ&RDDb7_hXOi6m!b7JGaQzT)<`durxbGdQYAlk5#j1!>VY-Qg3H7F%nAj z`tfbsM}5BqhTRkFE-pjuz@(*K4xw$`>)01_s+Z5SJ)fK`A99MqYbkLbZ6yj5EYnZ{ zt4LDsF53Fbn^jE#OcNLKW^G0g`S+M0k|dfPcZZ!uYMowkQIwrBgXJk?XOt~~1%y#J zWpn*{{48_u(q%AM`@o6HaI3VDazi!)JeNMmh28bL4$;flzP%bx<8fil<5=cYzUe{Y z7lDm7@gW=d8)0uvVs=a$Yk%M-F4_?1dMb&e%+f=s)hE02#G1`5R`EF7627Li>UHT{ z$=VMP?o`DdXP~14-fR>i(oN`%J&l@loDpI8gmp}zO}op$A4)#q2Mp}n<(!BPL{W`; z5UV1QKT{c#|8pp!xw=%L-_z8RFDz7Pe;FP=uFvH2kBP?lV>I++Ho-JCz+iRz--ArFeAG?tyx4@Wq?v~H^k;)J#RjgY7 zd@7N&%6qUfCg{X^WxedA3vx878pb`f;tEV1RQHX9x9Y66s69K-U6d#O!8Z78t&*LI zDWT?iQ^7_Y3$6O?D$qDj%n zXOn<2!4*m$A;_mx_(40mRpv%0geEvYPZ}V8>ADxNHf*`$#tzgncOP&?t(uzKhMSO` zFkQ`^KW_Wd-a0zBezIHj+V~){NZ}KXh8*8gU=h9b{kr+J8MiQS^Tk_XGQc4!xUA(; zSe!@XYAMRrLVBp(CULwB2`g)l5HA?RKxRthIIMGxAl9O}9|?N^&mj)M{(J z?8+Ciby{;`RB^D^S_{g(on?i-4`bDawcT|nB`JGd1_oY!>aKC^pyp)jU_?`6etmc@ z^J zC~MJGvENw!4WF*-Do1F$V1wB&dxgMp%)n=d&>mo>9R~+n;~lg3la<2Wh#FQ$Cwdbu z{KQ#Jj24>k=ZQ=!%?5)3*;WObZ6;EQ?h&;Q7@rlixv^)ptL^Yv>Ao3U79*G zc4CL4-xpc@h6f3)AYm=F7C~%b>hpI*wpa*C9_O|f+AdHPQg>H7EJyuL%eHMdjV7bg zlW&#&2_bJ^>F8l|RR* z#x4FrRB52{vlsW0xX7V2LxK6`vq|vnn0(ZG_4b!Bgmuq?S4?~R%%3UPXHU89KcT1# zp!w2!AesB6Nw846Dch()b1VeHwUdzX_o~@*0aNvi}xQe>Ej+i-!*N^ z&ZU-yzdQ-(>x2fN3IXL_`-Nj|MMt7y`^sfp0s;H++&0G*f!~I?7w87oFYxGZw@w*~ zlLFOisd!q8O39YoUNw^}ZbWz>UY6|>*qb#=5BN_)uP@sPV(9r`LcdG4TYTCc+$?WC zajuz&TfP;m^ElQOsl&H|m;zW!5{@?qAPuWgL)X!;PljjjpU6U&i(>?fUj!{T6C1ic z`_7_z87}AXWIgpfeM(uh_T}nV_+Y>T#M9ByrHGewbn)#RVoHsW>pU%2wUQ-9B4%fN z)N2Y_CxrBX^CGD>vaS$S9XFlLh6gE1 zn^Bv**3M13?&;`UPps4K*8QmNYu6qc%ET%zC~^BeBga<}9kR-vh*PHYI+7m5hei~r zb4}lF`Ms`bVBJTwZSn3mW>RR;oUTZNN-Yy<&p?4}2%#uz;#x;@uGM%==?S~}bpalp zy2kdxW%@jJuv~6Rxq6tG_PBj9XL};O9JcarjvA+(`@G|;-#z$QLj3?Avw6n1XyW^M zSy6TS)RDj3ake^tHMb6HlM>|jfEs1%%S|)Wx$dG80^kepDzMBBV`?3yhtCUnuaOrG zmNETeJ#Z_)rO`7bS~>-9$CVLTI=VL$T=}JVh59}k5Q$=6uvXrXMGZN(u7?Nh((`!P z2-xrNoJw0TU-z<-qI8Gr=ntsP#fcY~cBiQ)6`alott^>Zy3$jw^UMHU*SwXIqRd!k zg>$&8G31mLy%)zTb~9?)TBu0X5QpNT<$n%O4n<2SVooVq8Y0>PRWR;l1LZ7ySUU5OxFr*;Ys z)uzQZgE{o?x*AL#daG(WryRm5n)yNIOSb=>b`}v_P3^;ATO4} zEd5<f}X#td`=C%jzo+ArGK zRUW3{W-j*o)0U`!@BJN$^7qrZvWP(}?q(l>OtIb}{maRYpvj0Q_<@I+IL%&el_3(w zxT77@*t{&~YG%W#BdIW5ZSlJ+y&(Ozk-;Oq7Yzjvj}im0kFl}JYdX3+UptNqa=C>E zC7AAudqOhfRe`6tj3X7Cq~^&qSNld9L7<^q+$|kjw#)O}Nq0!omU^+!yCmgS?go!@ z$v-TF!1^GMxqM&FXP0DVcF37`Xw|&BDd|~&r4A7)WvOmkzOBOZSE&h?8~q!_EJU|O z)7JJ`c|zVNnaEk~w)z0P!NCD`p$TSyK<<$R24BwrGC1kY2X>&kie}D*Rgpx|2BgQb zUt5i{(M;}2$n1g_z$jLj0I zhv%q|oA$X-QL9b7csFax6+G3y5fnZrVL^aiWBrZ|E2Z;F6|^V{e7NUVBx12w zZv?&NeOj`M(fP~rMJJ-Wq+n^NQ8kh3b{f9ZP5vbuchYHs96oLv#YZUkdO#tR#kg=v z%O!0u%nxIu8?(=hi1T--CpEBOS^vq~q)nEjyWKxIFwR~Ay#596(^Fi@NQFC3)2Xyt zenOBbW4mg}(cby|(*iZCFCPrU3f_EL?hf;#W!VPZZML&9qnl5qy(g(4hSy8|^pNN{ zpNZGhf=uwZ8zPH#e_Ow>@^E$3f0J5ppxhlMj#Yw~Q$fN+j{`vx1d zc0yjRIwnF$cHTJ{wE(6(Gys}(Iq3qrx0y)yb593xynekHawt$#%-HohuOv{z-RB)7 zTF(kwwNM-OG@`*1=t~&)9S^w*gL#HBx`v&I+=%{|Dyj2y(vTDZ?1i(<2Q&Q z`1+0FJD%H?*M`ZFwj$FuEV@j6?9k9cQT-}`Xj3b)b=1)BjDcl0dj_$GXU6e4r;%g^ z7~$vMpE6rF#r|6YtKEul;}`?Ogho%@_g4nKkaS&VrN!yHN-r@vCPN9Pxbp)~D|4d- z;-QKczR1f|U zbaX2%Y2x-+ewEeS%N6BQyx6IAyQ?RArDgh03*L{EI)fhaV5Nm)H6E$mHqxaCy6|b1 zJMic|oVF1w0tH=mUw)ank=Bs-84wz>%t;W`q*omFP|Sd!?&N$1Si~pFK4{O8k_!1w zi!rGY0u^@zlx!1cbL}FI4OTI?2yKFK?X;!)6jTQ!UoK;zhlE9MN=fNes@xvm0PIgg zLuY#XinZ5fe3=JI!_ab<; zm;N&HBdAg)bc@qO($~0Zzsk=>M*p%43K~Z8RUL6+ClUGKW&uypOf6OD`0x5N?WdO% z=;<_es!w$Lok_M`!GMMaVLbI^KaoxEtt&PT9-fOGyqNsH0G`RUt~G7piz>BZ53c-U zupPK&aZk_1)AKABU6JGtPeDrR#5Ti-@^y#5%%;D5!_BSzwKX1I*fQc`*xgSwVo>gB zTf6NqC)gu8I*8qA682}hp1$_f?CGQ0ghmexic7r(p%Ka~W@?zOQ9G z)J*pX^gLVUxplvmR^(EyATO1*Y<_0bmx|rx@?g+?wDUakD}*2VB)pT#o3s1wnxa|3 z0L~>p(7h7}Sj*H~XVii*eE+~A+C?ZNrA+y4_vW+B*?aBgw@sy_2z{uK%)8l8|9<^kr7yCG1eQ-S^;>#BhTO{~y?P~-> z9s7-&j9E+mOt>-i`<>de35=l=$(YfEOBqSvpk-zdXG7Vi=`E`e{hG-)lB=6nMC~bW zb`aCgmX-P~g{|Q639FVlth?{~8NGw@L?7K_SdF@3R}l3tobc(3*k8?uT;|C+q8crN ze$3L&<%%HHhP-9sd;roc;YYWB7%4$v8|NFTI%lV8?~dKmE9Oa#nKR+hY7HOaHxJ#; zShp(*G*!IWk`FUzTs}zsDt*s)W9OHo-mmiDtw-Y;(4Hwdde)6~eFM-!9DQJEU6O0) zaJSB~$8WvhwYa)OVjOg7M`oqrlC4#ndAtA{e9S-d=LH_~Zt_AMpN=}0>++Vf)kcw< z%m6l?Y?~gaKaF!qguerJRChBiQqMNzZB49XnR?f7y?p2M>!r1ibg>`HwW?e{+A-}JjCHC8u?fccH{Mj)dvJII+5 z`L;4_Z6PTsr(FhHOeLuI#ntI#gs|20^||tZB&yOArs~D9)Fb@b+?7X5FVZ0lCbWDW z&gMgBU_Uca*uo}LUcMlYZ0X>1JzSobGxGLXW7f8N}DElpnu6sMT0(@mKdZ+((FH(6}j-xaEx?T8m19$rT!h=%5Ug(BzQwh@z& zk&%$-R)6bSL4lP~sUYSsW>n2I?AWz7@wv*0i)34gIGavwD@-zEKOCRz@TLZ_>G9c5 zT=q|v+b)nKQi`>LPB6@N8^+!stZ(Pz=vDm637-Y=#ZaQyep(dUf8_C+fnmAXwbb9PE-cNiz#^7*|fUTpE3NZ(xa$k)7mDJL&KJ~PAFq%S8IT2Wzl|MVhO zQ%oEn1Fg=jQ_%3(&Afa|(RxS2Ej26TOYpEFU;jrf2;YDfJwzqx^v$79OF?rtA?OGr zrvq`kGg+v5&hiM&zZV^)FnCk@`ubW-N5L$BhHJ?vP zIAMPh*4NiZN%2u42O&!2_*1hponXE|f+Phicl43D?eG+=3orX24b54TK7Znj@I45( zA3}P(dj0xU+q&2ny6_?Ij}X;5Q+cHiDz?%n&pz6rb;R^nT{+*IK^co)ky^~t^tver zrJj8DN4>D0g-55O0OZAjz1cHA@>_Y657G%qNjgq4pt3TyL{;*DNAh!5Kp-%N2IP5J zrwSGi#PpkAet_ohiHouVFY$4<2LXf9cMxZ$Bm*0p%>sTt3Oinp>1K*Rjk^6pdC_Zb zhLhW?(|f%p3mf(N#y(e-MMOjd%N*ZwWoBkdt$?}dxX}qTolE(p0W2)cm?5Hfk;kZY zdDmU+Np!#J&@LDMi|pgCn%o%Sq-pXh7r*8NU=;uNI^$4b<;BxA#2s|#3P|g56$Y=5 zvMeq(v#~3vtXOc{wMXWM?N)!hzt*2q$%x_5GRB=&8F7I=9*u#$Yw|k=R;sy zjC{X2QEj9_?cQvCiZF2YK1#g|!o;OEY;;(0mK}eOP0E$!heUuy!kp_jl+0EMT|D#lz$5;BdM4)%)t*yLTMG*4ah^_(>eKjT3&Qjntl^X=Vajx-jk?<$!IOpT3?_qse<*^1721AgTw zKz_I((t4s@Zx77`8(%0ZZ*Fe75)<4%``fk)W_)*A05c061HA!LVUD$pRbIZ@xfZ{b zmDL6oS#WV6N`XZEkoyb!IJ0bg^Qfer)=-pg2$FBR^mpt%Fp^nQBAWhV1@= zk|c_amchEh`~uM~hXfqk5RPNdSccd5!PJgZrs2#KBHndd5}(BJHrnQ97OLUx0zF-s z@%Egbt$Eh;JGT}hWO7mWA6CKAy*L6d7r2752?|=+5pF-y_P)_(7}RInlt6cbo|FFr z=dH>2^z`VqZ9AKE~~a3i|U( zo?jGHFb1*u8Z9~&A<)*u$=0U_yn<3v*deq!-PO{p)pWFj4#$~8s{g3LZ%y({_{QiU zTeW^UMH7$&8$%szg-h42s~A-I?!kv{r0^_!ki{?)bjcy8*<(mT@OWBG^!5E}8dI~h zwzglWS7stiGR`Z}?{Ur(9aJA=Jao?FUOH+xaK6@OvIt&2pRe7G9BI~Epi->p+r9v{ zPMQCVS+Ey6Br6)_-WM$gJJk0j`G$aS`H7?#g&IxQ7N%K!?V`Hd4*&I7MPEdhC-N0b zo492gU@|ZsnL*u=okn3%$vm6S3+@9a#yUde4$L)E;U~l@)RS$m{8Sh=s?EEvR^FQQ@a(P~Uj1w{Y|7t@`Y?kJD0Mq) zq&ZYSLYG~dHiNr@mcXyyF+DK(e{1Sd?{$7|aA)sz3aIsMio&%b8@{x6p= zbBX0T3(Ym(A!%^ZXK&0B`Z?C3--7N9>7VUZv&e0bE=IEMg`zZwpgD zP2_R|GYRZ>x<_wXIf<5UMh}kom3`Jv=X?=7fP#+3t0g2Pq?p9kKRU|UA!lUdvruoV zJ^dZAntNWC4D)$R;(N$(e)EKd7whwrY~&uhhH2kF4+4wf{|2n?xUY? z_eWye@zIIB1=~!o>U0*+N3j1~m(z53_wTqd8V*tPrDgw60tfDQZEe)b%N?N z$|ch^J-`Fdb4X6T8&xyVi&lJ0)jm(d75hcBf=S6qNPUaQ&&9k;b#a-U+mWP46e054 zbh#jQm~Y+kXn)SD7jegAUfpDwODa9Vk#(^pp=h(34ty?Oza;?N2yZ^#M<9XRvT?f5I@HG-MY*x0WLdwTG5m-2J#-- z)oX$H9>ht)C#bG^o|w3`yTNg8qHi+mU!I#T#SM??uzO>fMXx$i22I$cQCSW{QAOa? zffDi()&%2@IB8U?3fnR-SL5wA8gZLj(xR&X!dz!0(c1(f?Z~}6PNhT!Rh(mjOmz)` z7$5DDzL#nwuMHk+HFoZchDok;y!~g|gPi{3H*n!-20{GLI%%-=>bl2Pa3M_gfv&t_ znmn#*?9q?~J!&LCu488Tr(wMsqSRK57mHuZcG_~i401EF>~nJg`-Py;0fxWO)&a^) z*C;<%K*bq_=V`#|5~t3xPAxHjfF@I6$;;vLYfcFPbRhUxs*Gke+bg$6jaa!VfbfFCWnb}0s}!bF@UazH1C4+);7 z{K2aNypB^*L%fd~OeSjL0ww7au|fxy#&mz2e(B^)md=b4E$6(18-2>m<%l`K50JzN z4#d_jS$c}2DAu-yDw8rbo)1=oML19p{5_@Q<(cK$yZpVPH;^0TuH`P1i!ZzgS`d{6sDfD0d6r!cPWv+@+u^D z6ya=7x$}WWEn7q;Ri;lmx_$|kn;WZ#C2Vw*Ir09%-_dDgs`(JvV&F)jkkPORH)=kd zO{#-dP<;@mr432Io)>IB1ni7!OC}~7j`;n^^_$7Ztqu#V;02{sEW>{ifc4lT3cV6| zsM=ijYAToJEqj=F-CY)Z9KOk$kTGi-6a1YJDv}&HNLuo#DbvC$EIuus8CgYAoIAq3 z|8Q7J0x=@kjO_E86>J8~2ROjYEdxn!cH;&YIGVt9c@twE${|SD<`Gi6c?49P`8@G& z!RH_Me^saCRa>KAK;mmIF13<_nuq-E>gu0cZh`t?or_FRqY5#0z74YAT$|4#E@3kf zydU!;zGcf&@}+19mJ=_kFyMDD&-2e^7Yl<>vlVU;zF2J!dES>aigF$v(;T7?5xP*t zd{gk;>TN(zsm`8X3OAMf$xPM#AIB(jtC?vYhbB9F*sRjC`xSL-YTejleF&Cg^b>BvEcP} z+mbU5Ly5afTz;H>y>n^+igp;TiXB#r%dwtKDU%nJ?`<{XAvegvr!L}J**!zFwI)SB z*CU(+g*!L_*4R%57>FD_4gAZ==23A(l(p37yVY!7#WeC^Nghni7O9>|F==KzIDbP(69w zDQAQgu{{(FZfrAfTCJG38$V{9Pa3ZKA|)mZpM>K?kJ4FDhPCWVnz^*F=?jl=ZI2Cc z6b$dna4tI^cg#`_Ro>GDV}Gii#;9IeM^TJdEd7!1t&D-jprksdv0;bd2a;uc1C_@8 zIpGdUlptjYfPcQ%Zg&4XghMQ1Aa>1>mAMjMb#S7ji8y9W*Og=a#3IN+JT&Z zj=`K>LPCg*$wVILm zb`S1lN_#uPI1}^a^mLF@u=%yGLNYkPbO)P-;*OLlL^X`Z*Ui`pHIqN(~B-5DRhQQpxK$hvZ|F}?Vf zVayA0A5ZKcsXWkX`E?)N-qMo>-xD9M^+Ud1J6}v|JTlS#`HneW%&PVA{9E&Hw7# zH%g5+pVzjVk$JMZINCZ+Ldc$Igb_&mHv?VsgrQxtClp=P+q^D;vrKcuX1@Xq95tSw z`C!gsSw~&%0FMcbOty0W&_!pkiZJqvc!ev@a5AUJkS{D(3MJO8(~tVw5bj1hwV1g> z*;uu^EqA?l>uN5MGCqr9G>wY$-*tb{Db7$7SKy~rr&JGG3{+I-M>emY)GHoZuotsA z1)0C%HzNO0T&>-%RUPFB^*r_IyeU2H8xlTc`0-xB95rw=U#jp!46utBzEO`dyXR|A z)+&#^ThZ$J>NQI3U{$WEq$v$tB_X^@QO}mh7Zq=tu-(|{laR<(Pr>i~bZ_0CjC9yy zt3`Ue?zsihV0%$xiz+Fb%dl+W$hq2@*q}N6-nBZhX0{8(>{fvrPlJnPI74Mo-1-96 z{QOX3lA7c2r*s`GJ4S-1pX_bZ_Q$j`P(yaAEz%u9z^`O%>Ss;kkCoz&E$AX>Jfk{< zy8_0^Jn1Mau~--u)Kc`iETWbKFU}&#bU!I_a#5b#^{OjB`=XfswcM=z$G;^y>5S`X0D@gsJEESh#pAmGN^h}QHB2kMAG${m`=M-wkLp^`&|ct~ zSy(vNej>T!&AQEjF&ckwCJ!#O75Ulkdy2cl+$1GcrXSKYc@}ebdE=LnoD?eyZB%R%_!}K;{EAY*jOKqTp zNea7+el`6e&q11;IpEZ)Y+YHfWLUOBWXtP=-0U@NH1wAKJk93b0kfie{^_=5B}VOj z@w7Be9@K?9ne|L-P74;^HTR2D>*F#IPZ#^n!p`qe{2^eX4C7es z>kw`jab46J2QXR?K2O%INxSob)3P=otp$EK_yl*oVEq(0WJp z|I`-+w^J$m5Q&-$(5=%ejI9xlO0UyT*l#fZlb#6jL@Z^5hK@JTSkzq2_yNww#c0g= z3*tW>eidH6c`RGsy|h?hgp)dP$mM#IQY_^mZ=cz>XP~!}07wXW!^4B|P`uXi^O8Ni9qE;Mh3(N-(pdx_U1?i1&U}d;1Z!& zvq2$53okA$ZQ$DXwY3LcNw*0{C$v+m19w}}7&&{bqU=h5)rKQ3d42o2xd^yw{m2WH zY$>9;$C!TbBOki55vVMU=l+~y?y~+o9Bja<2vOLKFfMEorEGLY#(kV_PWS_>^WAf< z`$3^Z$n?H7K;`C#8~3DdH0iaXEc#>35RfLQW8O}J-$9V#u@!j}V2rnJoDmm_gqvrxS z9Ofn98a=9QIc?TCFqvkd$E>YWldQ4D4)+07h}4@MG+1LMtIGYRr4QtfuDDV4bb(|g zch=P|MD3Ow#Pd%8!P(opuxXG&eRo@vwlm#?jkRjd;#A}Iw=-`Kuv->F1tg_Oc&a4-|&kk@%tl0Hi_JLUCr@;6nWjFkG+*ftamVnSc>SC~SJ z>(6XtghHEl69!f(V)fo-zhxQ1qYku6~lvw@|~+i1F8ft$s(HEN#qBYG`JD{Hp?jzO=~^;Qv@*4Bq83pBy1p#z;B- zVjIH|ai<|i>e;Ox&s<=$e+0?I47-w&8sCzk;8N}dhY=E?+5p#Jj9i{GsV_|pS6X8( z8;POfUvMTk>&0h@~<)qh|4JCY;kF>fU`azY_YO@y@Qt zppl()Mn*N;feA-;ylQPK@sK!H+=sLj90;{;#!Td`Rx89h+4IWnrM| z+Lh2w_6)zPPlN4B(oH`b9yht?NkhZonPS3s(mnV*Me<-Z8!LVuCPIkC>Sq7ky*q_U z*GTqo_zF`ULtcOadDdBD=n8RqYLTz3s}c56L?m{^2M|ihB9DZ8L;m z18J*Qmvv(6EIDE))|YYG=L`2u(hq-D@|Qk6y1j@It0SZP1Q8xGnp%a@_#ruKR$2Z{ zrf#-+f?mI*!!I_t5O@x7=6olIVi<0~UWE&lO~FM&Zixa$^7H}wrVmsN^xB*6AV2#r z9Cnp%7<4~{&9+V@pY|mc?^&@v(IA*`(vE5_=}>aouQc|w{Yx*C^0g`NAzcqX+uG$Z zbIOHz&|F@wc->Xvf*r?-dfM&{|2Z#pw^$g?#cA9Qn^+hSPRw4AE%@sq4Y|!@2 z?TuaoW2K1r=6YEY(G=~_?U$~>(+p2e3Lg(Ty8BdwjTHA1=xju;(8r?>Tc{f40{CV|p- z)!XG`QYfBRZfhh5@;rV%QXWlpf(0H^nnT{oPL9_^Jx}mKw@OsjeIM3Zu{3u4n5uA; z`cv#dgY7^h8N~vkrA9Y5g{zf#IemcP@~&~o{x>|L_I2CS#D5XnRv3S`BDn^Z=H(CP zg%qy8VAg4jOI_NBwczSC-5IywpGwC4n`t~YKA`5lSgrCPn71U1X*WMR=$o@aoI`Pa zTt-7uqIOJ^z5GL&P`RxA|Ku{2+Txs@XDqaQX_W#y2tlg z_C;7bltVHtcs8ifwW}TVxS3XFaJB$RL+E5;B3t#$$aG<6mPH#E*}wo(&7P69r(iR^ zBxHA2g=M0$V#T-6;nDtn5&-Amv(raxx)K$5Z7=06jD|hi-IH!oZVx97n>S>RMMR=L z+ppEGtxqF1XjJ}&o#xk^oG>_x+n0AZjyLf;#aD3JCxhnYVjY zHCxV{;9&$whRXL@$|i9Vd9!75)lwU22kTL^%|tZb>3H`O8W!HYZ%KywKCh)*8h^W* z$2q~vUL1D|m(8%*-=xA->Q{MsyxhX`*5oax^V3S(JB@zvY0-nVE~Nz+vSD8OcoouR zFQRZ(f=?RdX0l}sz0ZbvgB zNRKY<;*mnlPWTkEAlWY_()sfT zGOmydc?!$QQMKAgWm#0XN$+V~S^y5;Rg@kkxCAjQ71#=&*~-?jT%0J z3h)15P_oNa48yNZJRka&P4Z6TA9~5#s-&a@0!_A<>RUSf>eb|QpOz9xAr+fdyC0`n zj*)eS_K#YUY&vrL?i_HB{rb}Vgk@Sa{-&eBw8(Q`ah>3TDAvmEPRl*M!tx2&3W@YZ zw^89_dOvwr2XN{Rij4XUK09B}_9ce_nOgX?EB~dy`G=OoH^%FS(1W1EN{rgdLEwsX z@hz(Fc8!)payR6+KmOUSmsq;&+(=2!m%n(4n;m;)j^2(iUv3i?lSlz#9rGYP4@5S1G$B<$$XF)YA7B?BE&?OV*5jP5Pw z5a*6WqQ=TL3C@(-w4=Jk?u7&&Wk_u#yZ%Bwzkrr54^8c^f+_Hz@2?n@D6gQPp{~vy zE2XR)-p7CLb$~Nu77-fYdzh=-d1=iWth@+bb2+jUMa`m~%@e&~a04a%9&UADN z|Cu|(pC)-afsUjks`I(H7U|b-bL+rENorus0#0f&&ama6O zi^P3Wb9{_vYDNokxbAj=D97TAFIh&-hM=MC#!duIof_NjW%p}OOqfq}X*vy5W}Mr^ z2jX{eMaFd%Sm?zGFyhNxdR#9GarY}le{5t>$#l2-a!uUoTIX=L4{EtiYj8>x$j*?- zZz{5bgMk}_5E>89MtLKbcU`m#r0%$*gIFhniPbsYGLUyQ_3!Q-8_m+p+`Jt%Fyrl^ z68&w;fLvGyon6B{^Fnq1u*g8TT<4zShcjJDkX{?EZvLBy65O=uM76e?tF_}DK->(` z((do1^b{bXz~`e-2j3y%_+c(B87tJYZ}J~JL#BZfUl7I9{jsDF7}wsMc6%SssH!3- z$E2B@kzurOaF2rbLvwR8g@7Gl*Z?kFq2*+OcBRVSKnoWdO}4vSxDfJTPvDV+dD`l` z%2+HF9|U5TC|x^AdPfJPjO?3geLv+1c2h|j_o{f*6v_63ICsJ8SR?HLz^-GX(IU(D zynxaETgmZ5hL0!lK6b`l=}(3}G-9)w)(I;?51SSsI#I=*29{JJZe`xtsQHnDx_eAx zfBO6Thtq{>{$2MIAhpV2+ob_6Wuc6)Aeh)Zxt0|pw>HzuCP^AzCO36suLL!zwifBT;ldKEori=r!K)1oM%X_fU!e zPPwQ(jt3vJdrVN9PYb1CDPD)&&qDu^0pd_QvixqV8^7mTEt(&7N(M~^VoAmb*6+8$ zl!5uS(pQI02bRUmqg<3Isx4cw6`1!ljOlvuGkQV)B~NS*`uSSrNYw|I;LIl$%>Pv* zz}~XwHKk$6U9wzetNCSOYd`O5Q`|Qg=5-_J4|Q))Wo+fYpSk%nxq5m9^}Q+Y4$;)s z(sDao)SK(=oGl#;R%)5E8>Sg;6-hgqfI?68kr_8E@y<80|DmoDRZ@HAWmWOT;}L9o zeTlj``t{4t_|Djvd3p0yWJmp{E@=XO84K>K9qC{ z?JXY>KtyCswlBXolqP+Vn0jZmW>!bN9(espYi&i#tW<~*3~$wUJf-mVv`r;E*s<_;*k5Ir;J#gMDQjp4Zw8shuKCUe}OyGzN9v;gU?yKxjE)jNA{Z~z1w?30soo4|vy@b!T z;EfCqH98`;#CfU2-XVT!)2&+S>09FArKMe-td1MxL<4H=x?#`bd?H&)e$>4ilZRQz z2M9m;o4Ca>KC0-GmzGr)yp?+WM0{3NQqf6KqJqy6<{W^}@!NHEmeS}jK9CZpnj=Q*wiylcy$+w>xFz?nG z9JRvdwF4uq@9rdULiWSFWTf7Xl}^?ixO#+J>Cb->uAk)SnU-+5H-#Ew=d^3w#GKr#BSrPt0r;MQEE@^ zJ_*vz^7$*>-31b%x&jT+qDV6Jj@{4md7;k4S^26t?CCv{yzH_vo8LP}h^lW=)ANntv0Eoe zxjHIW!yeZzZN?2l`)0k0I0*Q5a6Sm*u6Iu-aV^XVKhPVO&0VB|Z?99%S*XM*T;_jI z3D0K6EdkRU=JTU?l72g%f3A}d7F@$FJ)`3KcEmLGv5eEtv0w1JL+s093ICg-W>bdO+T}fdR=Ha=hHcW6;kLv@nL`swet+@crc#*z_W+K>3^xi zbFgN{?F33n>s9CFynG6()c$+9pybQ;1r`HJF1U&PjaLXfpeO);>9IXZMMsQ?c=E4e zdfkI2=l5u{JAHmD0((2>gpvn-RhSL@O&f#6I!#5SqeZ9!1Rd_Q(Q7gSppul5LKS`( zt@tGTWP5ksY^2zLi_3lr(!m7O~GxjnE311sSpntcmqySBGe zDR@b!{oMnXkHj=8`vs};zJp1r$cTuD$TEJ9vR?XWX=#}%#pRKS1~DUU3WRten;*`DJ`81bfBUYItsxZQYQ- zF1Z}wkYnY}QDmcOk##pT#C)!Ze;Mt1t|{unoyAEs9fnF-+><*~Od8d8JDqGNMl z_E``x57w_m#oL740309oubGfOZ@7(B!19ZOe7YFoe!fW=;#Rn?KkR-zV060AxyBvw z8inkw?FYT-|A)4BjIQhLyGF}2wi-6J)wr>f#&%=d*-c~H*-2yDwr$(C^{)1RKRWOI zd^lsAv%h7GJ$BZ*uC;!cbN=Sl)z#JCpZ}DBfX@>{M@>P|$0lbq_EV`Kwy2ZAMuYWw z?QWd4#X~q8m&x>QjptZOfs!KLFU7Au)bFlUHQn>Jg2t$(?FZRJq(vUAKNZ>Mc%pTl zhA1U@%csl;L4b$-b?=iFHX{C_yR^|NP*K4C74Pq&_|ucogeuM`%YQ*epqfPt7f?QK zIW#!Omacbgp4CKvyIP@dFge;DMmeVD9Og!k(zJ2;{P%qo5?I}-WlD>KbP_yt)!v`o{WglS4NO{)i%xwYp~*6xz%!*sy4^8Qkux9c*W`FWU&^6 zaI;!=A?G7%>k{$$-#FvEBh1@BEOUTWN$`aB(+-EJ{LB+dtCU%~Xv}rdkYB?x$Te2J zN_T62oiwe??4Y_byP|2)|6R|! zvArD&)^to!05smq5Pz;??DyW`p>~VADtQb&O>IVo9vU{&0{5&I*KC)5w)!kfIt9OC z=ge|1&GiaraV1{uS-sM+)?t{1>f`ak+Z_~SThadCp_zv-(Ri9H3ab6v2FU@pc9lxj zR4=OemvNk*9AqA!oh~z~FEnft)k>Sg0Yx4q8~>+)dq8zDZcVqk9$T7GTwMJ7`%O5# zU%O<~pNFFx)6ZnJ#pAj*e)f>{w5|0LK8A+P=^V-tFxvQ-Ows6`b3{2?2JpAY|Elt7 z>LWRtCw{AqT)=@9E3*3Mg46Y>7PBJnPy-ty(ooTmqN8YHtu(gg-F zMpC&O;WPeft&QItT&*h%KwWBa7iGrBQ~85rdh8~!-0xLj3%b^R0j-bC;L zI&yX|e?yR4^R%Cv4LiFDR5^o<*ysAEGl^Almyx97&l)G-xxcY>a8r3yzUyEk zjqYIRW8lvc&RDRb1O)AI$Gb#wLc)R2mg>c7e_!-)UCfVQ^k$=zdK4PJL&!C;xqh%)zl>WsxXYb#(I z4v@O_u%m~exl@mS?Wocl(O?z49JeCE$Hp9o$yXC1XPG-AxE zzTRF=(EVi+!jioJ*F&Eaw;c0~)IH%>i1EYkG<9V)Myi`0Ss-_@FT0%~ zJIYX7&b>IX4ufoIm?^PUN5hFGJ85K}*Z{ZJq!2y%$CA7DRFK(U9Oe%1%$W-t8(YN1 z_4?qNM(s;~eYv~Osce52FX81~M^H%bC$0i%YAp{M&5-ZUHcK`pOIEZvK8T6gd@fGg zVmrMlIHPX&PFX**#rYv)g^p4y*=?rMY)DAXL80Pv<#%(3uA8x<7na31+in4;4p#nN=`2xnnJSX;W`rgd!@JN`y2cB>?#rp_F;d7im&yL*iB)ts&hre*2KPL z0X3HLK@zk-3Tq!=s$GV0F1oB2cp|+lH(p_}w}W0XNP>4{(&#gCeTSFWyl1HGP3g7% zB290M5k3UQdxvdH!uA)eHNSLUtnB>%T#kx(AkE_k0yqIrT>D*-sabn>TCH18jy-yv!3K)e=j537GDzP zXlPxO>Dqb`RdVcw{0JS`p<;4^W<{70SCCkdgru$jUSecPN~&p!uj(X^5t1NDSULW@ zGQMP`%A&U>)GOG?`G!T&TkNk;{>{SM;g;5x^;t*0(Ymi{_yj(PBW}+kZQq!r(4hT3 zOF|9{ehstM70UM+QB>rIBp@+#sE{_aW)?Nr?^c9V3~$v6+H_tC#Ro)ALR5>rnoouz ztx1c!n6tVm4ll8f6#H}~?(`S-GX%uha#};{`zOhi??&)$n3RR{2yLTLZ~2Jd zc~_^YO3$-1lwcCuBj4Nj`XA@BH&v+@;wH8YW$XCb~Rg{kX_k>?(WEFW5ts}Sg4Z~x8)QquIm%N z%tQzx#|*NUC3u&$a@~mhqTM`aLTF)|&eOr)9!E6&lVX;dFlEQAFPX7G z`T9STSO4yCkhqjov0Z%s;>h6Vv$9hi_DR*65W%)W;(YAH&lSEl%3&#CbR~Th`5mE> zpZ0d}I2=Y-O~3G7-EGg=diWqtPkBslb@IHtsC}v(#?cX^3Ty>28ms0Rk2y=3+7*tM zUDk}=Jkoe<@rYPRJ~8xxUVA6Fx8LT5yI>ETUhp$HXs^4q8Tqx2tb^?|q%gO|5WLxn zTe8YuI!1J*nzE!`mYT=I{^b_;J8#ea!R!5}7RTa&9XuGn52H3ZrDJxQO-{>63a#ub zFOH+5GD{WY<^zJ69;jxkAGM^HF!&k5=%rsBX8Et)DG#4Ev*xR06ijBH))~y+K3-1| zl6V|#@}!Neq%Pw5%jDnV_s%r4?HcZ@e4_QMC=l2P>BYcrf^IbIPEg0yfuDOla{Q*; zF&RlLH5)ohw_l>d(rY87mKc*hIqYx|)ep&XE+_IY!@IWI08<7kE)#Tyj9m|! zer(`bw0mlg-$a<{YLuFG-}WJ48UMWyRP`{#X$xA3e4wHSd*=RVXkuB^blDFC zcG08lB#({dum$fp6p{bMN&G3|K6M5|6$2@hZndJ4$pX8 zdt3aQHOj>##3a0WbRdb2N{nND#^Nel(g%_MS5c!|&MlK{t}bRymM)L=&dRH;;PhY_ z!)M~bkGDbJl>+=(bJ$+wgqNAJT-Xc~bg+mpJCnrhUgCRlyJljk_P{YJgpW(}qe1>fJJ2CKvoHA0h%IQ?ATxIEugf#jyBI4Kq zO~lrc3Qq^!kmE6ark-q1GKt5_cDu)lLQ2Izippc}lavcU%us)^=CV(A&5{WDglpGtwJ-1FB7+cU<|mM4165X4k$q^ODc zPBCl&NP?iGatCGLLJB`u~!A1nuRZDib37Q>U8*XMX66Wzso2NsZ) zY(9PS3h36uz=&UIcAFIz7Z=aS%v>xJgG_H~YNC+OS*w$z<0m4@%oGW)QH%xxQk6n` zCVnHut0?5yk?R+q7;2;E%N*CX&nIo=aZ}#x26{f-t5UNUOZ0OpvA)80;gv1-m;_L* zRj5V^qZvVh=OX^>!!3gXkoi~h*IP4l51o8b#qrB-b(#45kh%-fJaM_<+@i@keF1+G z4ukq_z)%_UQ_Q7=5%{R;Dymkt(_YcC+Pv87(2DH76}HvkQg{!2HO;83$q_uy(*JZ> zs=a^Hqw z6RMEgt0_EsW)7jYoSDjmK8QUIm`2UpYtN_{!&LYoTwsR}4la!`7ie@#46H zHky*kD3&9*%c9+#8;C_cr6u_aotGz451w$?0}DtTR}z`Dr^PDc^WEPC6&5fn<-t!I zm64Vv6~=`13k#E;%#MnR0(38X_8bfeDJkZ9di)_en2t|(HfU(E3!*|o-56}2?a6^r zkS@SLOh^b#QLe_%(L#_A{Zgpkl1DQj+Y;;+AFIY6vINY9AI(>G1$-y}!I%ih1Bf7Q zPgiBK2PAbzHqYO^dlz64A)Z}RQ?szJ(AAx%0PxC^ud~2|z{CheSSkIVXjr_&4XbvmA96 zJ8y{8#Gam``3CugynD>#lGQ`~z_lJVt5*9&x9q0+EKZnrr>`mvxm;^kx_(ylob`HVP*~mVZJ2?dpH_xW6JAo ztqT5_U-rTzi0&toPARLXCA2JFts;R*bn# zp-$zb@_4vzbV2E50iXBFgWu`nKQ$lni<&8~@Y(oDJR)2dhbqLxYczlDG_pj{%Xd@W zsI^!W4)O8#4;CcUs{sSsN)+ZIB1b^mvun3AnkEzS^mIAeQFIJzs9EFjVA{XIMFcI< z%1XE$Ul^06;uvkxdHHH7lS0U(y~(Rg7qWMlF<5LSu(g;lH)~a;$lldG71NgtfsIJy zNe~G6^Ayv+6X_EA|I;#M`ZOdAv3a+qcfDHM07w0HB_%mqAGZ#3pa4FQ2~XSojPfW6 zP2bYtC%evTr5_RRK%wLO!{p)&jVfE0Gd~ahcy(i=6t6vPS?$8?3cE!ADR!skmaY!>Q2d^|~Mhl#jnsor?z1^$0jEoFvv?_LfULL0Y@{t9k&!+QhA`V0& zElC3?D{q~#zP{e+WQhQR9VZO%rwL};nB;Q13Bhrgmd>S3bBk7%opKSSDu%D}`;}tr zcFwK0r7QnUaGmNqJslX4xf|1I;VENvm*Q{c2QgF8y6rQBn?tYEQZO4RYB za2cXL$aYE=ci~X}vWlFD?Sn*z_enBw-CW*j@8rP)WR-M2N;?@4%zlhN{e=GRjhf$| za!YInS#9)DP0oRiY3I(Ht{1;3&@KBx`89JgY7k1oG=r}i&Ez`KY3qe6(MSS`zt8>w z9JPNjWA3z_O$Ias*KZp$SEh;{U8~smA@5fy&aDdOw}cCYW}=-|Q<@s{JD^K|#RCSq z%`p{}m0yAQw+5pa7cZ?n+nrOZWwCYRd%Jf(ThA${rIq@;J`TQ&4s35NL6y1G<`0SJ z{{aGl;6-*u3?z8I1Jp3!>cGPg6&JTtEHxurEHTCCGOX?M(-8vOd^Ye76h`@E2xN5V z-@re7^E0l^W~qeA^nk3r@vH& zJdAQ8$x{kJ2HKvSBy3G33iZ&3^f2I8(SNOh6^Y71u|$85(1gOO*!YFX{r2i5$DGnH z5|d+Dm=-RKBKC_<1wU66L-Tn&0CEKH{3pmEA^*$Y##9|ZJicD0-$3;S<yL^1>wDk5)+Qihvsk(+h7*Gixa#toqMg3AMtmS$G zEk+qzqFyUajEv-M&*1@tUyJE1Q(z)YMof%Am>d;QnK8{B#SODVp(^q5c_}(Ix{iXB zgF%m!Y*fwJXwxAfVK5TNugi@4pvoJ27eP3lx4Qc%YY2YZ8s9#{F@HoCntMVfWPXBRmykddlbF;2>^#Qt6YPT{N05hfusXe zW}|Z{+Mws_UMQx3NcmaXo5R^sqtVnZ)~uYI8SD&;=ZEWSKby?S{npnPPTzN*f9W9x zcWYYU9&o>E9`DX?@@Ddf^sqW%pO#=FN47`X2HOp0?Wl3!K=_7Kxk+-PZU-APR}kkX zihuZTfXa7_{{mEEe6#+y!Wmf+J|hU7#M3TGHB-xd#eHrABs7$0afnl2Eko zA~@-7T~$i}79d@@6PK5l(V40CO40SseTq!4t2?U!k(t<@Kp{XF=KHaQsxBcO$MeM$ zZ+1~7H0!j+ePR3@Z)TkqZzc64)4y?UzmQq`BfpT1uE`MrcF`6>cK{L1Cpz#YfDS!k zVnzM~4~3-T)q5c#E1}1du0mRWMNifksR5sXL~cjyHX@KZF4NLNzvHvb&crd6Pv$d) zSNgl!KTN%>L|sgAwu4A2AXMzoZU*80n!4j^=tsxb_YZ-8Dqszz(nbb|NY*&smL4i7 zX?48X-5eXv25dSb$gn)0INUoufzYam3tcbkQ}4MC&L9%0g47WPwaPw4DIPr3#x!xW z>o0{6oS>Kz;YV$}@SHF1fc*nMe!4E;^|zGfJ5gH%kyU!h(WX2M9pZ) zqH;9CN@j+Ca^xSf`5P6*^lBC2x zO5oD(5l((=gvgVq#7}c6WV6Yp2>k_@WbSyi1p2H`iWz}j*b;fnuh8tthg{JJKxa~#zmVyxzS#!#IJOmmLPn{a6pYm;~1Xik1Um%qd5wZSPB1v%>^ zpKjv)M#B)6wv^ znYmX!$<1o5yJ+v5RNd7V9;kZJ9W3=)sZoQ?X}R6j`VTF@dKtTfTW}heWd66Pt52Zi zr_&=rB51$J>*kk-(-H0Ud_dt(gbh|}GHj&OKE*RP-)FJQsos)d^Y* zy(9EW(8hGyoi%BOpl(T|%GVU`99iUfJhLj#!3$epK}ZUUmT^^G9OXn&*HBHd_INEJ zuO@gOA_m1K1EN2(pLtHG*5kO*%2C~Y4J|aS+aT3RdXGU<=~?fxyUgX%ib)PpxB2%n zf9H{-2DP?=s1!#&m%UuoI7rOEF{DO}ir62YSWY5X2nDz~amX8k-3uiR7aa5`MC$}!^=JpF-!bo1uOndvrn zB`MlP+|}cW6<2*j)U)+c(r>*f)wNo4fkE>V-Q|+z?8@+orHs2$4PP|;pkq&NlJmz0 z{0lXZxocV)?hiZ|Wz2ugPP>PT_Q+fI$N-==^Rx7my^BOy5!q=AyNlCv-ToCP$weGl z#(o3!%VGZp#JO~;vX(2*Zpz`8aUMuHd>?&uwZG0>B-L89avj=>f|xeheJ!Qp=B7 z-=4DF&XOO|0~@mn=xJW0t_t^Rp%S~_b$k3JZr}X&$S*2N`D(M_>|!{Qau0ZTQ!*;LksFT;r8PZ}F2h_jjrWCqD|2KVyKfJpXmL>A;5KyaPmr zHN#am5L|B^%{3!(y$xp|%0BA%{eFBXc_h2IiNQub?>dG>w;ePM#2E**;R= zU$1_6D=!b`LypBjgZg^2seH6>-|EFdpG5uVoo~ZfKX zVrlm2xCMg1=R0~-h2$r*98K4llWj9mVP`Et(TtrhOkxE5q6(_S88(=rQuG|e9<9>p&B!10^*(uVCGV1vzaBAsI6J2agr~eyHFqo+$2X7 z0OZ8R4cKQvA|TL_led*MP?uIzOpJ|B8rWTeaH8#K6r3&pxxN7 z{WezEpnOXbLY%bL*mT_0nt_h}>1mp)q_!MOY}MkvDsz*&s8f@xiF;WD!Kr*w*`x*9 zCupN%Vorcnr9UkL^$&N$u}jA*dCR~|xGU_FWq*Syz-{}UfVha7QmO@M&6=9EKE;A_ z{Wa_ayxT=U$+uX|C7LGgW2?nYE(toW|d;_=)DF=n|YGI7EKxcAitnMkA7=NPg6nR)rgQa zS&FM&NJInyceyn>eaHxbZg#HTj*LN0Rn^48LWXWvj#G9;_0s2B?dON9UyRpv`| zk^GVD7xHp)A)%pxNXUWqYi)1g{k?&WsFDDLsgpTUnUe;FaUO`#eO35UA*ido&8MYQ z2T4l{edU^mb}&>N$9Sc?uwJy_-pT8xV*Qk4~I&`NQb9L@+m3W6&+MUS2DV* z!0yyC_-+xb?GZ>eB-3sBH7Aio0HHQrh0m_FI1ajjj2`&JNjjQPKECr(^7dfjsMl!a zKuW_zJX6P;G#QusR=Xzw3IeV~0;{-Y1%j|Zt2L3YSNSFiq&pr~ZMY#lOP}+DH(=y~ zWrj>+G@v2_?}vN{%hp!JZ?wSfi?p_cNf}bMXF51B1-O`)!;)O80SSo5g!^Y=y9>2- zThfgwO1&S8zL>?*eqBCasi+EjiGze*8wxI>eN|@^~R>Br! z@2=y#Z{;8sXt{i>d^sty}f~qkMViT4yBRw(S^(}m;e{?#nsIJY7EvHSU zq#ThB^LSxSeC#}zkcM^&o#X@KlU=Uzfih@Ol#(2ZB?8iT>nQoI$>e6_GGDj!1wwG{ zb~Q`2ngg78PF7pPzw=eX0eE1oOkc`HiU%@d147Pz-v>7po8D2CZy{Mt9@Pf3vU)#^ zdCip25`~L73!?Ql<2l{J_9seNZHZp?*NqkxOc<5-iIK&GZbXEk(Nf!iu3s1p6?Lt2 z)Do8(rHb2!c!051&q|A(V_{SjtnO4wd*P|J!x-r6D`X)^8Qas{y}!4I2t!Ij0{6IS&4h1h~Pt}o!c@U`4Ixx|Yxm`B-YEK^r zp?h z5so9*m%X6fd@+JT- zn)N3KFGJN57IT1I-X11kkMjrB)yt}?iplno{PE}}FPax0OvZ4rdTjSBoao3}s7}iU zW*dpP$#h%Bz1@J;4L5*-hi7cfZ*jTw>yEu=h!f-B;zSy z4Q??rafR=S(Ag||lU-OLR3_KH?pe-sJoAk99D9-BXE;u)NAuRz z36T@KsU99j7(}wPE4&It&b)TkZl{Ra#CN=;S0JnC{VMj$lvA@5ML(WvjH1YNz&-Lxy|12KyVEbKad${X=%HxPcFt?&23X_74PFb;-*P#@!4{c5b{V7z8oXyTIm;KRX#R}|3ZT}u7 z#S)J@W(*54T5@$X4^Yw+i^@6dIgGbgE=t<0BQ2I37~9|BaOrNDwp{oTqp03k>}EAG zJW&p>QA8@r@VVBgH@TkodM0H^e&TSmc7G08otC6k!yuAG5O+@|A5Kryy%w+wk zh6fS@p$2nv(!{hm#KxEOA?jk0&#v+DE3F@Zl#AOVQ0EvqO5~%HhwWxU?W0 zkYHeAlFykfE+^;p=O;8V?LV5BNTK%7Ow@efddPl1o8mKlJaPAn*+4uII=xgj`@7u8 z#N=4qzF~p100JJ@7h$DhwQ7e$CE`J0Y#S(%IF+Ck1&1BuffOtr;^zH>FPCNQ{&$2h zVL_D5mhtFu7xKZ`l$4Aj`9I@Ivj{KKM`X~k_LGtPRr>&%aj1AqXBO8>(S5%Xl_e-? zNFK4{$Qhe6anXz7yd1(0IE0Iqrf)DkT9l)rk(r1`C_Gyzq^Zx{Xh^uLw~oo>U0fuZ zx9Y5_1o4_1JbshGAi5r(UZmPHYy{)_WVBMy0OpRQ(lYMbKtVOt*x$u;cJB#K6fN=X z5|z3=$s@lj20Fprxwi-g=5|D~%g7B>9fF1BfbO9&IzOhNh9nz?(HAUs)xff$=LHcs zIz3H>9s;^7NR~p-BPKYpzz{%z(2d{O+TF-@$5l*Z5_WGlgPR|@ol?<P`?4lVH&?Kjsuc4a$ZAtAI&ArBI5Z7IKZZyjs4?EgUQ|xVZ|DWq zdP=8qv0JD;dwrx~j_@)so!w0o?Umg5kdTOFG#AS!Pw2i^F~DqHf!L zs;Z@hH!HVuM%9mMlJ7uTQ;?DIi?4q=9#`{$uL537bw}aLkaXF>446Nk)4YO-CyznZ z{JKGt67`HK2pX2dffU^x31z>p`in5&*=9C&FCNoSTU{qkJ?I_ z@83Kg53qP#b@xK;@quAsJT@C$KuTL^AP6-L?s~SXud8Fm@%np~`L9rN0}qI(RJDEcPu&v(Zn2L&0Dz1MWe9zBv`_I$eYeyz9GZDiDZ z&70!`C{M1iY_bOv^FTck(xKJ~u7!8{xGC$oL;_?7WNCS3c&fv7t1+*Otp)`w(RTt_ z#A4FX#9}_bAPE{uGH(Z(8{fN{Q66;01u@&1e*5;#jQJl=Ainjlp1>f4pGP1PKP*Hh zV4+M?oM%`isG$;dcN!=5np~Z$KF87N@5P(?qXbs!Vy_?!T3cI#o5VUYcC%V_7`}(F zzp>hODrT&tFs!8sC{EOHFdWT?7kn3C>8uXI|3em;vs*Xv2g|y>I2C;>5etYA0mNGB z*~;&QCuS^NMsAj?plt8B-ayMM*uqUS&0suAqrx#k;(vnug}=gXVVQFO&NY%3EG;c{ zzlCuZabt7e@9LJfp$JS<`~SZy57oldVV15X(1KTgp(|GTIYzepp@&?ODBcD|@|7!I zh1__+1afj9PdOgchwpbgZ;ny!IAOU|SF*cO z!I?K^C3=R(km)lxO%;Ya3iGlLy8m3`Pe;IqK%QF(#wa9_CMA|LeK^yLkX}iHFT1!tY43^Cx+I+|pqNRKU{tH#XMSJ$Jc^9>+S1iI?^7(OB*%8R zUCH>B@O=D);ekG#>(2x6&5e{g2z1Sn>!_#lw>OB19!BX`RrT-B7h3gWd3mu zRlC`GpFb(mKhx2IKPdR#-RAoHcAX%3M}L>sB@#a|$E;z4C@2qOu#M20mhv(_c@_f51_TwI3uS}A1wPdKXVV`51M z!Xa16xRdsAvr2tZ*)$^z4_Z2Ie~!X}z@41CbxHIC(RdJ6Y935~C1HU8N{ldHfNO8l=x^B$es0W|5-wrQ$!uy!%I=8jDzlrlo#N)0O>?dI zoUqd-G+5+szTz@#ar(XF*NTJklrHt(IQu4k5U$=+ksOuP-DKXcjjkw#N#}Xr;xMiV zTaSw%Eqrfbc{vB417l7djs$eX$@9ATtB1*wo0HXt{uhzhq{HU5L<5!5u#HBv5~;zS zy&bqoZ`G8|f^ndrc=o5M?Ku4d9Z6#Libi}vP$L?~jH0fm|6Qm#@< z_U4fu-nsJF%==|WM}7aJH0>ngU0k3gpBVbXalCo6ik-lIoow>TluNmX@W)*O__%BT z>)eJn-~QvE-!sht=<~6js~+uvJnScu(;iq9N&T&PW{v+Ri#!ptri=c3(}IR*7Nv>V zNe@e;N?WJw6G6Uv5+)`MR{a6nX^eBp2ji_jcxI^dTp!v+FHDx3@0Nxdpl%jk&GL^e z?Hg1@6O;Vnl6xKbUO&(XxHySM`ae-rik^R=s54vsEZ)B*KKjyuj#0`TXHw(0LuZ6A zq40WbG_Y=yVLom9etdC`s{hKIFG*Y=ilDBKg%L+VS3ZTk|z#D0BnActLzE={*^oz_hWqlit!-fM@X5k(*k>pz_`$ymX3sKc< z?O{=@eAb(bV;A7zs@WV;-8j-KJ{G=Q3G4m0FzB(c?Ypt}Gtg}_T!gs|RB7}y^)UL) zeL2n^+k>LAI_`Bq?Ij_AOMBstbQR{~Vs!COXN715P6zy!v$@K5LaCMaRGpI}VN-1( zPqao;T;U2g?wYMy!FZ@3aT*#P0tI}Xw8`%ut5*8W6|93k5coteA2%71fyY^1W3y86 z%+{L-__h3Jm3SIG4RH64Znw>zkb@qd7YTzL6hP1+hx8*HuLI99R~X7Xm@XWLh*1Lk zwb%ayV1~mJJ7qyj+YI0&>2WZtkkao5kEL!a$18nhx_=v)CvudQfA7SxRQ$Iv>d^L7 zqxNZ;z8<)Hg}7^u!lPuil4sLzh#i-!PuQhPM!)l$_CKLn z7Uovm~YnXB~ zWR=wsPg5EB%k*K&f@8#A!Y{=rqjzQ_+@h^(W$x^`2uOyxTJ>W0WStv=?)-50m~qokOK55T<9n$&<{cUr(p)d)+z{~I)Q9mF%^t)CiSQ>e zj{CB}qzezvQ#z>-B!P*^RXKo-3}EJT9Wyw*1`~=Ag~m5h9n|Cvh9tB5%G};L&i>ZH zlv*^|37oS{c{P+7b~~T!EP^j1Q%4duaIA1ZcsjgtKkCSVv3b|Qs^&bhH-GoCzh`Y2 zUeBAfy8853=$cG@`V+N!GndAq&gOz#d^&eo0`_STB0q^u z?Phh8N1a&hMi3Hz_o-EL$C01CF56sZm&{e>M9s=CblJ5^?eXPT_S?r+jBQ9UIhoLz z8LV@o3e5BYmys4|}2n3d1&!sR$S8ljEQ%;YMYdAQ+?)0FbVl_Fa!aSprnIOAb_veU}& zVIEseOIjD$1i#FI|J;k_)l;;kVILE85ce@!DV2+P%C##oD*^nz>bugGUFI;uxX8mc zP7?OrlnL;r$R4e)$SBcqeJ1-x9K;xRf2Njrn;nx;M_4#9~D3oyUomk&lJ7} zEZL(5zN>C5_qqooEn~vn(ks<6)!r(GgylfH-^kb6Gv&MEBvJ_8TUnA_2vMwC!9 zZZ|V#rfcpUZ!DX{ueL&$t*VKus=8e646AReRtk@NXS%R4Uv{&@WCf&v+7Y#bH#WWW z!llQhF`tTfn~Rl)8VGs{mNy6hX20ue>aUs*4b4+eY6-x}3PWkf5Y$eI4D59F z9+HAVDFl4JrKQ~!y&*?(kHK>P*Z9z3wMcgc-~7yyO(^#hfh5SaaLl%(HQB2`Jl9L! z(1E7;mRjmd>3Qf$P!bIxmHNoT?r?M3C+p9WgI@YH`Rwcl4wuCC3EcAsF$o%IfMP-X zSJF~XPa45NKun{g)}g$s_2l(J$;mK|Y^dq{`h(_|!-a5q^7=>mY~thnXIm7EPmf-P zu?T5dv7XMzT1C7W{)RV3$LlvDx}%;6O0@(FzPeVIFq@WSruTQ&@*SHncG{~$oA8Gv zw6EC3iWCF4R&I+{7TM9evGKD_*NFTF?;S;&*?IV}1w{Wa<9VfTg;~L#WG|lIl+~K~ zi8m?OHt}hzln5dEb0$w?)+#8|UVij`DgVQ_-_w>t_u4culImSjlY;N`LpuD%q#aOB^qeE9<`#cU!D#kD9b6 zpIZ}&3S?zcA5oU2JQ9D#{!xs~|6rh~<^6HL5($m7%ei*ywrF8^rH*}<#S<4X&FnU> zgGbrEiam*6x^Osoh)iN@jszUQV~|#!-8DghOsFPpUdk zwSDKTicci`woKg-31ml>aidc>D*J#c=If3(Iz(^W`Y`+|@sP($?*@Uec#Oq!?6X>L$j#|>Cx+Cm~@QLuqlYZGmyO0WLf^vqF>~@;Sadj{L!Kh>_<%- z&npq0t|KJzovy2$`^)%bpF5lAf^fnnaTa>NiD_*nFQXy9Ese^u{9)LK-lNHG%h6It zn41M%>Ov>TAx*?NlxmJE=h@ykR{X*=k~a#8W2ON**$gWW)%MY&XSJ>w=q^^47s@Mt zA2qOL3W;plEJ{P?{(L(gQ3vgV80Ewt0jjH+uc=0=$xbG6b${Y57m#_+!9M^oB7GOV z`6_nguTp_M-+AGf^X~gtCB9rOrg@SR!=3j?M2VW3<|h-<)H)3v_PLB_AOl}M);^!s zq9a55aylN(k&Xf$>q#^(LISq^xPs(#)PHoO-RWWmgt9`eb6AsQZ9=&lAM*Cslf^Tn zah$U_Jso9=g#krgCT}%1uDuyH4_^884euhW;FSj2T2Tp$waM@2=<4LtEj#lb`{%Nu zah@+lziY^w7muC~8&(r7`xV9=7UlO_x@Idr#?KGO5guNHb!hr_!m!K!`BC2-7XFvfGT~z!2^Bepz7dMf|s4` zu3L4y_Wbbs38Txg!g0tEv=7$3xn9FFD80^; z3-!gZGQ1V=u#uA;ixDtMm6RyK<`}D1yFQbYSI$jsWJLL{o9hu6=iN3;=V!97o+gpE zKYG}gO;8djUf5ffO*vh?sa|dQCM!Fx&G{(>VN1ThpBXZh|{{I7Qq#ELGZ zzjoQ@N9&>}Ug8Wm@#dnX9`S0Bex9PZH}J~%&shQb`Z~K^Y5h_|BcofkAJ5$75}mD< z8p4w-61*v&$340d>*l=mkCwu_&TUd|%`H|_c~|H<1_@bjSdVa>Hw9E(4Cnk_Af(Mw zFRaBb`gJuNSF6|O-OI_(?{w_uIZvj$TyAF?Y(J~90!|FPuFnRp_q{wKiSf(FBFCJ} z`fYVezSFu| zx_dSwwf!5l==Ex&!93|LE9II^FYfuOQ(f%0cRcO6kC$zpW}n%ChZkQ43Q1V3BjSnA z=im2v>-w+RJ$_cE>2%a9NK>Z-Flt^iWt;sQo2xD6z3<&BOjbKt-<^5AA~__=f*;}amuCJs#d*?c+z**2~_M?yv_!hw57Yyq?@v}&(?A>B^t zdGgg=+lL2!wd$V%r-Sw64oT`3RktYVA%*>qzMBW90|hR=8vwybj{Gh|!`wwqYoK4ckU**V?@o z8whZa)$)YQ;7e=d1V^2et6WZs*~p?YDD^&y^mFz-Xo7|7q{5!m6Y&94(U`tKuWqpk?!v9?(XjHJ_G*0=X=h@dA^%-?#{j;_PqO@nLV>+ z#c%yqgr?xSQP$9+#FK;jQs(OF%08I0&ukaFJ^}+-`B(T9zw%>hTSbXVo^jASlEej@ z50QU$j`tMvHdV@U0!u$#PbNG=OZ?ggO#=@2xitthHG;Z%x#WWW@7N#?iYJ#0L`9S^ zRB4j+zH(2ig?q36heR|g=${_vwG2tvhdcUN?cM%8hCNn2@wVQbiB1`u|JnYmy`AfF zay#9`5SVkGr42c5>qF;-@gFI$;1oH7-)u2HOB^{WHCWcTy6*fQ^md&4&hBdQ!+n#; z2-Di#VTzp6wMqDTSxRa_x2kpD)2ycW(7k)9&s7=2x%^e;=%%Hg9{Pf-OXz>LlK|JE zdLePwNwaTpf~swH@cq~K*?+1CVMJ^!EV$KJoziNamE~2aye%_Oj17w*)mH~ZJqHHj zSedal7QI(Px@O99#eKq>BSh#7PMujxo+(e=S&VN{o+IhQnG;YKnKdmW*y7VpL-|6l zmNyfzMbtZ%J<7ymNp~=>2gdj_Uj0 zmlyVTaeGCE-(!dO%I9@_M&lf2BbC_xE>BY70cCXUQ~yAZB+w%%1|-M`#(LwQYDGY% zapZkkQV6|;^@v6HK5@&TBA`<6!K9LXiNHvR^9ZNMA<$L^>1{!2;@O%~sRD)kcnJpu6`7%G~gcodRER9m0W93yEJPy|L zfZw9s_pm1et6dEKMAf$J6G=1y4Mpy+_xu*h{>RMG1M`)~=WMo99gPCA@trP6z-}`w z#>gDWPVJ_1p{lz!JIooD6fGmjuD(0{ykya0&Ptd6wD20EhlYozi?s9j35_qqgbEZA zpG9OJaHzTL6op^-((HWsN}$!uTlbk6omu(&l*0}7(D}B9p(ZZ#x*u>v(c!F;8m}$A z7YXb?cVm-BO>Z+qR}>|qh)aC^1Hd4A?^#=XA3=+k)d-*nDlIxPu0&clhaqvOFx%Fa zH4B~5bI1ZNO*A$Z)^(qP&=Yp6ernqfqullax?SReQAIOFk4u|sQsm}huEWHuVUR6_F{fIwcoQ8!!k|n6n zknlI{cS6Kk7q13?>BJe$*E*SxQ9yW9ueP^DrKP2lx!hhM;oYL{ys!Zn!(JJkTR?ZnIlh?Gd}sL(28-*WUHX ze{wUSr%-ee1Z4$4X)KU)x&2aCM@Qv%=a&rbEjE1ub29L(#RS_)C>sx!&U zB_!h9+!}wp@Lh>ch{L%u9Qq>wP@w&vKn9F?+xU~jf(!DDAcE-ysggZ!4;Dmy(#>VrEL~}^@ZBV$TN-uK{vKEJ`%?oXw z2J!Nv?RqarmMpiUbWn-CWwMA-@L?))3pW(9THe=Tue_x_`@ew6$F39LY*As~b` z(VIXK=3j2~pYR_9@`XL|U(^bWWK_}+@J@T48$Uucw6$Adf>+GP9?O(2M3z6rwot|a z_ZJj1p0s|k2@xNG$DV70a{y{|6h~kHVR%>Tdmp}|lan0u#{jkp%)ToWq;cYchMbo* zID?{}@Rf&~t9EY;y^4y;l))Ut(|EV7c-gE;0D|Cm{t_<6I?GL zk7qegJ;(t&n5`<&pd%#wBZ2iuGxS6?0YeZG8%zGCQ+(_8aOtyM>V7^^onO<8HqAn* z@?#r>rzpeGm1_$PljyZVYYH7Hy;hTqd{l2PG6t+O23QzI*iSI}FwwM6=E&3`k*!)o zL2l+_k8eMPpwjLs82>np|Dgo|J$fn(32uXIzjJFq_QV>}k%OL_ooy1{4*DLGqgYVy zauJO2)}R01?4|fj8!y)z4gGOmDJJj+L`gFwLC+vx>3os!5v@Ej_*+bfYHi5*;>;%> z8O>6ZlanjOvVWcg2vC{Rc-^AToP;JNX|%kD71V};xDw}c%442Z%Zh6IS0Z3PPDE+z z#^2^hk!W`;oj>i1o72}1H}_Kp3&u4`DDG$f>goapH;{*`adtjgn~cxC@9%7D@E4CS zcVFW(rMsqk3hML|wGbdx*c?H=2y2CYNn*PZ$Ydx>@BPMKo4&Irg>ZX^`H|@xT+%Be zHs^JA@I08P zf}+UVhs4vYz8xHQkbrLN9-+jnKyHqrdRr(@L80H$jyEFc_xw3SsrFpG0|U$oqL9JE zzBtC6uhmgjjv1pT$7uDVU;j#PwA;zx_hq zb#)LF^UjlCQ?v5KJnpbN%(lB!!=8!{<_r3VxdC0~tczXpy1O&qq6L(Js_hze&X|+vAzMeVSOVJLW`{Le3wlcPAdqy?oU#7xU9*l2X|DytWy@1YI|@EJ!`Dq|kdcoN z)uE4%QI~+liso>&)Z$Av+f=!I$e%Unu${zSSCGKw!tf=%^i9Z}p6US&Ub(X?&-zdi zRE6Z_j*)lHJ^Z5*oymQCd)Dk?U+2L&((zkmjs~$r)6~?o($D-xNE<^n<8^y{HK`1DJeQ4m zcAcmY@rk{e3czj90;5s=)qfzPu2?-wE=)0IU=Nfq&&|wC5mcg50JYwgsPgl{M)u4a zY_L+K?8%)8#^zoPLSy7qHCbYHv^ZG7evsT`FG@@KQ4Z0X?@#8Ytva*wW2de>r%*0F zVq**ok99q|~ox%yjz)x`}_N5=K&#P)YNV=&3kly=ee%CQ!?UtK+3Q4J~VS) zjOV9r+}ED&l)qFUG!4i=ulc&La-X1*1nS%=OXshvh^Wmb>|LQT*`wJ>Jr$@hNZzGCgi@W=&RdQML zkp1}GmV$I5@X`T2%$WZ1@Np{?{@GC>G8;=pGRE1#7%EXmL486-sygp&EKW#liSG?( z&NM86**DpYBm99gVfK5=drG}0Q9anX>CvWsZ#8lQ%88Y(pT`|{(zE;JEesYG`@J;X zP`?bE8kX7Z?+dJtrys5(Un3vy4xAZ^RdXmh)Q49t)Z(?arnl{~shXd9QlDa8-oalq zdmF`eoEk69*M0_DTy+zfZFH{#icRI`_jOmC+jIT~1x>rX(djwm)*Px%R~*~DZrzTH zavYdamnl9%A|h0{mqe8=7Z!CTPXGiucrZy(h<&We==gde+n1*gSjxMRI2!Qk64bLl z?p@0|a>X+G7Xrol$z_yX>@d&A6O#GZU8cth)f5#cvU#^%79d#u0E1jme=V9iB<-a} z;{Y@(Ag0F79{YTf>aj!vmq4E((Lld9bH!pv`Zq)1vS)df)8jIydL2z?A&JP_LpN?;iRfN7J%+pCk`zkid>K3hBkt7>neAQ{wUV$75#tG_+& z!Q*ruosfy5QFp$-brL-6V_tY9r`R!l3rUxJXikLT=rC_Ubv;{+jradZthtSfF-%$L z!(|(>p{~K@aM78_-u%T5GWtCzNCL!(vO;ez4zMXPL6PoNn<}xUwz@jwrZZ+?0|Nt2 znzimoN()`xDzu2LU#QbLH&&rYi#}}PjbH8ESAQ2CxMO@QQpJtl!l_^R>WF*u^twKY zg1H^1$-63Y37&^@oY3sesRBSFTM7$#AlB&2Lu|TYQ7M@$=fx37NJyL=9SfCc^W#j* zrUCm6z=YqHktz-B>l5#))Ef45OJz3A7I(XdV1h$HNtss{%&SJ2Gy8edz4 zKD>Z8d`dK4$9QH1(`b(vIL|Cqz$pW$_A#0Y3kwGarFH*~NUuhPBU%P;4^|;DUQYFD zEF{CMSZS#JrlkCGe}C07k)(bXu1Im?__Nt>G@Mqkbi7SD4D94#}@{1%s{SOGnM zUR5!%spveauXJvpD%K7HEVZZ4&xq(R~QSy1L8E_ka7WvGq}S<6AgCgo`DP&uYlx zu^K=Zond1spcQHD)xsy!tJXI^p?0)h$wkJwTq+|>Z4#va78(nM* zK!@dBLOlEk*5RK+~n zjyK(!iR=!Dh=}e^3!jvy>RdwHZ;ymb=!l60sL+^yDfU4;m4^$g8ou5p1T?s0>oqTl zG3XS`i${=O$rLpH()dQ5ix2@qsGi@16KAKP&(j4z$|9RgO#m9BiHT^e(4MukC`^ba z%{A)&LjR3shCr$m^^I=p`QDOSJTxafy1oHL9GmVm-Jp5P4+KCl7N`Z5@pOfyWrS;r zJGENn;dq{E;G*XKrn0x?Z%2PZx=L^kr%iRYp&=+NUE^Oi0PQ0fAc(o z)(4tdt{x=C`Wj^XVMa&kf5gP|k;{^U>X1Nqy(fi06aOotjvEfn`@$j(gUJMtNG{8pFl`fyWcsR z(9zIHQbfuS*;H|`9;r|wP4Asa4M#SZ-P8a>{fAgVZLRD5?UhMc9G2_hBJalU15AtG z5b1_f%GnHWDSsujB9Gy}nN-(E3D+45vvMKEjAS8roPhqu@I^eM>3FfeSn4Vm$g8l> zcg%+UI7Kn%qvKxCNH9m4`8^)p-Q6ta_*S)7ql>8x#Rk1fLP3Byy43jk;76K7R#POl z4GNrq`A2b4Q5}wjuO13A zCn81HHaCS4Y*-S;aunfTlv1*VhK2@S@xsBwqrwSgD8Cw?)~-IGv6G8@GQ^JuED2;6X)hZZ3em8_P~#eFV#$A`&!|@dCEV&FP3#Rw%d& zwWREUdG18fHY4*L4o!_RY}QlsrhBQAV^_FM?qQL z-4#brz@626N zO4k%RRasy>Zi3E(JCi-`uMW4B_fO*kD;9`xy?mvKP>+XmdvkE-Q{03uXfi5_OOC&$ zOT^yKWO<_{&5tJL6c>N2*%XoGo;LUu|JQ5-uNPwsnN-sI6^d;Y?KK5TTw+Gm^ALy$ zgMf;7&pM4n{K%_^-Xbrz>$ia=&tX zUiu0kaOD=_(P=?`yIjmU)Y$C^2JOyPF|e`44dre>`+d%CFh*NfSy@?HI@;FeGjt(u zFAm7MxRM`^>)9N7!MYI=5^4`twu1(FSJ7R178a$}B;HiY#ok3;mp3=i#g?)}Atzle zq6O<3?xTw>Gedb$=DTU7iPxvUCmmddqv8`}?K!go>HEpa$#w99B02XKF&*l2Rnez8 za)BVsKE7mTA3DF=+(}ESufv4Ow9N&jO+>OcXX>Kk8S)OkH9X*)_gk6#t*r$|W8Gfd zpu(P{Lago#jSTuUWWLIgjh502tE&eT0g5_Je>k=w@qd%SQ$(~EP-A^mF!(zOsO~{( z8u_C2VM7^2)ODLT%ET&^?M)xJxaJv@csRF=NBKjkVBu5m61fQoc-5Wr(%8*wrmg>a z?xBjZMD&DW1--xF&0yxs?8BR@+0rtcX!a_fxOF>iEUfo>af)!JBqzt6+&|iRR_Z}H zwq;>oYM?kG+Tt6em+(?DnF|*mUkdV?r-5=`qh7=N&6gTaEYT86L{rjQ^nGS zp;-Q1#1B0O^HunJ3FvP6@||?S9uCvw+sRuO=+r9wK`5xGeSkXf%fWoT+f11deFfv5 z9050VKv2+{jb@;{h)CBhCg5VWPMS7bV#ds%@P$p|9o1ToT=1=X$at#9QHsZt<6iD| zrwo+!)L(VZi@u4a6ItN}$X(&-;l%T$&w6XFtxl@f0!`d|8}s468`*6j`QmE`mucbT z<*QW<+vX2;f3u%IuG}axpJ6ij03t%`%gtLvqM1#F{{9!O0aeeZZOl(BgAhHUv92}_ z@vwtYbs^j_Tn78>;dLs5aJkk8K_O<$u~<`yhXX;QJA;>c^Y{ohv)eWSEHPV;BDL z?n(C!w!9MyiZICHqi(LhT!uF}1X5^s^i*pCv6LK-9Wgk-l=TPJmrfm5Wv6ea&(umB z=L?s#aiNSz|I)v$V|Kr$X0$nJN}N-y5GvmjLFjiq%!~ifQUIH`$f8bjvlgG`9egxH zOQ1LWSLE*I!tjM*WF)=29f5a5RFsP92#Kx&843=fZ#B|8K7*ZYRR!Wohl6=QLO}0s zx@^UAQtxMg;)AcSrK$|d8JJ))nofoJlqu(nrpe@t1;(90sk2_VxhU=MmL6=*r`a-F z%xa_s(E(B_i!jqot0DA?Tw7-bvaor!hf6Wp4W%Ws zNlF#z2=`+xtl`uB24Z0a;;6u!^#Zgw3?OQ_zt9ijsmNTf@9683&zwS0rfF1YVR+OrapXuPDWK zqqy+$XK0}k6_?u$>ITmgcC zDM7ut0%cf+iR|lPQX34xtlqz-0@f$e5322U0P|VPW&rEgCM%#KkV01q>c8bvqT;KO zF0gyuQ(0GrH=?6vcZsx;2x)_pk%>TmIK*O9Qi-4q1K#sgRFQE)kX=;jln3;rkPcln zhtlHW?y6Q~gVK~&@zd)r&dxF+{(vr0AqrqDOGl~_5rlveqtCUMj%A)kNPqq;#AdmG zjJBX!V;^rGOVPMMqmKo@=|MCTV2(3n9YEk-eE;hu({4@uJ_UEB z+1%CDHJ#d*-V=?3sY9U|btqrUx{8DJI(ur;p9KznBE58lC8WdF&gRHS5MIT~aUXb{ zRH#Zz%C!*Rhx8Rkv(dl$WliBM`T_i%{CRgbE%2FM(PUts)Q568C1tu-(ppV#;e5b9 zOQy<$O9gT$%gWoz1D}qkp~cfX$FeFj zxf19?d^o-dH|oDm@$519n-R|QK2xH^nKV)q)FZ&O%ScO0tEpj6)nPz`|K?eAHRVt2 zy~%P^GxZjIT9ChzBOv>xpiH=N@;NIRHm=sVe(yDTx`VP zfyWCdy7CuLD{qV|dif4fYI(DF@;OjYbWf0{W^5Tv*6&TVmrN+N&A#EFftHbEqY3AG z8Ip;jCe4@BX}_(ltQM7Wz+j4sX@zV1j96_}JEwO=Iq(kb1*sTi-qV&97iFJVbY>=L zh8!Q;z{A70uN#`0Qbvjf*>GfS$6MBCVZDZh&BA(-i4GL;0C!F%)ihzw^6Q(K`4!T0 zQ|h&@l6~BnTFShQn^)Z-+O1sE9%~fK^Wq;}A$!5_rV1nkr42`0A5h@jTwU?F-Ng~A zD=Vqgt4mEM@@Wq!-@w?cnX?6@-%0S~m>21<-S#IMqkp8;sEr8?co+V)74f;yLOBfnBd&lZ~wyYpVJ;a1? zZ2aB>|C7LZ6cg|V^+ZuK*b{XT;n=G!+v4x%;P>Ft4k0XX-hfmg#8$HfEr&ge8&RuL}^ouaDTD?N92 zEJv0BYV{R@uyxh(DDHGPR7i;!%v-sMdJ77Daus5{_D3sg@V6whU!Lu-slSm zNIJtY%0!S7NwR6Y2JW~ZoFpSNeo>TF0}|>md6k)Ze2-gtA{*eE0_+~`rcQrl$X8z4 zmQ6H|Ew`kJVU~1pIG<)F+&}LFC{s~$*6x?iSlK#enk(FWXVtLOZYu| z_6!943aaLEj?T{X`2hp;^D%iTY>bQ7ZG zCORxJ7>ETG|IOsj$~uODJ7)*j;pu!2&2@AHD(m!aDwWI!Zvx&lqg{I=+T9#^21mtu z1n<-BW{OYv3cr#ZL45_u)Jzo&%4YipAJTygvwfwX4rb#=y2p~m)?h-o-4XA3X0X&l zL1>#s*HYQwkjyjzVX5!Qruxpj{qMO~$?or67suIF;UForT*;R0;RxtmVX5|X3zw$~ zoo1;YfQkP2tpAg1R!m3?exS+L1%Khvfp5W|+S(lc_3jrwGao+QAUTEcZDU1^Hg`cCb0y3T^>pFa~(`4DUGg2ghMO zaD!qd_l$Y24zBN4q>`&;_j*J|k84jyd%xQ-PKYiqubT~26c&|a+O|XF&w$XfT&QpF zf8OXR2Nv_Z+zG=?A56OcwC{ax7@w*q zyxKg*JaPB?sW>UT>$BTEeCz7t_2B1|(~vmU0$auo@f-q_P6-@NVOZp3WQR;M9l)d0 z`wcuTTQ)>dKQV??*P{TH(nUB~i1oQDJ0)%&r4Exs;)PT^Ev*IitU|XTz@euT?u;KK zOC)_9JKwDD;<{Dr0d#}g*`Iw8K^Qw-5Li%kCJTBXGzEb$4nHVhW)vnP{TR#E{}-p& z@x2jwsDVnNw%Y!5zf$G$1NV;Eqd25w9>mx@otu`dfCSEZ3&`ia#q_t3j4y_w*Rp*0 z2vRLIew(Z$ahR+g8PHvA0VDrk8vog!=sG)<)Rtv`M4(8if|F!940&L>yt6O5}HId4+a|63}OAfG5twZ zTX(p#}xFvjm`-@O_{4-f#Z6^s!`o>Gxc+fUTMC;|ckZw~jMtY@Q0 zrlvc^LK~Da;85Ovr|hIGVr>1EakBNx?Vh3-#vCp#F3{t$LaS^y-n|6NhXQOVlDyjY z?99w{)%dD@ZZ5%xz{Day!JF%|K0QNKe9(vm=Z4RBr(OU+x}ektr{0gZC@6n>B@A9T z>`q#*cJ`Z|Y60(^32XFTS*cL5jmnTFAm&lmg?P3v${$!yWOv&zh^dNStA|K+*3`Q0 z?}xHGcq=|wW#{CWj=hi1bBQAb=nc>IU{E-Bce45{$j`45(95{-#Z%Zjcb;B%!b2M6 z>ZUpOz27hmOb^Y?^xdbqZ6>rhBp^<>Q~}{`^aA?v!e)nuc>Y)$JFqTLj*OMHUV17- z%fk)yyKpGA)Z(vqjaeSoeG>9500Yx2qCj31D-sYPk465N>}})<3=i}>XR~tA38cz$ zw(pN^{Cs_V#U38^QXBGzrdQPNXn^aQd!BCwsFC`Okcw;dM-?E3@D9%5f@v@LK~_F0 zK1o^HcxiE&;;qfs^7h^=Ex`}(xFlX?303m#H;x~bM(Yih+;Y8(+&xjdj+N>zVSk)qGua^$m`k6HWSZvcZ3CT17R z4{vV_;>SoTo#i$9;PWrC_UB`jwV~8W^VRwNL3L4meDl0UC}sHd?gA3gel0uRk|1egNdhA@NlAZ=1g0O>D+%&sOhzirAByX1lAf6QZ>=tTDz6E2K{qtI@+upptx1#lW zrw`@cb|>w)JD`ieZ!8xrB3^c!d-=MDc_F@H@$SA4F8{uYd)p$ZzoKSj>RKX(y-8{U zegSL%Q0I3I2qXC{=!`P}+PlJWn#M2j?YpTd+}$1?lJzL4N)3`~t*MlHg4U$afVTP$ z7ju@^N87CK0z(b+4i8^jrXOz3ThOF}G2b&kJ`v)WPE*nS2F-V1$Y7o$j1U)Pc9t(^ z%;?khwd6moDf08zZrO*ZAI@Ne<`iQKHBQ;aeQ=?XD|exEf!htEF(i-No?0S z?JVU0jDV=4cP5+#9y`q@Ly`N43J6+{)p)PjINi~_Z2YRfuD~TJFGHjfRAd7N=sCgL z(ClX8X)1?CprWt6j$eZ|DT;F$=~E>DXS$JJ?tPlyQTpFO>V0Rm7oJ;ih=}tgq?~NQ zXejk5s0ZP0%zY5R&rD>dh38VU{!HeUtv+;f)Mn$dpnY{WTss8|ethY<)vamQku&?AbE1 z8`x{lQt&{b>(#qEWX3-)*0W?p0G1L z)OY-PtK07WiKCMa^Epo*4yz5Q5aEsG1c>mnlbNs|_dC!*uLlxD0l$Pkfib(*c-%7v z#kIDC*}AppJylpZ2S_Tw?8#(BV13CMYZrZ7Lfh)N@E03OXv<7c2JXyS277TiYgbjo zIM7*%FY%0@AP0k9yp~cED06e4@@hf?N*qF_YWBI=ScrHi>@5gwoOSPuPDMY{JfdzO zjlH$Ys29-`4Gq@k#n|RtcV|0j{;KsDob|P9E<*wgbzw|FxJPTW2VXVc8JRcwKzZ`K zuXNhlHDnN!_40q`ES6h!d1!jyH!^X! znw`K&iDC8F4gxV~|8-Fr0F@C~I>lwv8|RY7Ke}`mvT*R8(1$q+3IEumn@jhu?UmZc z$Htzq^sA^2IwMCdM2aqR z#i;cSN}O9EDz&@4Ts%oK6iA>}=P!cKKVECU_{_%_820#eqe%p`?uxBuacl?4QJNaP z`I%(heciqNlwUe!@8k5Zb|SJG+R0y6>OFQ1!l2vPT=)K7Jh-EGs^#wQ?FMsc$xF$^ zb`OpTqP@$8tN$LS-jW5m{f2?yLSKqhm`bcQJ6re;4I0+}Hf#SAbcJCt>`C7D2J+%d zmRbFe3YWB+S}%-(oE!#~{9|W5U-dN>cztN)+nurXwv;jvj=k8l2u>|1sJy=D<`)#C zKR|_zoGce}QfBU?6E%zVR=74}sial>f*V=DMEZ!FQqbchC}ExYwS$Q)QWC zfeuu6qY*P6Tze8PF_`~>3u`^6d%_MtJYgIELmNT@`46K}jOQ!1e>Wl0b?vdTV#|m) zwFeV72vXReX^|hh(KuV%u9%I!uyB1he$!T9`R(za5CbS%f~Q`_cupu)&ODu=1LtSd zPbC!_&1&0y`ORGk=g*uXk)(6RfHNVt;45U2J1Z+|1-(`(C?YaiKN?L76u}sbR>@iWE~v`)WGkMk%JOndSW`CZib4J=)RbnosDC*v;c8=fRO->OMqu9DuJBn ziTbcV_Jt?a=Ra96;t(J}9xr6i<|p3t#uq78|Kiur0Nm&C&i}I}G1^P{lC#sQt$(yD z65NrL4A~A14`iq_I#@WO2sS>uwuJzleK9AoC4Wv$mtxzTm`~`8h zVh!-f*dkCV7Gq|n=nd|+OzM&HSnN4JKF1fkc*cw^&v_YV8-|9N26)x*3!-nHke6}2 z=a_5U4$FkSCLAy4u{H;Vuywf#-cAN z#6e0BWqO&sSM0}@=EOIAI5iJAse|`I$TzxD(71h~ANLI-YR^}(XoB-%ZZ<=Dwg&pu zrxW6REy>ODGjkW<1Hg9Ld>$*z3q0V44d!zE^keL zvU*L?k2I_MhQ(|8l;Rc>^NI+!rP?i--{jAh>2wZKqa%eJn=xD7T->pp$6Z(`b|{1g zZ$z~1$IZ2T)@v#=8Nc(YgSFxFTki@!?EhS^d}P7?Hd=SWV79#neLUEla=9>1HCWA| zA>z%Sv7w#VV|GpG3jfLQKP297%-fgf-PIdht^Gw+zus(o$=j<`yRlz!ce?zPD>`{h zg})Z}>h5I50~f+MpFGxpoAvxYs8Y zF(QjuUwigeKL6XvFmZYwr=$Kqrvo>jiw6t}U^Ccmu=)+=1tBNjji{bR4z z2L!*i5^Lj!^FNvneR_MU^|T7S%0kHm;cSZBTezpeho+Ov zq4B%l)85}*wh0Qezn7a+3o5i}AEn48+L>PnIff%^yFnf zHdl4z^@6{#blX$PrvmY1SsV>zI(0(Cbxwy3)tNUes z-;Q?|^SZJl9N(HgUwM_{ z6^pjdI>exQ@=((jJY(PF!v{*c#TETkoHbl&r|xkyojMIZ{Vr&_hs^c21X(lOeN8E9 zU>DP?cGo=KS0ZVgW397~3PHQ5)ZV;9a`V94a%YOTcW%mU_40n6P<4PtyH7+#TR=aq zUt<8K_9n;*AnM5_B#!qVFp01}r9_UMB(HTOibNt}F7(Vzk7qozE}5HQTq#}2ZJBm{ z(`AIk8UJ`A{q?74yxU3$sfCwU!W3}Vi`Ur@7Ob86*UjbTsW}HGE~|b$H~Gn@csz#K zcEY#}qat~=(=;Vj%?@X-a_*QzOiP)=f2R4|fC0RM3hUD0D3q?T)wCTW`4#9`WZoS5 zWF$(JirY<(it33%0r?#Jc+=EI>+^#H1;=$h8SU;!+cZ2*s9P7uNX^OwnF!abue>Ao zxWut`SW7=T^g`UJjAytcjwLyJ`vUGrdD+ZjbceSpYjA)ARMTSRA=txB)`~S{ph&r2NWps<*4HHA!k{0d31y z;#^lO%w;K$bU6w-lz0T*ZELnye7+kG>xHGXJHQj=eK2LHH}cmldRUMC3({`$OfDIK6zURB z?yKBarjNu$l2Z)+1{2L5#_SG-Y`hz6Az3@kiyv|*OZ0uc&$q7*V!Ex!@xLI7d2j1+ z!&x7g3az0saT%y*?PSyN?kt#HHs6mGEZ|7w*^C9Y85F@36&mYoupVCMot>mgUXA(S zdNaD&NYpt}^9hy-vwvM49bzwsy19nYpc>^fYRT`Ulzqo1Ebv8eD1fMC#8qQ>f=)^1 zL3-*B-WN9o5=mpV3@bvFlt%=4{XcJrqr-uf8WH7m?288@iApc4Vphk?CIio)Z~wvUFA& z+l!fcAGkB;%?z0|aag06H+-m3ga_MnxOEwe%CGg$yaS!>L%Pa zLy&q*Z+Ed=tmmpjjsqX;*A~X1t^wC8cj3kEeuA6T#?miE$haG&)P;Wyzl`TE=C`+uF!8%FX>Ez3psQc0}vje@j#T?3p^Ju8j+b zDDp6yMOBoJk|&$sdZ3|5d=4GcCyezYo$q{eQW2T-&Vt)`NOfPWU8+wPR}*$(FHb-= z@7%*~RfIUp!fSPkimjGxZN7SBIlcvOW7P zqP$^dzdtXFu1HY4xiBWrHt@DXNQb2xO2sd_*jkG%@enGO#n;RlWtIyoJWI*dr%nEG zviGU?#IgmS*)zMh)GA{^w?hZE#`SDKQ9tF)VC9;h>F?i| zTgl_rWUlcl+g~-sg8O!{IOX=U0p^WA8~;3<%B8Y!f3csx&`08d`T(R7-?#zc@d86t z#*X2tnFM4Se0;9MFVc|>Eb-TD<4BKkF9bvgl?8-t17-SeIC@9!+@1)gHP#$lhpgcN# z_80OKTKO1n6cid1IqBD$o@u)aZia+g)M$qXyBrE0!S8fCND~&O14GiM`(QtXPAkQS zz~TqHO&UW(1dkt!i#``c8HsA`WiM5WV5$=2EbQ33gXA9{! zVF;S_=XivY@HkX-TuWFD4)?H`ZrkfFD{zE{y~7U)31Q+qS-t7jxijW(Mc^(F8-)(C zj=s9IQLu6gimf-era|m{$5QRH0woERJVM#Vr`uN*T>nl9;x=7aAOrvq4w95j7aZKI zjH3K2BULgB*vM~OKj(}mF$%#Ws_F6fS?|fIsUYO|?3&@Fzf$@XrBK7uL=@$7YRt{u zw#lO^Y3ba9YEyWasih$RAVVNpzm7JpE^>PPQdjZx?sPI7)4NG9=4JM7y-t*57r)%< zYb!~ATa6H#Z!k%RQJ>3!vFeD8rMBi<_70?hcYcS7j?3wV9!OZ?#41rh zl3vbJU}KZjZ4NgVq-P81oH_(}D7`%jo)P2?32~;X_*m4%rI@qzC^U{Zdwj}DTV-=` zKvUxjQuoGV1FcJ?l!E5p1N}Av_qU3FAr)U(Gj|FwctKfdYG>erP1XKMQ;{MV;e904 zi+<)6ihFTe0Zc6atNl$oQW&gYcVr-d}%**>C*3M1s#-UxeoI4;k z*(j1~-N%ciQ|7Fn`9T{OTToXN6cZ{;RWu86 zy)k(?=dgbMBOyu-wgLy^X_Ey$`TLK2Y3}@AEcBLx2{i22@@_Y|@}(}GTCHE;`8OmJ z&IQa;MnWcf4|Dv)M`ge1&4_;U0}ZqT-_mMP#!N#`y) zjMQGX6z?_@uHXiH%bzE(1+f8V)f=UCT7n3ogW-hQ9Pmm_p7+`>{B7Qb$F)Dli!~eD zVw8B+FXB)gs9zim6JzhU=U~@QvN*wasMxolu~!Kl*B?3yXT#F&A}}T^;vWAEQ?o#7 z>jl(0l0M^j9mv^`LT9O$MAT`L8f(%6zFrEP79&Raz4Lkn$4z4!BExDv&spM!WlKMWuP4F#8<%y;F|#ue3Fc zzr6sC^{lG5G<57Ih_EGM{UBG&kiyYTXRva$^p z|GA6iOuZ=QdE0#{U3#&b;RW3{3ylMImxPuXUjtbGQTFjUI!b02ty8cF3+H}cbd-8| zJPR3O*eziy39PajxB{5yf9E7&3>@pyH84tnsk!NQsCa;N)T=51zlyjjur9E?XywZ=w#7ID@M zVFVZRn_vy{bt~s)W*zI)NqtY#T>a`2b9F#_(Sq@qsof^2le(aG9rZCAp34eV z(?IJ&#VqZ#2A%WL7$kYDpAIXq(C4ccQB0sT+Wy@TZ^G`8MY|9 zC0nW=5XExi4rXHwgNn^*JJYz{>|=RX(gu9EvOKAvr?E%-T!HN}pB1&T?S@$IYma9Q zn%yC3Mm2bjxGg@giS{4d79?-IGBz{T+oN|CDT7j`#Kh4O=7}3_oq0R8`#0Z|fB70(lo)f= z<=w?|`k-J;LYf6MS$vu2J`wdsimi&f$Rbl#k7v*9Sq`~e(>pJhqan23L*l(ua-xAx zzsUp+7NyfeHdhJmr$Q$vBEIhZYz+)f9kHIQu)ovV{88W`nb7hQ++kYu;it9Z?c+uH z4H01dfvJ|zg>?Pgq~Hv|)Y4{UC&!%3_j*dX_Paj!3@i*I58C?aC^9!z4^CSaf#Ha` zygAeC9!CdRskBXgBhR6xv5aoQKXK!t*;@|`3CWqW7&!m~dkIElC1L&uNJSogY+U@* zWQK^%DlP+xul=R?6E_l_L7U$z?O)V-m!mmu8)scKRSKTYX2f~^0=@)4Z8*R#r)hi- zmUv>9GO{$Ht`pkMMNv*+@+%j%?}2L2N$+4zN7Vk-JL6bNn4aB2E{SG{U?Vg%glAPs zVgq?-eKmgRm#D&?cv_~IJ3ybzk79x7@|L+9quKei-g|Y>kPL6VV8c^GjF3-afqOD6 z2J>ZtH~UA^}+Y7rhR#|6_TS!4uqEmel4ht$`N0(t-ivzKiGmVJa~xPu0F4NuII4Du7P z>JKZ%e{^+?4A>J5bzshd(=suqEIptvB8=#Hto%+O5MDD`xP}-*LE(Sa|N2dOw^otW zluM}*tV$BUYNkr%wvICezIs6$9kRlC+j=oFJYnBIK*|fc=brbF zzn_Jp^PZ3pEcdaVgqM4?ud^mLxwHD9IENj}1e98kmy5MkN}a$dBWM6Cy2|r4eYO6} zW#6*r*-TLt?AI(AKCEe)a7jxavtYcmx`e@y&r41COK%_vM1$1IkUIJLohqPSVbd+lED74OR>EaBF zt@sS0jy7xD=C7Ur0HZrA+zIf|jCtX-AMt`lRtavZ?_gr*;9W}LhJ zlkU|KY3cbc4S$0XSi?K#?)4I;4XEZ`<$7WbzppdsZdAXl>1%9HFClUIRsy3Urzd+w zvFaUvsQlB?UQWe^@Ka;d3_KfaekXnB^@(FnYYk)fQ1QWj<^Cbg->fBlI*3(;+PHPY z;@vAfRY}f1$B8gZbX3k&SedRLcmDQ)1j?(khcp-Sd!Z2U83MVo z;@F%cj(^_IqTjp9c2dP^H8f<+?(X8I+Bm3lRPgO!L+F=WI@V<)vlJJ}V#XhTF)vrF zgkdF8oroyMRrc=im%rCXn$L1Pq71XR6T*!DN%=6Uyh#j|NXf_rPISQ4YAu{np<@DEI|=}( z^_<^(rQg!*_#u0X30!2o{o2=fhyY5Ed;pCKYl1f;oTWo08&(>ul8xm#3XT+a$+p(u znOFB1B=Bd>C-9Bxm6lspFEP}KuUn6WO_Ti&{Mia=@cXY%WwpSQN+&w^&!8^n*2`lr zH9#N{1W$qIg3Ys6Ny~k4M<3<}=`n1|5fdrI!I4x(tC+sf2ZyFl8CQaD^wYcB(ztH9aXg{@+pHK&~7Omn`?cPWZ<6vT@?+*@*i-%=_|N95g zpi3C&$eQR4(pjEQ)m(ff-J{zY4@Q{#L|vU}{tl4SYs^lIXb_yb@W!D6^G47EMz=-W z>@eK`0Oa{c)Qj5PGK|US@b&}xs0OM$(p+-K3G-70=O$Rna?yUfqE;O(wq`GTwMGXT zrnsxi(c50skS(ZGw^ETeS#{k)zEo`$Enl1uWVG&S{bIyQO1!0hwRiG3#zLrQx+WNS zUw^BQffwss5;&JzQI<95emx##BP}hgGO2Rq6rgJO_U&$t-{J{Q(1+a4;BHpY8nEi! z?fWnSF@3?#enBP?cJ}MLAusS9)=L1OG2bDF!j0vle(z_wg1ysww+T7`K6K^?N_~$N zrl5k;+~OXTyd(<%Bnpgexbv2GRE)3$Ar+lBzG0zwrRwsd`cvVDGT`CkF~*8>TqWWU ze!gDkaP%>p?*w?<8!uZJYYQ)>`bYqCjStD_NyYiV_0b0`yx5s(egz*OKgHS!xP%r@ z?evJ+>cEfwa`XlrMb+@Elm-2;toQB^)>5axo_?dh=PyR0CbXb+czjXb3F0dn*{k6C z75p-1C)v(w_&)k`d_4P!N$-yS+0x;q5@~;imAKKAezic5;aJ9g{zfu}BnA0W=9;Fd zML}=4;W66Sz8hVdg0W*DYxQU4Jzjko0zH`t9gG*Set7 z{Y_aW#;7F(<&T&&MT`P8-v1g|2y_BlOW?NLkO zbcDxYqU>XN0x;H--l9;C$%LadO=ZXyz3#0KB@o9r7sZvmU!<>>N@RA9<;S`{GS0J8E1DN1_BS;xKFVfejWns6bctf)@? zZpouOP8pF;i3VGAJ(v0Tb9<2?FrbI1lyP^O7wYra4F{Qg>l%$TURro*)R*0L6XSfe zEbMshG`?L=R<%99_iaq{!l=ou(H2v~h@H}&A3MEmku}tDyY6f@@63GmN6|^h3;M@% zAYON)vzxr=$e<6^Z$Mm9&H`v2a0^e(7aLLjICp7y$6v zd%E`);mfnRUyhfAnsNX~)ku3g#s8@Od|=bGtl;N!+1{UZQ>73=j*9Jklg9cS=ST5h zJ1h4*s5A7$TT3EiS&~D9`mU8kqVBFba&DSs1=ZQWH#X>JyebMsgZ;NNb^anB5iM}tW zB@E1bWVPPbD)xZv1Y3oIGAIzRb1H=L*`~cR@vfc^<);pbTegXdAoK}<=aVRbYbkVj z3a8MnmPr!{))2K;u`)eiGAK0I{~nzAIau^(O)X0K|8Z>Rz!r_>TMDn$VxtS(R`wCf zgcKVwn9ZQ(yXgy@f|e3=w*H{+-$OcAvIA{wY(zvvWE4EbsFM5H16*U@X##%WFUZTk z2x2A!WoMg>h`h;CWg;YIms3_A>g{FLZ(IJv#?Ic4%OEIN-?3@Pk=$LP1kHGqsoLzm zCtyE&TATB^X^R}u{^{o2{>v-X?8EJe5>AuWTb3h~pH{Qg7X4r=SXfe;s7HCLP}4`b zCf!i>Mfs(=i3Da13=@pNHp=Gv*@lVw%C$~#rb&xzP1XJQ8X~yH>UGMsO@{ha#O{Dz zI2@KH46a-|-5SFbhm-G+6DPI!lG`m5hUOLNnf3Riwo{Tf5hqcI;5i%RHnl88w8h!}x^Qgu zI@uh_X^RtdT)cI>{9fc>j-l}Ome22fMXQuIE*smH#E{!?h^ex&av*&?gObgRBVBtU zD_d_UHm1>ed{L6NX>WdivchhTE*o+Uhdw?PRjcn2D4X8A8+UtgBIjdoJL3)#hRkem zJY+Ju+^K;2U%f;5&C0_QcXG_bNNEO(^hZaj=n4=8_!}!BjQUP0|8uaqm_b z=;9yMH>6!pgq!}zk%fdMeYC;3G0S<3^2+F|mc^ei`2PUVJVYh%sN$j*;9;TVJ?O{& z1H`Jzj(=PMkz5>95ER@5ZLHDH8;e%P2Ia>#rhrnF$GAm7xnZ`>?;k|`ERxSaa50WX z4&PI-nH->D{WP{)W=(Kw4t8;QqGlB&Q<2elW^IWbh4rvnwfoSf@?ao3%Zs>t!Cn*9 zpVqA8sC{nhB(x_JaafhqtrA?IhPh5gL4jPHiPPY7v5ySUR(HasaEFB^x4qdjTUoXI zRKDHp8MIj&8H6!(lssjC9+ytT1G`h znTWzwdFZf68%lFtbW>U=|0~^-{_S8l*^_ z)G3)U{R2mDr>4R(NrjbMZa6k%Bp5d6TK&l?tSs^dIl-|R85yr=nACm%fd;&^4*$?>*(kmSJbZ=!k=&V9L`$XR62c;gH96FW#`CGR)gB!y$)VIVJ*11Kc zW5ZQ0(Z&m+(<#*v~Weed(Cdc*;#=h$JTX3z5oG-->tXL(L!NwhxpCJey|NVfl8%Cu?YC!3)*ax9o(d6Xh6lYjIvV}owGGUc_Gv&=xotV{Q zZ5&rM{>|=obH`m0ywYjgszL-Yl`ItOz(D-~4hkj@Y54 zTe_so+7N249@fv4(SZ%}cw#rTgw~Y(n@Vxx5En%i(kHTSM zkUIsRvK3Ovb`Aa=KF>mn^YHpm-Q|Ol;@%D;wYfABmsM-mqk191>!ZjQpvF4&f4K6x z0!AxY`AAuEt+x!34C%em(ZaoUSzaV)KKU6|pL)DtfsN3h;k3igOX}3y+1X#S^QuxD zg2EdMC1@9mTaj}Aes33+rE#}@#IUH(MuU*v#~zn;e)1Y;gWu$r=jUDi3>K2Ue?r%WOh-*4TnoV+3jBopNEv{qk<^kHU4~AFD66IeZ}AsbMc2_ET?8 zfDD-aj$b<<8?=8WK!s(OzIQENC&kl0a>lN#paen2m<2*?I4UHpq|@$HTW8$>D?bhk z!V)H{&rjRh z$_hKpq6=#ptyBQu7H-BWhI#qLi7 zEZD;}&`gUT%=XGYF+E+}V}HJ-rDYU2K0ZEKZe(DJYYmIKa=B?Pnis)On63j0;KefC zjeeEL88Tp>KikX@U~umJrQ^YA%;VyQh?m%#kW8vA!p7Tjn|8i9b}d;6_wP121-ZR) z`*p=d!m1?i>+d_lbTBiCV^v^d6mRTs_+e>sE=Y=@J_0L-?9=|ustbgJ-}vHET5vy- zNUCLn%F*33;p*b(1JmD=xmqoG`M#ZiIxkTG&;5wf=ZAJi(oyhy6ltSSQ=IP{y0PX zLCz*a`PH8k)8Nq~qMK&sQ&z`e3-GVm)n2|1!s|r6VYte_4Rme#@$h=B^>hyj7CS7o zBtZJuf2NQ>FXSj(;KK^5%M0vRVqlJ!@3D^c;ElT9h-f%5H03 ziah&!Cl5J7Cv)iiV0-(ZCssSZ(?Y;W-`(eX=f<#9eMNoG>I5H&{uya$X>ZE97W`_| zW8+f$g4iY1yjG2Sx$t^W%h|@@2k}$U}^nHg0WZWi!B=o&rLVlw&`!0O-8;eE(*PqA5+u zqE`;X>sU-%^)N2S@?$=ToIE~B@2yvPf<(ez3pIvrk_9R*WG+>2YrWM67$YT}aKcT; zHubaXm0mt&1?2DG9M=vD3mYLD6whu-xLZlB=cq5@8o&RIHugn1=xT<>{5{-;)bI37 zWsmdcDK69{^i>@bEIIrQ3&y7=SN4h}IG{Txfd34zkBnh;rmhO|ltcKMPk>jDllOWN zL}UvpuJhpPWzT6TwH88s@wmWLU}R44{zQPC|Mb*R)C?H)(VA&}ab9GL+(@fyYD$o` zWR0yLEiLV#&_zM{SubZ~B+kTC$ifa@OcN#43(!|sZL?;G$b#6mH(*38pq&>@PICF0b{*Lw)HUc0pPCYv8|*?Z?ejG9>)y29fOp%Y?+qQL%R4u@c0g zfS8%^zbilb7U(eM;HAF^+pWb`z46r?c57>2QtT8tkp?Z>^6&DDU1*6YSR}(c#4pFd zz?7D#lU6S4iX*Z=b%y7TTKZ+cJ1Upd7SNiT8xfrb^|!g4G&@Yc|c%XBqm?v0LKSZOZL$Z2rx$_hS8_ul5F!xC@Y zAoS?V+5?qMgoa=Naddqxf0_XR%RnB%(6~HY8e+Qcym0buvX9J14Y^BZr{~@6AuxED zY-w1p}#a_alQdoF$2$#h5V@|YzS|Tel5Qm{Dny6-w~eQT+b5aHUr52NAa|Ey>7>X z+1=rF_X6^bL%`jSp<>#PgNX&$4NuZArftp7(|W8VQPuX{$_m$zK=jluTkYzTjo=J{ zhcNpbC|>8z?MzFvV&FiZUTxQyrldkH}pKOe;^stkCs?#X4p z+SV%L9ZZd-S{&)Lx^tVPADCR|FAhZDb_a``TBWS+$NXYmdmPvbdf6vk6mdC!VrlmlD5zWnDk<(N% zyKt$fdvBw?cq%y)mvkx3c_)}{%zGgSXSDdsvj-1^n{n$^Z)T>zJ zW1ieT*h0O(KQ%~$iT)F!fj(@pTQJ%V;O*{wZ?5*(19GrBIbKY+kM(k8^NdU&Gw`+l z>;2#{aMzQ8rcbWt>IK`_e@IDvzqk|&l+%kxd3FXHrDD$tYWX&{slfWGJrJ$Wu#rR9yzw?!_#pQjQnw|oAFixA6FeY z3t&5?lH7rOz|+*mtz@bo#YEiqdjNx#J9C|IKaYEb=aq29FiM(1juNH3QtAi37s?V{ zp5=)n1Y}Ygq5<2RRuJsH_`CE?n2a&$Vo3nsK@EtlkVXlxjqBmG+-PmsCuSyonr#`suFOCYt400wKA5UCwO^z0M*a*es%dTJn znd_3XbAc_vo(dmJdM#%5z9EF*3^FhIu;6eHKXA`wlF6`Htv!3}{v4`k6*_&`4yh#t zS24cCv>Yms)SWG#*d^~xIPtcX?T<1r3Gk+|9X%sevS%>7)^&&Br*-j`Gvcjx85@YU zRe{KoO<%NXw{!6NndE_`M+yv2NmpqZ0g?-+WC2cJQF9h*b2;V zR!An0N9v@Ib(xJ)kSUo0Ah0EzFL$hiOknfg&C_MXrKaCjE855=61Z za_<`eK)becyviFbVCJNww?yVDe5~gU+q)C1}yI*euEmx9G@S8E#i(z>dhCo>Y=e3dqtS*0VHMSgNh=E=m++4z66-0N2Vv$9&kaEn-*imuHQYK+vqBR&&dgvH zWR_N2d}r@JCgncLIoO;O+8>WR6cgY$JPpX_2xJ|QdNh;Y=XoG0DYfm>t-;GGt`!s# zBE*7y%_%uy-7~Tdf}|x5tX6-(!Fv$JM*5ecYZc91XVLopx9*U8VV-N$67C#eBA$5%4SM8OX< z-GO-o_#P%_5G9_n`T~d_1k+T08??C~%6y_%4erdt>~B*Wx1DvgTRO`bf+(iFH_NlX%m^LxK80hzRH5+bbTx}ut< z{MyeO{Yu?7%`*?e_*Jyf%J^anZf`!;0&%OuNpF0l480tEO8#I}#Jy zbzEuf%- zIvW*MXimXJ{7?$JmK17slw4WRPdCFr&i~1W_4_4jp%5ym5v`@NCi%J!$QP~0fYFaO zDrTnghNs;%V#3&u&Be85sDL)pQ-Ya}S6UPl6g<4V3SE8eZ3ho#pZI2rL_<1_`ks!N z!ZWaMPSq!zMj!(+s1^T2MT+Fb3I5qEsONkspvI;H00wc$si851WVpvZB86>`%W_Rx zB4U3buxDC#{*GEB1(j?XV^YPE-(sD(f_F*+|A`FUyNIw5xgq?c1h)u9Z2ooDR+$Dj zTju-uW#`6f**`F)DoIc4fbwWMe|Qu#tQ9|n8Gk)YItA~JYW^GML(5+5{CUUn_18#VH~$iY~3@`o2{r8Cw1`S#*GiOdnE;2+3Mf7$_akpxS$Y+IaEOpR!ya06b&^UClb?q4dC5XN z+|Z%r*xk_GeY<=5M?;5e7R~MaJ>i8)Y710E=j4I)e95uCzP>jDwkqHwg87kjn&=N( zO`qFM8AY$4&RCl!`|3lDL|x9(>5@YJnQD;e;)@-(>0|kxYBts(C7-jY&xLOBI`avJ zZR^F>C&n4AP6I^|Cl(#%Nvn=u8)ORRm`40WOI4I z=>z(Zuz>on=xn33v>AVY`P*eihX)?n^yN8?Oi-*(eeZ!#8rWh%x4L(vnlDouMCdl%z@$bym1ica`j$yU5} z-l_8W>eqjS#(DhG98QIh4=5_JOAW1l*Y6QS{L3T|S; zi9~R27GkozKK?XI4m-j3x426F_IvN`9SUiaffOhE6W@FePP_KRmvy`3{>%*cRp{Qy znQCE;_j&Q;zlo1kn2%CCKia7+@EJMIRuJzatp_$ZAAHqDO`&|qMVG(#$(N;eSW!WD zuyKp(<)Jv0S7o0zc|>E!s&>SsmrQThjEVIGC!^B1mf`vL4x6gK7!#VL>R9(T1P0}8 zZPNUYeqUXD9i6z!FE`i^UYz0W?(BUd1!Aq3$xNL4Yw2fwz2G^k3p&|@@(R5SWt7+% z*u{jKTjYz~;{$ANoF|$B2*QTknSZQ;&V0>vPP)p+f&?_~6V!RU&%fDAfbK5*I@-sm z2cC+Mz7w!`dL_hy1VoucuEi`^$(kaR-%VJ~7caG-@sDY#mo1t1%};uHI6^oZKYLPY z2d>l1LI`7@8pksYluW1@H#9dBG#f(+$SfmZH;Du?5MuGhvNN($anI zi@2zZS6a?*SjqIg8Kv!v>tfc3JhAXt`+6-JkHuyVrb}x-EKLj)7%+H&dTd4AHOWmA zGV^Sq8v^A!(WP7Z*Bi*mI8sw`&d$!SaSyx|Db%l7c~+pubI0sxp4b|P>0JJ2oiTK# z#_nc^iA!BI+rPJ{b?g>}U^UQ-59_PYyAzWw`X{96QDFFt*tNw%?Pa#k+MjGRb9BS1 zKoUH9GKZwH)F>Y>Qhutf0=jQ%Icqx?E+t_{Pc*x6B_B^uHp!o}?ibxJ^j9LbraE&7 zhm7ZMx~w-<5F+Ii*C%&4t-N-vIjc{RVjiAZ+uA|{E(8SYU6qxNkJkr`83BMOWq1Zc z`Gq%o+DfI6m{BPbl|cI@dHUGyiT{-|9nZixaR)q8Tn!s9l-t7P=eS+)^yT z$S=f@Zl9YynA?K*k(X=BfFVkd8u90f$ZrJ=HMJsd^Hx(v7{WtbArXipdT%#fkX#%U zzHh-1=Uedy$ltugS@dY5cX_Y*6y(pRs$SII)y+va7m0QyPD$53{C3^LY`JM8ZYQ2vG_Yt^04`xPDZ7p61?pM>)BJ6_Vh7a)W2}{fJNW z@Oxi{zY~m^P^6Wi3IKrr-{D{^OKBlI)D<>)Y)j)i?lz=niT|9(^$J=VIHlkZFs3y_ zO2RFg2RS9h5zO1tlKsRAtv_S0EVXEzNc@FP%&j!ElwO82)F};Mk82M4ur)nT(LhxF z>AAh|2?!#wwVK?DO|DD)EjGAGN_#*m6v!%!9sl3wnmYACB+qnjhC9F^Krqoj_A7qg zI0-x-a&7x+ys4Mffx+5+`c%K8x^#2l%YGyVOUu%jQGFAR`U35ZW=gK8Eit7A$dBIH zThyFBN{ID%BdK>EbJEqeu`x#*`^iJ4gksS%IJh_5bvayaoG0kEACPV1hasg>F5H>! z{IS`}7D*xdlRQW(b@L_SsHvMfJ&Yt}owg#Ra92go##yUa=>CyF)=gv=88+M}qHwZ3nH5-dU<_RUO^`wyfl(dWISgb+ocaDxfh zQaqR(BIe5Y*04B?X%))adb3nqTQedxX>481xZk8qkhVUQ-k3qtJAM*JoqnOSDX@r# z`~Km~E;L$k^wjMqQD%eBAiD2IX+n!z>Mj!&%a3!PNE-__RO}|gD7a6H!5P>+E+?ug> zO`LGUKIPM_NfT3EY;@o7GV2_CFz;`2b>BGe#qioazJ|XC0nYAQv&e=aDhzoKDJ)9j zb;>A^AQa%Rdja+GY#@y`ZD1(BaWtNxj=3M7_V9|c` z^LyQpNavW1h3G->UkA4&*_8$AKzJ*z(2K<*-Hz9S1UPtA+*aL9cFhz5X6mub++hMa z#HZ7ue(PIb0;0LvHB;o{*_{OIt+fyBjW@arf2Sh-n~f)u81HekmLDl*=LQ>ycX?6! zCk}lg)$XvVdjWO0XYer4%3rBVtbPCM>)P5He&EcE*6r^HXI5oA@@xquVAO{A-D2a~ zdPS>L5E+!W5*(VJ3`yRkbDio7b(%VPHt8a{UMKG=B#DKShh`N7O`6K45__)4^vOsj zEwJ{C$h+)?ZkkF=NAjJC%NJMcn>m*D7B?8hy1{Ws0FXChFAXg0uvz5+8rmqHZ#m_< zpI8;M+IJQcgrjxZVA{?%IHjbd&|~0IOBmWev`i?zOASf;I_mRzj;rME$MXSGVEMu1 zNcOwbzfmDTHY=^nPKl7`68Z&)I?OjA;IObQ**TH{^}7cQM=1;+D8pnfhZIWbwK3e8S*fNDQ;h)K5W{mQC-l6LB^2Qa6=o4 z>QW?4{f)ZADT1E`Z2A0;P@>;QrUV-6!NY63ST8FUDpxxO1_% zs1Tg3aw=E8ukoSAv0^@0O;>uk8<`-Cq|%OkUr^D=Q-t3;^!Rrg^;^%UKW9_G@oPgOMJ@jZM3lPuVfr&Gf>l((Df0XY0pFW8r&?7<^fxdwx#QR#PqQA6yHH+5H*)^pKv)tA1;}yj{rmaNLthUZO)@q+vug!JgWzYtz3rSJxY@_23usKGyzj;K%D= z_`B%h%<#H*ujQSzuaW|*62L4F9R{OnU~t!+Tod(okJ)vZ*NP}z{}z3TO20ArO6b8K z59vdyH8k|-#kITu+4C_*M&tBjQ(ChQz96M51##jbr>QL$wrWk1<6`eVJMZFGn#;Oh z69~0CUzH6Q07(60>${u^9KOS=oW2y?^SUt`qJfYXG~Rogqb^wTg4_+83WG;HZf|L3 z2rf=Tx_@y-snks^vliOe$bFoP5+^ZuJn|<^NB%}~gIq>m#-U2b)skrFB}GDo%H{3? zy&*UT#^^JDp~Ig76Sm*PP=LBdM%0p0<*oj;mgcYhQFG`FTJL)Rf37^QmoD&dRsMiK zs(i8&`_=rqKPcQs*#~SxE+TVb(_8w==S4iJ=U|UCVB2AsxI<`xeg%F#{ewDw3L?oO z!7_ba@zDWx3vwki73Q60+y=epE8k_iIdl0zj%8As$6KP(xpr`bs%oX8kMc*4@C5Ac z#{{z?JMW-&1Dmsr{7QF-Y4nLt;4GZ$v^i<%gk=2V-+GqqN$jU<{A^7T6pj$bLBzAy zXl2_5-&(X|xt**6j~uDQaR)~nXJ@m%1Q`x?{26B?X65FdstNm?=B=v>q}V1eX%9L7 zY>O40?~0yUsPi^MtBh29q=che5aSzQB93ft((u96Ngndyzv#nOjKfs}mu}V8p*X0z*YWjEzK9VUgz-ZV6 zZ3gZa1W}~_o7A!6!rYV@r44>mR(q>)Gq=Ny8Yl*SO&;JQ>}PC+2xZQl$(HGd94S8bihAo2ssJzLs#SkB9@bHjz-{oas?yzUf(%5ThX-#MbtkGYB=oFD2v$rum zESAF|x#<#`<2cvL#Ce*NaHaU*Qz)v44+`_09m`In002(3DXZVX-vqgD|8G*k0pq)W zO$OCn%nP*t)9CnLpq$gI%Wwj=7ki7r?RxkHVl(_zd9lcQz;3|kS^OVt93CFqSft6{ z2+{GgU*=?I19AwS2wyG9%E-uc(P_7QwW4)W_O|g>4)oxDc$rs?x=9eH)h;4yDlt#h z*XC-womY^cJ$&qYUM2}qM&9EgZIWLSFd3_t$070@XepEp9mYkOs%i0L&dYFjYX7ad zu^at27A{RIl+`Q0mqe}AZnX~d&@{zi!Fliv%eRI`c3u-c=S#od0REX05|_lu?>7}= zRFwjyq_@717u+7#!AyQJGt=JOd@nznM~IbVFBB1cFw|mTcwejmY14TWxMX`Fd^heG z;}klZz4nt5Tu^rG)6MMxYH=P+Gd?2<4KW+SIRL{DhFjMUZ$8X-W4I&orJYO7L8HO=brCR8u9}?8RGz^}A zzf(?egIHiZl z0~v+57i(KtE2$vI;rNX=2V%|Lm<|AHYK&~k)wZ0DoYPApAh%InDTB?W&%UZF;-TPB z5#f-P7XgvoaemYh&Q))UO`*n^xRvH;spjLv4Pf=Z%ej%eu-CdhvhHNKQO#phiom zq=?7pqnyT)rzuA)v*{wQyM*_7)W%D@BcaqRDOYrk5~uZ#@8jWIpkgWNbYCNB#TwfDBHGVJ5C9D@#XpV@#;p;;@ZcAL`3@X;6~5Y{*zUt zqH0-$bAluxB}0q<+tGK7qMg<0F4~;07~qw zHWABWx=0oSfrnBz^Qw|^}H^yjSdg-P2{corY^5j#`ix;-~y6q~L zspW)<;vLnpmDPqNa)o(glL?0Clx_VX>ux`cDXdIPn!`UvY@nyR%;pDy zRAtHE>BBX#Qfny1V2=IMNlj_h+KHjiWmEQp*V1Cx{dH0#pc&Ccxpd~L(2ySAw!E9> z<}G(HpfhLogbeD)pg(EePsXT5g8kFWaw1t-d7;yfNYB3CH~ z)oQ1*FsmZ(LEf!zXlYXx}r&m2cijWWS3FRqAvYis*0*M@oE58o*MBcp=Ok(3F z^SmB;g}maqTX}SZDVKFb11{IRz)M#Sw$=Y8E3KYEUerTFLhoR?G*gHE~W%XJi|8I+C)9z59W7h_L=+e#k$8m=f z1=@edjtT($D~Bpn1GJkuJ^Jp8>1ETPtUbCS2h#N&ddfMH;zmwn(l@PU%z6>0oS&py zt@%?Lte~^vUPJ3$td89lRt5eMk)aBTBp7Wlgo@LBZtsoMAq~TWY_RyGq{$j<4K!>0 zS{tqZG2cl{ghIE)cRm-xuPRUH=LbSc!r3dlVRxdsx|Uw(ol_20oxh+Sqw=`(LhH%( zes>tka_BQljIL$yeK?WWD$()8nr6z{uKyV4}5F9qLafc1T-QC?GxI?cb&vVXqy5Fz6&luhR7$XC+_nd3Z zs=DjCYlcSj9PtYdHHY}2irC(}PY4pBW{TyiI*JsIQ5IOGo*UWmMKH7mTi9J};4$+# z+cxw6{s;yNMh+ix-Jn|&#ZP{OMd)Sr(Yj^Ah4ycsHf+Udyi-_O?C$>% zLYzHVj#pPj$ICu>^U!k302mI+*4Ug4o7zqV*(|5Lc0WbUIh%RaBOOguDoqqIHqvG6 z73%ww{=pF%MCXc%N@6lVe9 zkNx{=_jjp{k8kHnkii89Z{o_qN}6hMJgwJLTNjL=&Q2MrYTI@QIy#+oVR8tJ8JwK~;OWqc~lE_(UJ;8&rQ*UN^cE7z}M-a!9Q z?`%7NKK0{kFkeiSadP6m<`zfHF$eQwqn@sH+!{tdS(llGZuM-Ca?YtR14Cb~QnNvd z9o)x1>v->G^mVbEjUorJtBS1J=ST&%`=Qmu*r|H=>#pfFl$j2GQu*6|vS`&Q%|KL^ zsyg^^QubP>=?~TQ1+l2Ik%pYN#W8Kbo)|6IY$hw;v`Iw&d6P>qhhd9@0W-|hx#O1% z>Ut$k!jfwtG;shlyqTcEUUzos#57{1r!sqUmqL4+A|pfah%w?a(cJ?_ z30WArXucO6ovhi)vVga+R`bgrwEx9t1X+rS^3>bm^4N&XeL)k&bJIRE<7ruLmf;zV zgV9>8;cjVD7$kWhW1zr393q*qRl#xYHQ%oyZbAfOTXqS5i}u5-lbC3EKzPFp>5EVi z$QoHhqSQDn(nUsBU&!TvScy3vMNd1_kGt>{u?%eyRo_ z3!@)UH`&Y2EjhdnCl+Mzx8T^2pM&=pS)@LNY^9P6?|DMHu?j>G%kk(4eXZ z1Bjsc4Fe_KQD<*z777h7k?F9O7=^gA2lhtlQJddszoyJOvZe9RYb`J+n>|+-tm}GQDI)MPEHieWTth#r4J5phN@?6^;B+7q4)wd;5#Wq1SBX zW*K*9BB%U*w0|E|SlOU1H&-W1eDhHp16@i+>Q2G;_R1E+q_0TD@P*JkkSh>)=K;NYO0lZvSDdOUm( z@O(`yYLuesD=V)O|M1JPW{%Fe#?w@0y@PdfkyD$!|Kr4=%F%1xzf|*XVJIQvUOrv& zS34#KIymA0pFcgjS4P1W*yXTtI_(nhJy=IS zvbcN?J-~``h}GIU`*ML`K#XWYNP3^rRR=>!eSs<8R&ba_@WL<hmNmm zzZ^fr=*qGML#H|XK+ha=DlUGFjUX@${nrrRlzdy;-=S$aMwbis31KsRjZFP7fDbhs z!r#!A$0`#aU>Jy2W=e`L!36>7ikePPK@*E+%^*&P(BP&7gbt4523SpSC2Vl%J8bkY z5SiChr6YBol3l{B9Pb*q-{KoDMAk7hU%Y2O-^z2@s6gal@mFT1-0lr$P6c76@vj?~ zg)6E}Qh3n-lcuJ8RM^VO{2W=N8zS5TC2h}&++OAP1CN`9ytcNsvT}?~fG9~ref`JT zx+GR@C8e>|GG2;UsrPMalrOa7-&23u>ZWX|Tju8-RIZ}B2}1ED4qzh3;PKPZ?UD%o zTEE4;r5k`ee#kRfZR6g1kckf^O%bzURDo?@fg(F#rW-Xb=+8LL8csQDiAFfX-!8|; z?>ldRd%$=C($Q9%>i%;K7u<%3$PT#a1=H>CbX)z>|1nq7S1=Ft`w(fnF3ZmT+HS$6 z;kq}TcQ9QV%SXq^DCl)na`=&ll9G~-PghGz3$>le=4ig=^*i|QqJTMJd}6}1cxYs# zhbde(p~O8hl1T6gS1e{%R4R8<%R}3QCv_-6l!TNNV3bS`0MqdF=;-%|#U^)fyZ-L} zzI>*D-WlL0Zft6r*mIEz7}?QK$JEimlP1BJuZfgTEK(`xpEfo(H#ai+`eOHcTwELp zNhZRr((u0Mo>sDfc5!e|5k(=MJBRW{j{y&EoT7HThPppqD)+%Q(bG;VI3DhL{`7mj z^y>iRIh(;0YKiXKJ1c^Dj)6JNBD%G9%jfu+H~uYJn+Yjnhg+tx2j}M7!H;h9noFE6 z$3d~QSE>xqu!VRvob#qt%z_GhTz2{3;plW{sAP~Tg~S`XyUXnDz}0k-R1fJ?sydFY zSFhb)2*6~HjP&=%L?DNViimXBJCC8bID8g>IR08U@Dk9Sr}aqE@+<7G_6Z!1K0TKm zj*>~D)sTidhQrKd)N7RvD+X46Ei*NxCNLxtz&R-4td9+JN!37MoP}7>97qWY z$rw19jbzAWNs0PL^7ZHAfhu0+z(XIdW={)~_B%@P?gh)Wtezv^5zhAm`8#t)p@wt#+j=r?8 zurS%$`h27vKg5sO05x2rcCY0-TIqc-lYcvSd3JQU{*X&A(@`MLvaC>aUONqoD3fBkJdrO3OMzk&94zjVwwJg0l; zY^>G>-g^ctPKkw(ui54ao z%=@3XtO|IXZT|fE^IiM?zEuO>>t6^Dmoo+tRcrFeEM0AFAI{1F?}JXAohpqQDm4vF zWkm&Xbgr+33CmoU*l^49P*_wHxTGX5LKotrca=gV+}qx6IQm0qNL5)&%j5Cx@?CqY z_r2TxK!sEldWtbe&6>|ICeB40ZY=Cm5%OA$G|3ha;-At$6u?(5v-WZ?eIRw zi!3&yCqd04idcv|c6D}+rN3Ng_m#Bbsf?D#-48M;OHWkO@Y093w95qIc`~+6I4-l$ z+4q4`1_ogsIt3{*?kzDJ8#?&Kmlh~-Woha9ABBHP(7bK$(EteZ892jj&RKpr=l?hf(MnQ_ys#uA3UT?R6d zq66v1*NN$#jfbp2q+i)<`)&VmRMgXZT)2MoMcZknmMIu%pXB_l^O8#Q9l4H-7*B=( zO8hkhLmNKOg{{iVy}eLr!nB7I00pOJW)e%U`eG@~Xc{Y{chayuHIij@jC6T*ypHhq z7L@Ijo;G37E^G4gXo^8Jx9oaNZt`4-Nw#=f*IidM2i)6Vlfl z04EL<5<^j;B)F7<%tWOKmo24Qu&AhrH0TMGPhtBwKeVK#rpDx8-;>1F$Rm>CP-^>p zDM}U{Pd$znBO8c@^kP)Ny8w8F(S5O6jkZ=#*zxdidy%D3*^U_L+jEjqFONpm+hDl0PQU6N;Sr`$2jOeg{H)ygbwxOYc z%n7L8Uqkuev-j(xIyz~yoBn)9Tf7|88&wsk5~pe+Ausx>x$bv5W1!X4yn*@UB&D@A ztJN=hGw2J-W<0D>Md>Hm#PU6gH&h~{J9e% zU{E6wI1V5u=`PxOS6xJdo?tKiF{hmDO*FeDalJutXyVR+hB|X`Ulmt!08>iYW~jR2 z??;=o%L$wkDc5y@yx9uyv@pI~#;WR!xr4PEWb0y+NE>R4u0I^A(DJ^BW0sXFo|$|d zowwdCH&BWRb9Q``WU~??1Sk|z9iM-#x7CLxR4q`U*3L)3c(E^B=ko%0*(yr=V|2tMMM%7 zHmMUv{vkr%R_W&qMLT?o!sM)UqQ%iyN2-5#X}#XKaUgLwnL7Vg=qgM-_I{&0;_ls2`0C zL>B&=Jhs3S2Z;8`sWX}-HB_=N2n=7OS%`{$5;CzswRSL9Vipdzy4^UnK9_6UE&N!x zV&Q&d4fA#ix`<6HzIr%i`~kM2At5plHYB*(OM&;nlSEnBit^UPs$KO@0rx@?>EC1# z!0@k-2JTZIPqx>BGe6l~UL^@pq795Qeo_zY!6g1KsbT`C1nPILxZOaEk}MqX>!Eja zQl9W?9$OeXI*DRh&sCKvKvC_iqi4$O_0W`dVJT*No9&c~+xMWFprwF+03_bS6-QN$fnh%YNpoAcNA`MoLOUrtRJ7u)GI17>Y6{ZPi4zY&()_AGrn| zMuTvK`2^3DQ?i2m?H-k=IWHeV=L#;>ZBAM2_=H65YDDQ15$a+$em?N?Z0RkqjCBth z>MI)E_md+6FFb(1V5q69bJUxb=jU%jG-#2bv$M1H`+~#62Zx5@Z3~;5A0V7>=DF1& zE%ptc(o5iStTZUwxhp8K>Rrr=VweUUe%9CoY(Vd5#D^8y;Jzgept_iE=TQ<9iB*i& z()L%hHM()a?}4*>nBCgAaIQV~|@u?`O@N|No*a1RwWrHB!H?acfM(M zhBOc{Vwip;eQ5mgb=&N#hke9&OF;r(WN3t+lYr9~A2hsy+aTK5hb%aRwl2H{) zldSKbEvJC^O)(X{Qqq2^sZRqZu&A=KvWtt0CS3v<5I4DvI-%!mfgo8@QevW^_d}8D z@%}o3wbRaDl`|JII#Zjlio8HPzzY~-?Ue(Z_MdnBay7r zl$EaOV6x0#^5ZTp>)UH*?pFaoU?^g80HEX1VRACEfWW|halq8Mqv%##m*8U4xF4PL zYb~P|oLanK2s(pD6L=Gm0Y{GU1j{3KkVjwJp`B;AWdRuXh>A9c_}n5Mm3O`cK%WA ziB0yd#!yM|duwrOU0oeprN!7{Lv2Qed`xefpLGlxoY?2Rb*-SNV^eFbV#U1Z+_)Y4 zm6p-5qPju)8@me45^|Y@UOjS$jqZs5ACqH32f;MmiUKDg#Scq`k1A^2_-Dy?HoavX zwBp(oiiReu-A7|CjK2PEtX2|#*Y$V_;%nf>T&cZ$oK!RTUFTbPV}+U=doEVSNDKWU z1QHt(av@@%!I`hLl+>u*qVzJ*P#o7Yf}^-ca^~ZCwgDN^gmYEm$!+P@(v7s|<>5qC zkpJe|o-gA3T)3ent`tdH=qr4Cg9h*dJ8H=0!NGlvwHn8@<|ZN6jQWxJqtL)rsX^*C z>&!siOxSsBEgeE8Pv0lOKF@U$1+OcaR%D5!?k|@b!k3TJbS}Dyx-{8)E_<9lrn4_I z1+)V;qhiOfq^=B_r6CmSVj`Fpsm7U_s=2f9alI^%zqP+}3b!a$7jSil?~+y#C$ zJ8nyOOqDJEXSrxH^52q`ltgE+HI+|?Bm2aaeC~H8y>vfHdm`fMI8q(8f^d-#M8*IP zyOW_AcS`ZSXn)8-z+*~vEU*#pcrOfn*?3_^Ck##Oy0ZjcnXVzgJo<(#-0I$+@<<_U zQ>ksjZ+bmM-IPjOvby@0y?yZl{RNN~{!SNoNF1$J6~mtHmrq6eC>JNm`Y=M^H(s7A zbjF3D2!Cz%;-y${H$r9xln^GOW-g|(R}#Nd0Zay{mGji%^h9s>>+qLmRwnSIg}W|z z6(mggKwa7ob2-|5(ZXmZRn>yNm}D7h8OfFE7pC$$$V$@lt0kgfEJJdg+NGzAftqww zCec9Cj=Ejq{s~rK(e9kW!gaOe@Z*cnbshLcG4?JT82g+RQHabS!&Xf}+MlMu3)`Z` zM$hqFspYdlz+cNs1ClAH+Qxv)AD}nbU1ey*7rtNo9lI;7`!HS!shQnqS2;lPo;%OA z%j8rYtS@^*f*LY<^f4wKxclljxG9b_n~$VmB9Z@16rQ*!I^pym@w$vh0mVtkHDq+~ z+N`~YFRE*X5@%{jWH}|QkeY|*4 zE(e!z(vs1eQ#Gju{k1;^H6t+MdZfwiX+%t-XzOgX7*tqj;V{D;+ee0+a(|YPoQ#8x zKKXw4=*ZT{h{QuvMI|;hm9|O9(vmuIqq3-z3I1TJIMGI@CyL1T>7l`fnkK%{d?Z6* zIh8RnGZSLB#4`sCB*5e2ES#K)2$A^Q$_56{co%;R6h`=nJ7)**Lu+Apf@%(i#%((q z!PG^`O&(EW2oZzp!E<@H7)H+Y1PB`xlU_pB+e z2iYAQ`0Cd&o0(dUPC6C=6`A?*>bfDzi!Q>mr=Fi)?J;uPqteiW1C!|6F(o?zF;9&pM@MSPx3Zk;5c1HP7 zxV=WKXMHnUu|7JgC?X;vMJ6DipEl^Jqn2;O2w8+6zv<5`2anX%nJ4UZCl|C z|3E@DvDp=3poqt?nY+mE+wkSc^d}j1eQAo%;6lgU@dqSug|RpS-t54Knk^b+nh>NB zMduX@a={$9$Z8vY*Wn;hlSoz;ifxI&e7Cb3zt0!%go6$AfEgXeezqTUrSr5$U?2nw zoGbeN!N~eI>o&P}Hy-?6F%)}`&f8MQ$Ve?C=^P{=*(S74G$Cs?L+XP{yjDwoUY6@E zuO_|)^|;T(;A?%Yj9j{49{@JwUXf)$xDEpk3IY1f*KQ$7JsVr1y^nX3UWXzW69d|f zsy)fOui)TJIXMAULWR7nqo)CpfIg%|pTqhTf5NbAT-C_l2+lj5&&ze6CPdfw`a4gU zw4sc|2(yeJ_MWS9j*K;CSXZ6Ae%w(;e)`;Wsg5b>Bft4aw@XyAs$sgcJs!=o^72Zj z;Z|?30VF{j7S7d0^!{2VXm4kjztKIhpqN&!Shj%GrFzKAEiqq2okZogg>cldmrPTY zd3UF3Z})2k2wKH;&VhICdTL3B(_VLhCHSgktps)i6}t6O*Uoysk1QbKI|~DAH8xX&AGX6H z-tSy?=*EQPOVifEYh%QNetwi3@jxx2j}0AVPQENPDk6{ zAD2M^fzSg441Eaye97{3BtF|X1_I)MU^ddAu_^-b5q(+)k9xb_MRpCSv8@$K>if*PEX7 z*ai;kt$i}|619qF&DmWmN{teJ;t=31x}sJeTVfkI-CD?_4^*%*gjg*f-?kt0_uis; zvS4tr%UHYV4H=%MJVm!IHC+%ex4W;6Z^>W{CowfgiA8zXLxJ^d!1V^dovuLedEq1V zGj*1)PgMDp$V?J-@4!~?Ev0~R&8e4`KOn=NkB{W5%n;|Bax#iyvM0yBnW)5>$+>X) z)wK$5jKpi(_XQoxsp03mC`)}XV_OJ1s?c{&PO1)aE)`X;9uU{g*{|&Ly4b!-`dTjk zAS-^?%h8`APl@B3m-$6TF1|VumpZRF=3t!=E%t=NzzP0- zV^^ED4-Qh3ld17g!n)Sh)@-MU8i1UyiS_pD!U3M3sa@715iK~Jdz1BY?z>))MtHSR z*3aIyh=rUz{ik>67umO8rOk|eGCnyh${M7T^6ntAs1?aZLN8t%hz%jM^9D`Z=FANY zP|WO%45Hj0RaZncum(^Uv@F(H-5bmvXX&`?w3W>aqCirRQ5D|i;SD>DK5{koxY+Ow zIbG>RUGI%QvQpF<(Iw{5mM(FgH1E{fL+c2E8|&^~X#dl^GZlGQzaD#gSfGz~Y$7xE?PiOUGxJ z0aFugI8r6!^$HnQ{_D^I-0PYPoKYV-$RB*(e(F0YAU=xPib(oW~J$* z;aPeockA&Cwwo7?BQ0v#!Ip?f-O90@)71NJ!s9a%S$FQ^WFohwc#-~@<1Jkssn4tj z#ejtmk#`?$_};u{%FapSnUDOnr^Y;<>z?Fe-;Ifig5XL=2Hq<(u16!<9XtKEU)H#m z&(sYx>}Dsa%_*PIxIlZUDwGNtx|*7^??0U^^G8cgzV9s*yfdd#k#4wTA!K2L(Ji}B zfbKyF8O&D9RVjNT{1Sz+KsnEMnVtQom3)i6k#I$@42Y8!7i%rOCc=_tJdvMPP~tsk zhApv&ly<7gKDlQDrrZ-nw^yYYA{X)DhiK{bTjlr2RJ1cqFGVC4N=lmbQI!cb=rCt^ zxk$wkpLQ~R$&kXr!nEixK_dYnE~b_J=u@j|Mxl*^SyW}17RktSEba!B(Qa^K%N(i$A$w5!G#G&qn{ z$e^xgN;;Noik-K7gd)Dk_uAFc{0)f`PZZ^SUNHy+%Fpi|RlKuP)*Yl~5Z2Zn2Y(h5 zyH!&YR#UU&*R|_f_D-dcO?n@a#^c)G(*rd%6Z=io)QzU>jrl~8wCG-6tn$wqcV*%M zoAU<`Q!H9aSqj$erX(r}UW7R-1JSL^@uEZ;%9!f}(Zt6^VV-#Hz{Ypui0AchO}hd; z$C0AWVC9JmM0IpL8}h2F*-GOia-|kfonV_3qd7!8!yKH1{6cGM4-4#_oGP=3{rlot z*}FS)D>NxoP4~1?zTZSj%}~bV?z{+!&CEbyydQFVxH-jQ(EV`9y*E9ut=6WYPBC>q z(V=Cer?d4(Eo}SyJsH?3KU69=q*ShYJmMyEype3h`1P-?E zJ1og#koz2$6rG*dr)(_g&c|@-|1OpMzKfTbI7?g&i&RE%^0p!55&kmH0DQ3(6~BD9N1D< z-NeC>0;|L&sH1O&&DrejNJvQ$2X*9)v6)J%*{xK#-CgX(^a9d7g}e>d%UUS7F_R|_ z!MJrg?H0(&q;i=Y5zRM}h~`g9rek@GLfa7swz6?5GQJRSz)3L8<#LBBZ4H9U1sT%q zD<3;Yzvxz%YHcmGXdP`bFH=xS30+NniezGIL>NsdeViB%i#vWECWewGoojMkXIvcm z>({`6c5xUQAvdAOsjL*8F&iS~=#s(^LdFlpmG(7vQxgGU&*%2$26zTxr)E~qH3CQj zrWWM*NtKlzB;HLK9@{D~e?h+h+4gP6+Hp5UhcMFLcL+d_su| zJE+M6fz;F!DDkpb3*WwdyL)hYeeDi7HC+G3QD=}HYYN@md^P1BFm?iNFX(K-(3USD7Z7-JkH6Lf%|j zsCp>yHdNBWFsk(2c~Y4x3cwqX=QBu>v*lcAju`$iVdrIeG#9_g*PFip}indoaV*BULpOiIo;NClpZF=%!zMeWbtIt6bj1fMK~c`I~1Dr~Fe+ zqoYS|T%W7}?H_F7-0bZ2&CQDj<<8X)nStV$gaY7b$4`zf)49!!uc7YWS+bSw z^1k~AAE5$0JUrx06ezejSUgOI^hBYZo#FPR2K{eWiTKHYNVi}-IPcq2A_h$y=idp> zs-gZHgvdaP2!>`1hL%Yf-zJAzxRht77`*F3_iifwiW8Q2tE#jp{PGtjGo`QB+ zHJJdP$dGq^`~%{uQ0Kf?@Es-T+eM)^w>H7AN)CrkXO&=u;~m9}7d z#2M+I#r|v#OUO{2{SjOIJ*yYpC!4y{-(^a0;a26Eo0D!5M53VCT8CmjiF*)Ey2P&H zf7B@4b+3NcD9J>3Od`QM68h4t7lkt8@@8}4zpk{1V%pZo-bTtVu$*7_=nt zTu>q~tB~LzWsGxQF6Pj2JNu`Usk=cjhl|!Fc|&%(H;V6R7M*^SJN;n>KLqu-2_W|r z>!noFu&%GV(BAb}Pl={PckIqtBL#|nzH#u;%zaKNPCjFq(h#$)EHJ_jR661m+t|m& zrp$?>HE+`XT~xTPwD@f#$qFDR8I-w5q5{eVwg zl~p>rhIJg?S`*u`r;Ih#QMGN=#!)>eDh|RVJcGnh*mJfz+gAKt$rm@c&~JVYR9D~n z`9)+((^|P0Y@37rzbq7FTwzq?y&CMNt{~-0B6YjNetO_!QXh6=vJ6;h&NTAmtFL>r zpHj9Cn+Q)P@_1{MCeA>mNX89xdX`769y|PX-XgQ1u2XvLl^TEfF&K_iZ)(1@ksVGS ztN6jQDFg8i50TN@%Ym7uR9UJ`5|EpF9Zt~5EjNo&KY*IU3EElw&3KdHBi~H_Sm|Kc ze`+=>Tbd7ttq7H`6H@V6elAYsK-rSCI^phC+AzDd(BV+WB1?{3soFSd&cxSJ;X?PO z%#?dMa1>1BmeI$OvLa#Nq(hf)ePEdZIq1LVUb4+Ce!;0?EAqeOyE3tmp=+sVb@@MU z{I12vtx5asdjoE@il;vVwE*G_>g}Ia4)8Giw@PO@k7(fOms?7segMi6T5chTJ>q2! z28P>PCL!tLV{7=4DxJY_!X^5jY{`}+uA8nb@f&|h#>MG?2GV))66kPhEaCtsVg71n z?~^dC@o?6+oMtITDWt<@tLu|(S-3G7A5%I;a%VUw_XwqiTvnguTZ%21rgZ*SrSq7+ zIP~Se%|uXh^J0HLD3honb>{hr`ybAmP6AK-1>uxzJ7<1M?c{8_17q!cfvy_fP=(g5JV9lw`Rs1l4^JpHQi zEPjg3_DWb%QICY}yhTef^#+BpRiF<#_9!Ds1G%F8Vj4U!Ou=zqnsw^4X7rNs_=I_m zcgNKRVY*8M9j(?72mx|*C^L{ZURnO+mFAiM2+HDe*NiZZMFIbjYSZgLk&mCQM9 z=e9NvE)5w8(B?S$br2#c6+$I#mxcNd=*Ak9$jyQxELqo+RP!9E6^1NK1r>s0R#p-o z7RAa>iHm}wkBpPjiMyz!8jk!sy2Y`n_R3w$=cG0j93`xCS}W4~736{unAgX#cMd|Li-l zTQM;BP1pE4_~VB;OMJ0-OZQg_b%nUfAfr2OqqZ2e>vlI^g;$J zan_Ei1jzh4q+s#WYfZOLqGU{)Bz{=T(S=qf?^qW{dN2DJ0?WaC?=uq7qj4So>@AE^ z-Qnlefrr8bPpZk#$Y{@lc6~&6VrkB*O&i~#hf^{Vnv>Dv)5USTxWE0{h|(?;QX*WHtMq5$1vnzq0#Le|xr`}N1qOU${;DT8t6-N z5+3|GcNCSbdvYAm7X)W$)41ehIcspiW{T&k_qi#krav1IJht8J^`J?cXahmU5&|-I zZ%Wbh9L^k#Eo#d#czf*WY$7j!-{a``kolT@(?&fSEmh@oi1+%@YTGPY9SSTKW;J5P z4B=b5J9(VojKbY^2TLyNm_vPIuIat5JMq-K*dhWsp!TzlhNOIAry!Dm&dPSLi|wL1 z8eVvX55NblEg3%!H;pJxR;EJTn0Tj~42o-KBL#gS8v==miICWW#>Oq{jUk{r*&H%d zT;%n)HR;iS@1YT?YWtu+y~-6i(|l32H|ZK|IX46sE$1t5sLiqA8K;lfik)V7Xjnjh zYu)ipx2*gs9UtrTEmyQK=sHpIN2S-pDyua6@jh>vO<076qV1ReDTtxpHfrz^)DI|~ zS{?kbGmfG>MOkkZWCJaX(hH^bcoOaOm((3N5lOfuCUe4p*8MRXl0eka zs=VBc+?lHW$fyNxBllU|ddxBjt;k3wJm<#>P@oFjR7g=R63~#k)JXyyxbp-+K2@cV znwbW809sn(ewN=m-eqhN{RZ$6OnsqJBt_RrZijldnn^AX#=h{jP&yoR3!XiagG|m- z1NAptRkaOKoMBABkxj-Bg_yMUqd43U-R;B@%4#RA@1S{6gm@{3fz^H+LvVq$qKN{g zBd+?$JUzI$v%&s)vISF6Y_r;*bS{*MMGw4#4N(z-t5}t5KjlmIuEyrAQkdu|XzAE% z*`a6lEx!eGBDiTS!}R(3I(+1e8pS7jAmVAv%(y{*`)?gaCPTjBMPY#+7&01ah z8_gIQQx{2nyOTX;EobDsQ1a8Rs%w*dDaxuF*_aOjfl=1o91UbMWaOhIeA7VdvERbW zEMRZWlrS(JKMm$MY1a5rpV-1P+?H-v_3L?k~+ zbU9!XUxhbJ)=NQSeQd9iPFb91Wn25Gny()N{4D0953-^$}8{gP~Prl{$;(%Qm;lrE5Ex{FDEf86)^ zcKV%R@??GLmO;=LW3}F!1{km~l*(xzZw}D5&lOGxI|xDxcikoKoLSV-mg)S6ab5%h zI~u7};ymv79ZVBib#MpqWaaN33Edov=C>N$Y)mUd*V?w~=EAPP?h^?Ob!s_)28j?) z2xyQod;4S)r(SO1(gr)d%2T@y?|*g+erzBh=;M&cwYb(onR8cZQ%)7nbQp4nA+9Vc z$Yk6&3;Z$z>!ntlj<|Tx;)Gqa019RaV8+LM*iI>To3CDR7Jn81yFVu46=;RyE=UBC z4Ewmzj~I=8CE_V&4G$blv5s@RRm;FO#2!B6aP4+*b`8{8A*N`gKr2Av)G$uEBrpo7 z?63by*N`Wm%t5qWn)>-kDghd!M?Xa6-cS^)PtZjg_1M%ZBK_D(*jN+(n!sMpew0Wp zOx#rGyp7$p3R>`duGDD2qtz~uO^rXXsH!Pp#W$lpcIK7>No*hi&(9U2Z{F7()r}3- zSV0h4x=2WRF1sB*@1>ovqtD^ctE= zA<@~Mc#oq0_NTl)B*drLbR}PS+B%ttt1z>jYqE_~#@c_kd!z!bb!p51fjGeODt)`} z6G8Dda~W3ET1#)gEdRT=#6G6Plt&3sIDoAr+=lr;UfR&?s0G7BO-_wP7@AZ`5*;I= zaU}$MS(o~^(}ndqg0k(R9OMuC-ZG?Xfym%l(;VpW)>t%{7Dl3N zY}xa0@Lz@HNsAUP9dBU$SQxy`TWO-_C7P5gw)wr4l)zT{D;EeHA3mgx=(pCZwLa9-3&`@pmPtB--V0%buD4I^4RNH%Wx6iqy#1u-|+7Cybxnn$j-LQ8-tqL z-?06)Pc^u6;_kNi;)ddgpIK!IC;DMzDKAdrU(c?`>UX7gMF$;nFUO{^15@~Nm|mg6y#8M#;tdK;_v`J#Ai-r^~J=s^kMpzVU;N-BC!D;m#$yiBWc|7*z*9bt75`9St zsn(`SNFy=v7a*-cLnK5RDJfwHhrJn_J0EeUUx{5ZqK?TrC#&jA*RdNFuTSk;9?!Dd zE=eUi8+p8tmVc<(Aa-=3&|}xCjqe368R#2zOj7?Djkbn^FE4)6M|xU9EFXyOk#2vg z82l;C>T9E;IVro(Ltc&$RQoE>!1~Jqz~rfUn6iIbvL+s_AR4oaG&w+a4qG-@*ylYD zzmtWGQ6S6+*b&{|WPXR2xF1q#DZg||dBN@~9J6O5r@vmBXc^yC@k|I80S2HR4}0h{ zg&B^8I*x3!J+q(;d|$HaBV$Q1$+^*XLSN5^=-%!n<~a zgzI9Hm8C1epfnGPvdNy+>0>j`%FfQoweO+;Fur)D!nlXSC_T>VKhLNIkS9 z7yR9L9KeD-h#jhTb}inn3*Ef})|gy{)(s`UsyrI)&eMe>2nAi_3@pv7-Zx7s$IaUg z`&}-b43_5>?>8P55mtT9-lF_GWLJT%1X+1wtc5$cS zT+Of3iN*0*!UAMt9qn8Nt$bATYn108bz&%47A`YY{Q!}EUo3XzHUaTlWkU~%`|vv* z_8NVe9DE;r-ojOP#+8AFk|=Oj=;x6uXEI1xlsS7e=%D&0VL8Q@@o95(S8zDAHOe5! zDUtdh-_m?bop`cyg(9E#W1q`LYOGw+wx9qLjyagT+cBU57ed@CsOLOyw=y4?e_4KP zrCVd?KR5+A!{$6ba@OFQ)frCHO-`aDI5T1R8MWRM=bCPrWr0lC3JVybb0>-+0wH$n zouCMPg09<#$JWRNUqxkQr=xjG9UY;K%#9xRI?d*WIB6Cpa&~9V6XwP|a1~eP6u+aK zrO!7_F&PPwZ=%X$e~RnQgY!m3ftJ0IS=4{vnr`n@)skLrqEvr>`wyxrzbG79TZRT@ z=6>31)js->&NWAtN4;>7{WbGwI^X5EQ17?_fS4(s9Gsl)>!RlPkntPy)wW}L)pP=u zZnMp&4%YM@sC-xl-84Lo0o(4UmJTm?fa1cWAaG{~H?1>JDKtLBwkSbL!L(x31wl#I zfB}nEC-$)W5*qFpjMu3?w$E&I6=-Y-W}y|!9KA?%MeX^9Q8eS@%2KZNSYKi&D9{Ha zI0&|f z1;kXS1#W3;$=*_mYzB@ix7a33bWn>M;W^3VeL; zy>=`@;IYAgw8qRl9M7>8aq|dq$fwm zzoW3*XH&Pa;MQdPM^^b^dZt2CBl#OtMTp$^G$bRKnqSs&E`z$X7nFl(2ml=-%x5;%^h>8w9{WV`Gbpt##aUAHyOe1svBqGx)vc z8%gD?5}(EYtksJjEq;D~9Gov+z`poLLW8BFrKJ_i0Iq}DhFixmm#{GE79iGgz4laZ z)?9&{j0fe>bJ9o)tmXe}R``!GhcpgtKPzI-%V02u;ghz5aXxiE_qxryNMyn1u0GS_ zZuEqO5R>S%D{ZP}rGGE)3{I&}(nOPZ8_P7H>fxVGu()9$jz74E0;Q>BgPihU)5o5lT;iN`HJBg^ z2{}1QzYt()rKR0i-;f;%5f=@>5)GuNc;}9ZK^pK~J9CQieig_3jtqe1#G$O+~r??Q}b7 zH|H4l?11z)5jTu@R$hw(G^^UC3T+sqLT)Okb4>wh7!Ng?S`>OD3SRDL(-8dFH*@=J ze0ue-ib|05`|R$HCz?m6NZyaUNglDo&>22=Wz8+xZ;(x2s(E-ihbI(<{--n%?gYTy zqZsPrT)SeDeq<7>2Ag_7IN3fKqdu8W2;ne8JpYPXY+c+{ebl419V9S_ynY*bUc1ILe2Ss4(`{%2E;CD&ohFo~nneO;!N7eb9(ZX3v;^NYX0*3*}!Xl==OYL(2 zva;DeGZj%-#cyxvFP`6y{NgDAp z6lMpqurwQo+D&eubTBeZ2q-ixKvhEfNUjk3rL~xQXlSM4UDCYyLTnQ)=Nnrc$mf=(z5rpMq3rIS>UfiuNs6{L zZJYOBEQ?d*#U&xP9aD~56IKDpjmycK1K2JXTSIf0CfrML8qFJR4!VYw(@3~%yTE;# z|Fp}ief{4dG`ib=voRS;f%JrHjn&z&C{W$72hlz~JpnhimX(zmRLH;AaB;Z+W@la< z&P_}cSjLd({k2~U2Y0NoCE*HD#9Fdxt1P^J@z=WxOftx=ub=Jy`Ln%Kl;ehim6cf# zQrh?iJ?{P5>}bU^Lo>#9-gAV`wh!0goG<_U^Vz;=2Vz3VBjoRS`a?26*buOa`I?O+ zAT&OnO6XidRhNQ5H1_uP0+}l})UK|V=Q|^uDhW2s_s30hLpGz*H+?&G_;RF$DL(i4 z7Cxw2=@-R$gbxDO>_km2Lm@b4ROpps|eoAG+?TxoIOipthh z?JO5M;fGATlDNcaGOkiUVn>a(3B!>r9qRJSeH>}rt&FMS9~lwv%dv^$(#LF2$QT+S*7jvR88GS8bqk(R7^r_@@)z(S zAP*_=jO^*Dl1J~umyOBfOykv4VqpSi>H+a!(e<$$2?PWLog%T(=QH{;f{ES(rK@ra6A}=_z?VZC*T>dtu;RCPBbb#V2XyhemR_t2&Pgs9%063MA zbatdBb|gvo7~Cho=L_XBavj(Jf@lo;ZCqXN?iSo3xVw7@9Q;6VhiTsT|C?E} z?wWh=tob~XAGUzefRT5V_Z zEB5qZ_pAA8z1ZRimm2?7l9rxt$}%5<&pw}R7L$66a?v#sZ`za*V8G_lmBDvaxE8mN zO+kS0-zz2(xc8MnGgwW0(s*&N^H=U`r?yI-^{vbeBR4d58+uGCHR{=+`&++z$MhVb zoW9U_6h&p7_|)h~rQx1douq%Gmnzf}4jX!}KdqDZ5{!c4%10hEv%}_=hKS6btg7@Y zn6@ztZV%Lb&CbnZr5!0bIq0tJBgnj{g>1~~25;;AqB*u;3>1ZKNsR%nS(8>2N(rC$ zml%jm-m*7}h_Rz#dnmT6dIIW_E4}6<#~H$fI~V&EPT2O)A?ZUNQ5^fpgZYVc_TF!^ z#oExAaCx2Yyvk?-bApQa2ox?{(l+DC=9s_q+os$Q#&>!N8C7lRd+RJp zflupY_sJ1XVr^zjmMiMFVDRr|fAP75+he$%b4*Z2M<(VMm2s*|d0kkM+wy6e$GIvB zuStjbQtZQ}e#f9i_w20tTrX()jb^}7&9n7_0g21~%<}W+&ks@Gz|ie7j)T?Em&TUO z93;@2ya`l~H8lzL{`~w-Sibu2te4dizx(U#g@;Mn;j;Pd6A=-yrzi;+ra~Wj*!D0? z?OR$}00Y%(DLAx!u}MX(A)7G1r1}A_2+ViJZD*SX#VzjG4uj$b336;gsyO!v39a9ga}u4NDdeW23X~mDRg;Uj`E0V%XtUZrQ%D3_ zNmXxPpO1ICKg&f#4Lc|$yL981pDs^oF`lcp{X`^btijJI4(>(}52%nuQNfnJl9!26 z$#o59dlNm1NAgNh=;*{ZL8nh3E#=d1>y%h!#OqPB?8y_~gy$#-4DWugC&iSBQMG)i zCN%}5U^38pH6ld5VAAXMs#?Vx+*r&4WpV25>#}XW?ppV_l29jA@&@zI0%q2di@_#T zdqiP+RVFnCpqnCu-{SXSol)1zGQwMOd~T;JD@92S{L>eZ(JxgyK(N<86(P0p=0M^i zOYPflrklE*+A^vtm^WCTDFJNGR#Rm4=H}*v zd|r$1@{6wT&-S@W3yMOXT-3kBecHbK{xNj`Qp{@Mv=xD=?X<%A@^tMmpj&ftP1a~u ztCm!27OCzQy9aC861yM#6mvSd%ZN-u>(qbu)N2LRyLYX=FDfd@oydBar)O?vX6D@` zzg46pHbSmtx)|@(X%y0X24!A|yx!F3)jK;*@s${Mr|*|@_LKlO1!DPodc3av%E6cj z6z;avOAae7p4i{Ovsq(lAd?D6&kYx|=S~8YsTq}-plF1IcT;M5)zFnx!Y2F8;TRe< zq!^578d}<=Cf7>Ef@}nWw{HP0RzE))0x8V6@@v$NPm7|jwsH8)NMrV8``x4THhJn8 z0RaJ}TuBQCBJhGAQ`l-$d zdh0Sm#-Gs~dLnCg};c9oq zW!5!1EW5UALB`x5o%4eb@)EUjlQ z941}4IVdl&gb^F9ISV>S*8R(_*2~bn_mR7X{(T5E5AS@#CMoptRv>R-lYS`-Q~cxE zkYK~<3)>kZGOW$fW*i|$Z=f>@X_lZNp2(HS5~B>#Y<8;-2?@ziEHnc|+nz8zNhzr; zgxi~&jll#%@M-udrs@lHW6v)p6G&SX?4N6(DZVMUm_d(XB>84V<4IZ1z>fUDKjYU? zBqgSupc^GYNL&9rlBO4icz$xyL~iY+vwR&uxY%-Rfwd3m7Cgt(#HfYt{LI<3!@>d; ziI_Df%Y_k`=cZOPlAPsDt#Q=}I>+JGL?`fDuk=Rb6^^YnqSDXPU8hQ57FW|>*fJQ7 zc}r`Y-Zx;ZEVj?pGv0pBq!glf27yYQe^cJCT(eD0D@ocOq3}gB*kHEpdLOnvp{d!T z%`xQ;3;7U{&p^RULQ|{$L7JCi!C@r2z&*3;AfDsl8}UEMbYC4_<*Ja3s*utvdrLba z=Mp(!GdX?)>o2hPzg)1_z2~?$hbfOtGSeRwCjHa(l)oeE`i*mvQzbdqIqE6ja&Al> zt5h_Bec+(FMvQp<&~IQahD z@`Rw2P;v6VSPR8ef{H&{uHUBnH|~|9Lfn7MOviYo$p-G_py-q_*rait@nrLEu~|oH zV26kP-xp8`d93-yd2674<*i4!Drp3bUZ_K!_Vab#TOA7v+J9(*EDUxTH}lmNQn4Qx z9o{EaR8-VJS7gI}suv%w^%&OR`f!S$x9iw=!g0K5L_NGsed4gMPn%1~J#KRWF-ks7 z{Mcy7Qs?`Ha}EMjr^PzmLD{ZiK$b8#Pt1CShudLGp0_nMdCt(#5EB!V^()};lvh`? z3UfjZgu!j;s94YOnVm-6_fNfBw7_rnw5_N{J$cFC@H$aihPElmIa4fEM3+gKiS<)g z5H`i19HdZlStMKI#gSm(Dj3>)G#)93?fW$f^|*H2Engj!EHiV>mls+;geCg?H^1(r zTXwG8wkLumbB>VVk9IyRl_hR+&pQ9gu+i#LR3jDI3 zgcaOZfheH@K#X3Wp*W+x)!awB)fN~}$e&)li>7Yx=m45T10?9^=u?;Gcsn0+Dgcd} zk1vgonUxi0SMT5d;y0~|3GZn|T?b7r{h}Rj#$@Sz#=t<9s`bY3!q4<4`@_$Do@YnI z#C&hoxh$(OHpYEE`22*pfd6K1ZhX&jD&g=5mifyG5jh@X@~N%c~3eT921_4}xV zTFZh%Ry=-!Z1+_AY`XDB{~_;79=kMOm6Ue}1)0G~svRl_3A z3vu)XU$H<2(|98!PlIHk=|+b*pdn_+ZU(9;Pmhg*v%oZXR(uyoxX0*dYPAs_9zI%X zCC1J!=O$lD>3;F|lU42`$5>_R+wHuxY{lwBxt1mQ=&&_vHjz6u$_!pDqOcnTTDgW^-lN-R zms00zPccG0E5ogM1Q&-+)9@fZY+*#8dGn51OTGECx^brMt#U-(`x|>5B5H1Kj}Cw2 zlK^p2z=PkJtEAv@`TM)g+(auUKcAA>IzX*56c*8R!{YG_=&@sO>(*^*-#OexEU!TE zSjr8jLLNa*WXNMIFH5z;&hg{K_PKkxJxc5FP(c~#RE|+^z-SvRB9Ae`!s}iGRRaVz zTS@y96N!yg>%s&d58vT9&p+`wTuVsQ)Y6Q(SC2R304^{;%Yl9|Scy=Shlho=H*^T5 zwb1l=m%a44-9L>2eAFC>s0|5Nychb+qF?5mG1Q5w$Thhf{@Ix-!fQhz;JmoL#wR7p zXMg%w07#LEj`s9??^&DDL;(w6Y;5eLcd60&+meSlEr`^8oHKy0tuWs)5qS}Tcx-=^ z^EwoTJNItlvC!r9K`ZR2#LFue77_pEFwNVm$qBCc#Q53iuOrw%)ZKz{o2rPSRBu2I z-*75V#GlYnin?RbZG7M|AuofV`Tl#%M|rH@LApR2Fq(6V>9w|JCJ zEhY0!U>$JTN2+jSj=Ma-Dyf0Y!mkuuu*M4dcX+F;{30nHt zMZ(R!C{OhW;$!W1J;bc0{g_8_kNn2n-gpvzZTt9TPm6|lEg|v#hNk9bk9PlAas0C`deq@1YfO$Bo9QkZzi4-?vK@Fz|#EeNZomL$7O@ADy18Sio}3 z;-K+tp$&ktUIczI_z=?4u(xxYOhMNO>o#HJhUMQh|#i&yEMobDi9 zGZ(&kfvOfuO)cZ?!rIZ3XsD;WwoJ3B8bJ^jn(K(mOCJZIx%zxavCm9h$1Xied9qWAv5@X!P9Y<0Ztc%sCs}HClIY34 z6&d9NRU)*S61mMsD5>!%6)g+E;{v#ZX=Y88i!NfE^>N@qZbUIYVQoG;Va;O+7N2+(1ocY6`wop06zK(NJNALdbu(0O3MW z!=im6CFxge2xgeyWC9Mg&jUB%+^|Mxmf6OvUVDBB1j@uxkrty=_5GhA;?xN zn7m+)L;JJ8Kdi6J_&YC9jG1Cn^rUx9%$W~}t6S%CUafe>W3$~1gz??gwNE<^m$mLE za7kIwo|m;I-1i*ZC;n8H8hhpEx7&Ja2ihyK#&po@72h85xUGM$ipO)KgP6vb>a~JS z(Oa>!OIvM=+Gl=d!-qxi-VX=Pj*gDj`#X2H zbrAnTm3GB*6hrv1V0Nxu%3?p_=g0FLUDp*lOEMKb4pFMbYTKI==~8wv68b;d(@A^J8pf1|0Cwjz3adqLf%8!0Vq$nyv;B5=6}ov` zJ_^&D*RNlnh{vd%zaV~QWCYR#gskRRJT5@zj+*^xzEK1AXw)~nlwnd|c`Su%M78Zv zuG>|*lB6wX&fYSv{E`;7vmX`f(-E0Cg)~DW6$58`8moUY8I3}S3M3^89K6EHg`V(H zhJD7`xCuXzi@{$G^YC!K0ytQVkg=1h{`c?I;a`_4sBKI-f5$ zOqJ{Ld3)a9`#8x~j_L zB{Nalm6e|P46f#anmcDN0COymGRS=?v4ZH^r&`dZC_(Q)4bk#!?ns3Xt1JdCZiPG- z+xJYWW$NN#AmH_CxJfK1*nzDy#Gw~`bmUsKbW&?R#tMn4b90h*H``Y zX?3mVoP|@Kl$zK5Q>7+~*$4~WxG;e5|8}6B1Sl3ri;Mp~Iq|Sp-QL~?cDAvx0YlG_ zk55Y4-Pw_Zz9u3nJCp~VY=QWRbncE^Ptm6^=PVi&5ao_c{tM>rft8x0d1sau^%-+K`y@8dGEkQDJEj40{6z z(ORCGSKE7cjT`{piUDPRXqlT5)lWh%_D0o1i}gMDupYc= zEpe}&e$C7ylW2EvW0sX}=^l^}7dKt3D-R6ZQBRDE%TuPdvQqix1H2PBAF!T{gH+?- zAP(4WPmHk}S$KJuJ379##9;^m8{pR~iE7hd98mN^mxT&;+EuKj&R@sSM|xcvHN?d1 zEY-W$O6PLNqLtyjqKpYh6WuU>3-NnD*WR(ysOMxir}61->HXA%QQ$~_DOL$}2K&I^ zaAJ$cBc4dD@bd{dzOn07km$)ddSmOR>?%x5-5i(1VtAl?<^R6mB$&P>T?}x_XG(e2 zf2TJ%@AgG?Hfdvi;fK#VeNu?+e#9Uao^K2J5fI?wahFc3Hq}SQ1S>QdDb=nQgucec zX0=M3m!F@=L$tS78$BQbcs6x^aNrNs6r)Z95#?C^tS5eow&T{-OJ$^mCt_n^-%*L^ z!hmSZtce010VUcC7T#76Q%%Bju%u3{YCfJ`E>~P|1->klrNrbN>L1_YFIVTbO4J-7 zCH;KosL$p3!(CMLRabO&UQtfrp(Cdcp#t8wj>1@PqviC2$6h>41(!u6)hurAv(Y9L zWu|wuvceqpc83?tCjYnIHn%Dt7MM+PqaLDtI-eq(mU93=c6s5!SDP?M1vE!K@WIhn6qF(%vq z-8+l*Uu%af!&9$(8fV$_zJ!d{A={QhaCB8ubI|u0PSdvT{ujfF8<4JFw2(wry zfehbJ+DF?z%F=}kBh(FH2$adY9dEn>kDFwj3rZN;W2TMWh;t}QqJ8!)(JgZ zO}FzBNA7lD%#n*q_IcI0>`6@fWAg&{Sz_ILnwA_PP=nI>=7})aAYuZ>e7%V)DK(?h z!CV&2CVduk)^PB+44_kX9`!@7zy;U?chkkq?VEHM11GV;5U4fg}J#HF;a||#J(*f zXja!9AU8E}GimKZ)pR>DofD-68k`W0UA2J;?wl^E+=@tG_P)HkUfCL=_WJvhcz=KY zWIhfJV~d{_ns;`Y;w-}0*&4wE*98SfM+?ZX&^H-Z6Q$|=XRUtI_gTn~Nqwc$7%p}W zADL@-x|!KTjalJcgK(h&W8YqBb1)%m+VUnkEiOMsm}r>jeqo*nZmF!(;5KlMl3EI! z9bH&v1AGRF}~8WJ^I-b~sxiYHLU|4DY^= zySmw7HS%SKXKaP38nUjij#ib!WH_-@T28ZSC*=TL?8%dZ@x#~a<<*(3RYmlYczm~&5F zo$fZ@a*aZn-5u^@pM~LZ*kzZ}=T%ij55UYFK*U_o-HGJ3Hy(tqx;GBmm}@X^+SZ#7 zy&x-e(PQ<|-Qu0r)=sAFl|+xGPSR5JMa#t6FnM8DSzYDvef!8p&oP;^0(CV!F?x(hZ$)GmmXddxx)ei zg~+bl!#PrZQ|_24F22iO9-}iFF>)7A4R1+5$HvNbGGJpjsjFwtan_eUVJ?43pC6;y z1?}H>URMY|v&6)l=+=G>_eYDxomk4TO%L12pbYwCXqGefMVdTn+1(cnfR!0mhHeOn zq#||d@^}QjYlj}5o~(q0GWc?Yaw-@anu$PKe(a#4cVq;1_t8IZp;|iy&2c>t~B;7PD^1?Nu9Ks8e8*|XO9y&(#j4mjhN!`8i~?I zj(*zb9DI3|^WKkU%;ih&TRP8k65pR1miMn)=ul)~=v??(KuP|JuouYIYqy9ZU_2KB zPhGQlOF)3nu6jEM4zC)AKcVL4xvo*D6P;}tR7p-eth74zR>(n_pwEs-dV>qM?kTVT z69kl-^A&GBZdJt4WX3GEkquUAN=j28+l|-u^C!bM(kIAY0EX$gAmJG;$*H-CN8;l~z<;k9Ugx?R z@sEH-ZIBVl{=OBTEvbaj!dDdt2YsNX&J>$2)zLDPcu3g4p%K!)97fhV?b9Ql4pWl}NlT-57XtiO2D&M##eqL4W$`UG1DC#vjd$Pa=)*MY z6S$xqUr}OYPsnjLa<|Q3%BC+ihLX&jS`k2=G|MG>iHOZ(xycn40rD=(o(OmhWB|e=S)%vC>D0w`kxhF&zZfI}m^0jE z``?x%iOkKY+*%BGzS=L-KtBfx`*9@TYSecbd^+lz%XQC{Uc#iEt^ipXFidmYSFOz_ zd=p&P%wlh*4qgdNmRQC%#>UdC$)+$5uB0Bj-EOJTDezSPCYLfWNSJO{3G8ENSEJra)|6I3&0zk(4K9dtP%M{=@%qUyBpMuWvo~+}z{Oj;Oje6Tw-e0hgKx0$IxeNXMPv&DCIKr7_`7xtk2m^^5}fO#8U>L4p9m zSfNVM$>e7t69pN-&WIwHd4-6`4AvHMTwq{eZ7ndV;O(~iyXz78OJMNyOW%t>o-W_W z9pLpoh5Wz}QP{>+P%FEaMGZ1~=HCy%>-3iKW}6XCW~<+m_gl^{6_NVO)&9WYR|Bsg zKm4-{v(6iz0)b^;!Ybf}fQdpNr!l|}IriRraWE)Iq6-En(qVC5gOUuy>%mcXV~9Q$ zWe~uk5nsJxdRXz0(`4X87P@JE^1#nS4_MWFWo}cX&9hpIA%(F@+ICxao^HaVF?iGu zbPS)wv;7GsYQIYj4h}Q7-e>$c!865F7=c^p=Si#nk`Gz5^xWp%H_kc5EEHLtp5tC1 zO(>rOe3-d#C?St|xHJSwgiUa!S!Dw0mBiD1Lm(*o3$jSt8x(@y++3s^hy*fj2urWd`vgK{ zP0X+V1;Dn*h`~zNHRfIhvdXB0-0J@TE>|av`jX&%{r_|HH}iZmIEQrl_It-Av!pAd z2zCVJi%)oT#6Fw_)nVR6=L<3*#W)%WflLYkT$ZqPSun}aYmZwZ;R5yD@L0a&`ayr|J8%73b`T_EU%VZO1*g{+{aga+|B$6>hiEZoXaJ)g%y8 zN|23_B7*AuHJlbhSmWE?ZZqi*S{p9LzwcYrqtMlsf3p3^=hZ}Vz4Z$Hb!iG^*b6^F zc6c%?f6}q8&`rIAB+#2-z+*nQvF1a98l|KO%^GDlE zQJed_TVE^-E7Wc&QVi@RPT?@|@hr-q!eY=xj$bApWbU}r%n;r;=WMxr!1Qm!7^g|RuzD#F|axqXb z9yiK*-u32dMthG~%>jp(xHX%jX}FaNDjEHNgbcm$TqCDeg>A;G^w(fJc+T}QC0SYT z87Xy1fcWQM(Lbb$R7(o2fd$ETfbVTYbuYckp~nEbNDFD-MA8XA^U8Lu%N^Io_qOGzJ0$Mz*S~*@U&#skj*dR;;QXHQDH%+@26plNKdyTs z;Rp`q?W*K7Sg$UV=5cbk(Q4w^T9OdIzwgXCsKVt#SNy68B`ZE#9?B|jqMA9-3 z13BdxND&tdJ3^nYR34{`KGWDvC}*r!r6U?n=^S@UGs|idiwX_`+r_I_uMmTcUN67) zWf*K~pH92Vvo2ORuy|G&9osk#V%RH$Gux8~e-tkolrvneTTbvO&9Ajc%Uo!oLDCzg~D=pOS#q}{XpIQ}(Ru)N+L0a4J+yoRm%l%cNK5CU`U*aB) z94rPoziBH{)8JRVvF2+^5?0nFaFHSntS$pkodV&&3vXv>n(*%0E) zz8HlOPXAz1H4&%H{>ah-Yjx+5)IaaenQ#241TB~E6oY{JE`OMpCAwalfxFB0fHV9^fc?tJ@5vXnl6g6e99Kg7^TvJki3r$Aj}gTBe^u{ieb$S{t=?ts$+%y3}7oG zgWzH5)}tjhn+diFeoW=!l#$fYDm7jl1nbkE?#^I_hPpBcuKHVnIIdC6$uvt32?0PF zPpnVW|I9t9<@%$dBa`bSK(7o9w3}du!hlfT?MM>KUG8a%i{Skwurr^GZPiz0^>3Zg7i5n2{a*RR*$ZvGtubKWDi z@C8UrKYhx`%YOKsC}W3sY+dE3Zy4@-9v!z}a&z6!gqE~=&FA*{(|z*~yK@=)S(6yT zNI?29g+8gu_J3b?)g14#yBoG@?AoWL-CJyywI003Ms$`&c&UHmw9nmRT*Uh`Fi^wl zF;YCBH1T(whCXBIsyT(T?_PUVQd_mvoNjY2=F#ww_J#cDb^3c)-qpkeaZGA#Y>ZK% zKoN5!aV=5>1fGW|l9;(O=3w!!O4<$UpONN_m=(Y*b9|IGLd5OD5SG_KM#jv1eDFk! zL{B(cEGXz`X%UVTK`>b(EpYZjC3ricafFcRje6n}NP6s>OdDsEC3nV2$Oz&n14t#) zK7^kjxV>DDk~=y2ZP*hEpDoW49i5s|p+`k=Tlk?+_A17un4EAQ7 z^?KZWVkFjm8v;?WPUEx>73)*bL50=f;fG%-2P+lg@XS?znu*FR#D6?V0XlsQCC(~E zE*=rFn+$yF@W&`Js54fK{?0{iz!4u&}$etK?TggbV*W1rO#Dg{J$^d^BnQvR078T+iU*+ zn2G;`OK4=R!PFoPO9%@n|9Uam(XK;STa<^Lw0bUU#BBfJ z!hL!2`>BywJEC*B!abGji{ZGG`H|MdvVp9o(?_c?6x9^rkWn>a&=6K8923yPN$+`j zyQof1%Z@rdpES>Vd6o$e7_)jfOjWu$oVcHxc;Ixq-+9t>SE~u}sGuS`3%;;*@jkXM zuSkBslUK^7nTVaoCqZ9&y1qk~>sKPNKK8dtZIjOE?ADtofOgQrh@#D1At%ngT2S$g z=T(xdM3K<7hm%BlzFVdL*NQ3=9o_p56@gQ^!r7PA+w)^A8|!Ny$Uv{))RbVQ@Xlko z${1fXqv45(vBglG#6*j^!CB7APXxE-C%C(Zmosnd{oV3XU+-&Tt>`TbK}UzxUlM2b z>IvTRs#ii!)7!Y9H(V8FzrL=ppO{S;w$-7a(2}S0(S^*j=LP3sFLV|kULW6uVq*wm z1-c6@AYVD1)S{iq!fEOLo_}*7+-v(FCKn z??cZHekwW!N@`r#9QVe?l9NwAGW7C1WBL%hbV*HAo4&uCxG=3vMD-!@%tnIOg9HYf zFk0clu$XC1$oUGGRetDP&Fb?CL?1efN`d`m4BzzRMX-{}}R+e;E5&~{{yly(o%{YGA0)a1RxMb`eEKo&2#flDjDCm)3)&a{Jow*y8^^Z6KW(x^287($` zXdd-UFSmG}_wd~9d%8#7{SLj}=|NxMGO|v&n=1(?+BbsntV|Q%9M^jb2fpm^mCDiK z)8$^b5!9VL3+DP zn8XMr=xN=Kn$KZVH1x)HHybTp^s4H6We7YPYm`zeUbmJ?!_B=p&D|94n_)0O2KMKFQ9EVblkW)|A2h^YB>m<=(f1o-7R*>Bb=O=LvExiDElQ&+RFeI z_#MpPe3@2yx3f`+Vx(1c6bjLtoM`8{-YJevJ|cD^_d+bgLF>=<|;C13t5}=ec+de<^Z5b+|O{_V9 zukJA#SgvfSzLr@yw-!`H6dqSD(Q0nDS~jV(UtD2Vf{~v09d4>VxvFMxzx8$6xiDd5K7uo<~Q?(bK!HeCavgXBth4NznK}BbUB>@;PMf zFTr4Rm!BYq>zV~i(b1P3OzYq=JtW#2qp857jD3|og&4QSrD_3vPJ}Z@nmPxbjCYsQ zF&HDI0$+3zV{}mE=%in@#vjbrhUkb^yVdP8&Sw!B9n1_cp0`&fFXr2qp9y{EB-Q>g zJmh9azOeel+X8MB8zmD$nh*ywI@ zZ7Cc?6ynIpwZueuERpgi^0crKi9Q^li8nAf{R8}y^hffysYAQo%v7+XnuiwB zX7d)l7VT|G1z$Jw(lq_g4%s|!`6MOGG8QM>l7U3=+myMw!Cx8QZucdU56aOQ%+4h+zAUIME62O|q7>JBZaL(vri*%CW)N05sRb`Ze$r^$ zsEeXuV!hOCuljp-qGz~NL~e7@-uYmOBkI#kk(Ht15MinNkF%{FyO%rdaxE9X@1|ZD zH41!!_pK$N5+=*6J0qHBv;1l_WZ7Kd{Lci=_bJMH+~XMjh0LWw<+7FQ%taX~gV5Mk z#wyJ2ZzYaq#8N62g0+qd-PQ|h4Cv&e9%1NuxAf8T7V18rS|ZIJumhMSIEa z&^4e&8Ui>(Gwch+EpL)2FnVss7Bt}7y9#pClzzYOGA2ZpZ#CzeZ<={qx_3U!I&0PS zZtfHkpc`R{P%mXsK3C|?wgX)fqhCpqViRpyIn6x(x;b{~864djMkrpnH{T!F=kRLF zIOc99q7`G1%khk*XqmB6mT5YRXq9z)*8lSN!IM0xL?=WPw^oTh(Be!u{;9LW=2VJv z9!jcrNahNJg*ClhC-rcp(DBf72D*4LbUo6d;mRo_%P12+o>_dul#qE-h%ivCUMd7jkh;6w%oMbhsrzc!j_%)%Gb zI#O}+)7Dn*{^gtmYVbX+5T3#ti`sOP zzNovNBayQ^>qb4Rwr3{u-JNC=#%(!H44oQXvb`|l(#!H(*-`0%2mI=>kf|{wzRNXfqADr z^Y<>l@(7jr1xd@6`}V{{7cClUzQgW#9Cx=#;c`5b>~8(x+NSNrLG1-|V}D6}$ntfs zTJq{>>-W5Q+iw#F>xq{`av=`24){l9@Ly;M^}@NX!ugg9i3Srp9Vl!~K3@b-3J;iv zG)Jyrh%3y7Nm`n2AzTwj!Fn52Y%{4IDmraeglu_gV`>7bswJCzrj0JHwR=#1Y3|rh z=R3_8degtJCZeuR&C84vIh~W;r;WB-zvEPw{0*rrZxy}$UGwI)>rKz(4@E)hOH0M! zl+6%FKIZ<8ipvWcLLs3&Y)P_=J!(R)kuPa-Fv}!sI-7rPht4(Td|4(~U1N3oE!Csa|1aJnJaUhQB81!Lr&2u4e{^ft zlM@!l>u--H+wf;jW=``D=@zE>w))sDUX-Dye`?QbbQ^`rulx@Buam?t?EmG_<^LdH i|KB?!{a^W@dxYQf69oKf&)FZ&RuUqz!li=xU;i7QV55Ej diff --git a/doc/sphinx/source/recipes/recipe_droughts.rst b/doc/sphinx/source/recipes/recipe_droughts.rst index a03d0e8096..7f00dc5afa 100644 --- a/doc/sphinx/source/recipes/recipe_droughts.rst +++ b/doc/sphinx/source/recipes/recipe_droughts.rst @@ -40,19 +40,18 @@ Recipes: * :ref:`droughts/recipe_cdd.yml ` * :ref:`droughts/recipe_spei.yml ` -* :ref:`recipe_martin18grl.yml ` +* :ref:`droughts/recipe_martin18grl.yml ` -Diagnostics are stored in ``diag_scripts/droughts/``. General index -calculattions are done by: +Diagnostics are stored in ``diag_scripts/droughts/``. An incomplete list of +diagnostics that are used in different recipes is shown below. Some recipes +might use additional diagnostics. See the corresponding recipe documentation +for more details.: * cdd.py: calculate Consecutive Dry Days * pet.R: calculate Potential Evapo-Transpiration * spei.R: calculate Standardized Evapo-Transpiration Index -The recipes might use additional diagnostics, see the corresponding recipe -documentation for more details. - User settings ------------- diff --git a/doc/sphinx/source/recipes/recipe_spei.rst b/doc/sphinx/source/recipes/recipe_spei.rst index ff8a2b8e59..c1b3f481f4 100644 --- a/doc/sphinx/source/recipes/recipe_spei.rst +++ b/doc/sphinx/source/recipes/recipe_spei.rst @@ -14,37 +14,75 @@ Meteorological droughts are often described using the standardized precipitation A hydrological drought occurs when low water supply becomes evident, especially in streams, reservoirs, and groundwater levels, usually after extended periods of meteorological drought. GCMs normally do not simulate hydrological processes in sufficient detail to give deeper insights into hydrological drought processes. Neither do they properly describe agricultural droughts, when crops become affected by the hydrological drought. However, hydrological drought can be estimated by accounting for evapotranspiration, and thereby estimate the surface retention of water. The standardized precipitation-evapotranspiration index (SPEI; Vicente-Serrano et al., 2010) has been developed to also account for temperature effects on the surface water fluxes. Evapotranspiration is not normally calculated in GCMs, so SPEI often takes other inputs to estimate the evapotranspiration. Here, the Thornthwaite (Thornthwaite, 1948) method based on temperature is applied. + Available recipes and diagnostics --------------------------------- -Recipes are stored in recipes/ - - * recipe_spei.yml - +Recipes are stored in ``recipes/droughts/`` -Diagnostics are stored in diag_scripts/droughtindex/ +* recipe_spei.yml - * diag_spi.R: calculate the SPI index - * diag_spei.R: calculate the SPEI index +Diagnostics are stored in ``diag_scripts/droughts/`` +* pet.R: calculate the PET as input variable for SPEI +* spei.R: calculate the SPI and SPEI index User settings ------------- -#. Script diag_spi.py +pet.R +~~~~~ + +pet_type: str + The method used to calculate the potential evapotranspiration. + Some settings and required variables depend on the calculation method. + Options are: Penman, Thornthwaite, Hargreaves + + +spei.R +~~~~~~ + + Calculating the SPI is a specialcase of the SPEI. Provide only precipitation + as input variable or ancestor and set ``distribution="Gamma"``, to calculate + SPI. + + +smooth_month: int + The number of months (scale) to accumulate. Common choices are 3, 6, 9, 12. + +write_coeffs: boolean, optional + Save fitting coefficients. + By default FALSE. - *Required settings (script)* +write_wb: boolean, optional + Write water balance to netcdf file. + By default FALSE. - * reference_dataset: dataset_name - The reference data set acts as a baseline for calculating model bias. +short_name_pet: string, optional + Short name of the variable to use as PET. + By default "evspsblpot" -#. Script diag_spei.py +distributionn: string, optional + Type of distribution used for SPEI calibration. + Possible options are: "Gamma", "log-Logistic", "Pearson III". + By default "log-Logistic". - *Required settings (script)* +refstart_year: int, optional + First of reference period. + By default first year of time series - * reference_dataset: dataset_name - The reference data set acts as a baseline for calculating model bias. +refstart_month: int, optional + first month of reference period. + By default 1. + +refend_year: int, optional + Last year of the reference period. + By default last year of time series. + +refend_month``: integer, optional + Last month of reference period. + By default 12. Variables @@ -53,27 +91,15 @@ Variables * pr (atmos, monthly mean, time latitude longitude) * tas (atmos, monthly mean, time latitude longitude) +Which variables are required or used for index calculation depends on the index +and calculation method of the PET. The Thornthwaite method requires tas, while +the Hargreaves method requires tas and sfcWind. The Penman method requires tas, +sfcWind, rsds, clt, hurs, and ps, but it is possible to approximate some of the +variables if not available. + References ---------- * McKee, T. B., Doesken, N. J., & Kleist, J. (1993). The relationship of drought frequency and duration to time scales. In Proceedings of the 8th Conference on Applied Climatology (Vol. 17, No. 22, pp. 179-183). Boston, MA: American Meteorological Society. * Vicente-Serrano, S. M., Beguería, S., & López-Moreno, J. I. (2010). A multiscalar drought index sensitive to global warming: the standardized precipitation evapotranspiration index. Journal of climate, 23(7), 1696-1718. - - -Example plots -------------- - -.. _fig_spei: -.. figure:: /recipes/figures/spei/histogram_spei.png - :align: center - :width: 14cm - - (top) Probability distribution of the standardized precipitation-evapotranspiration index of a sub-set of the CMIP5 models, and (bottom) bias relative to the CRU reference data set. - -.. _fig_spi: -.. figure:: /recipes/figures/spei/histogram_spi.png - :align: center - :width: 14cm - - (top) Probability distribution of the standardized precipitation index of a sub-set of the CMIP5 models, and (bottom) bias relative to the CRU reference data set. diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index b049a6a80f..c2dc9f9cde 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -23,8 +23,7 @@ Dataset name to use for comparison (excluded from MMM). With ``compare_intervals=True`` this option has no effect. threshold: float, optional (default: -2.0) - Threshold for binary classifiaction of a drought. - Not yet implemented. + Threshold for an event to be considered as drought. compare_intervals: bool, false If true, begin and end of the time periods are compared instead of models and reference. The lengths of begin and end period is given by From 08b85be748b2d42d9d94083690ca377929bdfade Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 12 Feb 2025 15:16:02 +0100 Subject: [PATCH 13/66] update docs martin18grl --- .../source/recipes/recipe_martin18grl.rst | 47 +++++++++---------- doc/sphinx/source/recipes/recipe_spei.rst | 2 +- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/doc/sphinx/source/recipes/recipe_martin18grl.rst b/doc/sphinx/source/recipes/recipe_martin18grl.rst index 6496afeb55..9c21142e5c 100644 --- a/doc/sphinx/source/recipes/recipe_martin18grl.rst +++ b/doc/sphinx/source/recipes/recipe_martin18grl.rst @@ -7,7 +7,10 @@ Overview -------- -Following `Martin (2018)`_ drought characteristics are calculated based on the standard precipitation index (SPI), see `Mckee et al. (1993)`_. These characteristics are frequency, average duration, SPI index and severity index of drought events. +Following `Martin (2018)`_ drought characteristics are calculated based on the +standard precipitation index (SPI), see `Mckee et al. (1993)`_. +These characteristics are frequency, average duration, SPI index and severity +index of drought events. .. _`Martin (2018)`: https://agupubs.onlinelibrary.wiley.com/doi/abs/10.1029/2018GL079807 .. _`Mckee et al. (1993)`: https://www.nature.com/articles/nclimate3387 @@ -16,42 +19,36 @@ Following `Martin (2018)`_ drought characteristics are calculated based on the s Available recipes and diagnostics --------------------------------- -Recipes are stored in recipes/ +Recipes are stored in ``recipes/droughts/`` * recipe_martin18grl.yml +Diagnostics are stored in ``diag_scripts/droughts/`` -Diagnostics are stored in diag_scripts/ - - * droughtindex/diag_save_spi.R - * droughtindex/collect_drought_obs_multi.py - * droughtindex/collect_drought_model.py - * droughtindex/collect_drought_func.py + * :ref:`droughts/spei.R ` + * :ref:`droughts/collect_drought.py ` +Functions for metric calculation, plots and utility can be found in +``droughts/collect_drought_func.py``. User settings in recipe ----------------------- -The recipe can be run with different CMIP5 and CMIP6 models and one observational or reanalysis data set. - -The droughtindex/diag_save_spi.R script calculates the SPI index for any given time series. It is based on droughtindex/diag_spi.R but saves the SPI index and does not plot the histogram. The distribution and the representative time scale (smooth_month) can be set by the user, the values used in Martin (2018) are smooth_month: 6 and distribution: 'Gamma' for SPI. - -There are two python diagnostics, which can use the SPI data to calculate the drought characteristics (frequency, average duration, SPI index and severity index of drought events) based on Martin (2018): +The recipe calculates SPI for two different time periods. The first part +compares CMIP5 models with the CRU observational dataset. The second part +compares the last 50 years of the 21st century with the historical period +(1950-2000) for the RCP8.5 scenario. -* To compare these characteristics between model data and observations or renanalysis data use droughtindex/collect_drought_obs_multi.py - Here, the user can set: - * indexname: Necessary to identify data produced by droughtindex/diag_save_spi.R as well as write captions and filenames. At the moment only indexname: 'SPI' is supported. - * threshold: Threshold for this index below which an event is considered to be a drought, the setting for SPI should be usually threshold: -2.0 but any other value will be accepted. Values should not be < - 3.0 or > 3.0 for SPI (else it will identify none/always drought conditions). +The recipe can be run with different CMIP5 and CMIP6 models and one +observational or reanalysis data set. The latter is specified as +``reference_dataset`` in the recipe. -* To compare these ccharacteristics between different time periods in model data use droughtindex/collect_drought_model.py - Here, the user can set: - * indexname: Necessary to identify data produced by droughtindex/diag_save_spi.R as well as write captions and filenames. At the moment only indexname: 'SPI' is supported. - * threshold: Threshold for this index below which an event is considered to be a drought, the setting for SPI should be usually threshold: -2.0 but any other value will be accepted. Values should not be < - 3.0 or > 3.0 for SPI (else it will identify none/always drought conditions). - * start_year: Needs to be equal or larger than the start_year for droughtindex/diag_save_spi.R. - * end_year: Needs to be equal or smaller than the end_year for droughtindex/diag_save_spi.R. - * comparison_period: should be < (end_year - start_year)/2 to have non overlapping time series in the comparison. +The distribution (``distribution: Gamma``) and the representative time scale +(``smooth_month: 6``) can be changed by the user too. A complete list of +settings and their description can be found in the +:ref:`SPEI recipe ` and +:ref:`Collect drought API documentation `. -The third diagnostic droughtindex/collect_drought_func.py contains functions both ones above use. Variables --------- diff --git a/doc/sphinx/source/recipes/recipe_spei.rst b/doc/sphinx/source/recipes/recipe_spei.rst index c1b3f481f4..2e80424569 100644 --- a/doc/sphinx/source/recipes/recipe_spei.rst +++ b/doc/sphinx/source/recipes/recipe_spei.rst @@ -43,7 +43,7 @@ pet_type: str spei.R ~~~~~~ - Calculating the SPI is a specialcase of the SPEI. Provide only precipitation + SPI is considered as a special case of the SPEI. Provide only precipitation as input variable or ancestor and set ``distribution="Gamma"``, to calculate SPI. From 0bc32e3682b310caec5cab2bd20782ffec76d178 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 12 Feb 2025 16:20:19 +0100 Subject: [PATCH 14/66] update cdd recipe docs --- .../source/recipes/recipe_consecdrydays.rst | 32 +++++++++++-------- .../source/recipes/recipe_martin18grl.rst | 1 - 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/doc/sphinx/source/recipes/recipe_consecdrydays.rst b/doc/sphinx/source/recipes/recipe_consecdrydays.rst index 9701ebc2b2..bbbcfd4ffa 100644 --- a/doc/sphinx/source/recipes/recipe_consecdrydays.rst +++ b/doc/sphinx/source/recipes/recipe_consecdrydays.rst @@ -21,32 +21,35 @@ Available recipes and diagnostics Recipes are stored in recipes/droughts/ - * recipe_cdd.yml +* recipe_cdd.yml Diagnostics are stored in diag_scripts/droughts/ - * cdd.py: calculates the longest period of consecutive dry days, and - the frequency of dry day periods longer than a user defined length +* cdd.py: calculates the longest period of consecutive dry days, and + the frequency of dry day periods longer than a user defined length User settings in recipe ----------------------- -#. Script diag_cdd.py +Script diag_cdd.py +~~~~~~~~~~~~~~~~~~ - *Required settings (script)* +plim: float + limit for a day to be considered dry [mm/day] - * plim: limit for a day to be considered dry [mm/day] +frlim: int + the shortest number of consecutive dry days for entering statistic on + frequency of dry periods. - * frlim: the shortest number of consecutive dry days for entering statistic on frequency of dry periods. +Under ``plot``: - *Optional settings (script)* +cmap: str, optional + the name of a colormap. cmocean colormaps are also supported. - Under ``plot``: - - * cmap: the name of a colormap. cmocean colormaps are also supported. - - * other keyword arguments to :func:`esmvaltool.diag_scripts.shared.plot.global_pcolormesh` can also be supplied. +other keyword arguments to +:func:`esmvaltool.diag_scripts.shared.plot.global_pcolormesh` can also be +supplied. Variables --------- @@ -62,4 +65,5 @@ Example plots :align: center :width: 14cm - Example of the number of occurrences with consecutive dry days of more than five days in the period 2001 to 2002 for the CMIP5 model bcc-csm1-1-m. + Example of the number of occurrences with consecutive dry days of more than + five days in the period 2001 to 2002 for the CMIP5 model bcc-csm1-1-m. diff --git a/doc/sphinx/source/recipes/recipe_martin18grl.rst b/doc/sphinx/source/recipes/recipe_martin18grl.rst index 9c21142e5c..86f3d9452e 100644 --- a/doc/sphinx/source/recipes/recipe_martin18grl.rst +++ b/doc/sphinx/source/recipes/recipe_martin18grl.rst @@ -6,7 +6,6 @@ Drought characteristics following Martin (2018) Overview -------- - Following `Martin (2018)`_ drought characteristics are calculated based on the standard precipitation index (SPI), see `Mckee et al. (1993)`_. These characteristics are frequency, average duration, SPI index and severity From 85179abb7a530b9e000407052e46acc72d5935a1 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Thu, 13 Feb 2025 11:46:50 +0100 Subject: [PATCH 15/66] fix caption indentation in docs --- doc/sphinx/source/recipes/recipe_consecdrydays.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/doc/sphinx/source/recipes/recipe_consecdrydays.rst b/doc/sphinx/source/recipes/recipe_consecdrydays.rst index bbbcfd4ffa..26a2b7ea11 100644 --- a/doc/sphinx/source/recipes/recipe_consecdrydays.rst +++ b/doc/sphinx/source/recipes/recipe_consecdrydays.rst @@ -64,6 +64,5 @@ Example plots .. figure:: /recipes/figures/consecdrydays/consec_example_freq.png :align: center :width: 14cm - - Example of the number of occurrences with consecutive dry days of more than - five days in the period 2001 to 2002 for the CMIP5 model bcc-csm1-1-m. + + Example of the number of occurrences with consecutive dry days of more than five days in the period 2001 to 2002 for the CMIP5 model bcc-csm1-1-m. From 52f13b1fd9fcca3d51834bb1dfd581f87c639498 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 14 Feb 2025 10:52:08 +0100 Subject: [PATCH 16/66] fix time slicing based on comparison_period --- esmvaltool/diag_scripts/droughts/collect_drought.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index c2dc9f9cde..ee2c873d9a 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -66,22 +66,16 @@ def _plot_future_vs_past(cfg, cube, slices, fnames): def _set_tscube(cfg, cube, time, tstype): """Time slice from a cube with start/end given by cfg.""" - print("sett time slice") if tstype == 'Future': - print("future") - start_year = cfg["end_year"] - cfg["comparison_period"] + start_year = cfg["end_year"] - cfg["comparison_period"] + 1 start = dt.datetime(start_year , 1, 15, 0, 0, 0) end = dt.datetime(cfg['end_year'], 12, 16, 0, 0, 0) elif tstype == 'Historic': - print("historic") start = dt.datetime(cfg['start_year'], 1, 15, 0, 0, 0) - end_year = cfg["start_year"] + cfg["comparison_period"] + end_year = cfg["start_year"] + cfg["comparison_period"] - 1 end = dt.datetime(end_year, 12, 16, 0, 0, 0) - print(start, end) stime = time.nearest_neighbour_index(time.units.date2num(start)) etime = time.nearest_neighbour_index(time.units.date2num(end)) - print(stime, etime) - print(cube) tscube = cube[stime:etime, :, :] return tscube From 5a207e90db25897148773346a838efb2edb3e85c Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 18 Feb 2025 15:58:05 +0100 Subject: [PATCH 17/66] make individual model plots optional --- esmvaltool/diag_scripts/droughts/collect_drought.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index ee2c873d9a..33bb9dd3c1 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -32,6 +32,8 @@ Number of years from begin and end of the full period to be compared. Should be < (end_year - start_year)/2. If ``compare_intervals=False`` this option has no effect. +plot_models: bool, false + Save plots for each individual model, in addition to multi-model mean. start_year: int This option is used to select the time slices for comparison if ``compare_intervals=True``. end_year: int @@ -101,7 +103,9 @@ def main(cfg): ts_cube = _set_tscube(cfg, cube, cube.coord('time'), tstype) drought_show = _get_drought_data(cfg, ts_cube) drought_slices[tstype].append(drought_show.data) - _plot_single_maps(cfg, cube_mean, drought_show, tstype, fname) + if cfg.get("plot_models", False): + _plot_single_maps( + cfg, cube_mean, drought_show, tstype, fname) else: # calculate and plot metrics per dataset drought_show = _get_drought_data(cfg, cube) @@ -109,7 +113,9 @@ def main(cfg): ref_data = drought_show.data else: drought_data.append(drought_show.data) - _plot_single_maps(cfg, cube_mean, drought_show, 'Historic', fname) + if cfg.get("plot_models", False): + _plot_single_maps( + cfg, cube_mean, drought_show, 'Historic', fname) if cfg.get("compare_intervals", False): # calculate multi model mean for time slices From 3ba2520d83707f42384223f3017aadb1a0bd1e72 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 18 Feb 2025 15:59:09 +0100 Subject: [PATCH 18/66] add PET documentation --- doc/sphinx/source/recipes/recipe_droughts.rst | 25 +++++- doc/sphinx/source/recipes/recipe_spei.rst | 76 +++++++++++++++---- esmvaltool/diag_scripts/droughts/pet.R | 22 ++++-- 3 files changed, 97 insertions(+), 26 deletions(-) diff --git a/doc/sphinx/source/recipes/recipe_droughts.rst b/doc/sphinx/source/recipes/recipe_droughts.rst index 7f00dc5afa..4a97d14fe2 100644 --- a/doc/sphinx/source/recipes/recipe_droughts.rst +++ b/doc/sphinx/source/recipes/recipe_droughts.rst @@ -14,6 +14,24 @@ period frequency of dry days based on user defined thresholds. More details and usage examples can be found in the :ref:`CDD recipe documentation `. + +Potential or Reference Evapo-Transpiration (PET, ET0) +----------------------------------------------------- + +The Potential Evapo-Transpiration (PET) is a measure of the evaporative demand +of the atmosphere. It represents the amount of water, that would evaporate from +a reference surface, i.e. an fully watered alfalfa field. ``pet.R`` is able to +calculate PET based on a method of users choice (``pet_type``). The methods +require different input variables for example: + +- Penman: tas, tasmin, tasmax, sfcWind, rsds, clt, hurs, ps +- Hargreaves: tas, tasmin, tasmax, (rsds, pr) +- Thornthwaite: tas + +A complete list and more details can be found in +:ref:`SPEI recipe documentation `. + + Standardized Precipitation-Evapotranspiration Index (SPEI) ---------------------------------------------------------- @@ -38,10 +56,9 @@ Available recipes and diagnostics Recipes: -* :ref:`droughts/recipe_cdd.yml ` -* :ref:`droughts/recipe_spei.yml ` -* :ref:`droughts/recipe_martin18grl.yml ` - +* :ref:`recipes_consecdrydays` +* :ref:`recipes_spei` +* :ref:`recipes_martin18grl` Diagnostics are stored in ``diag_scripts/droughts/``. An incomplete list of diagnostics that are used in different recipes is shown below. Some recipes diff --git a/doc/sphinx/source/recipes/recipe_spei.rst b/doc/sphinx/source/recipes/recipe_spei.rst index 2e80424569..5aea912496 100644 --- a/doc/sphinx/source/recipes/recipe_spei.rst +++ b/doc/sphinx/source/recipes/recipe_spei.rst @@ -13,40 +13,76 @@ Meteorological droughts are often described using the standardized precipitation A hydrological drought occurs when low water supply becomes evident, especially in streams, reservoirs, and groundwater levels, usually after extended periods of meteorological drought. GCMs normally do not simulate hydrological processes in sufficient detail to give deeper insights into hydrological drought processes. Neither do they properly describe agricultural droughts, when crops become affected by the hydrological drought. However, hydrological drought can be estimated by accounting for evapotranspiration, and thereby estimate the surface retention of water. The standardized precipitation-evapotranspiration index (SPEI; Vicente-Serrano et al., 2010) has been developed to also account for temperature effects on the surface water fluxes. Evapotranspiration is not normally calculated in GCMs, so SPEI often takes other inputs to estimate the evapotranspiration. Here, the Thornthwaite (Thornthwaite, 1948) method based on temperature is applied. +This page documents a set of R diagnostics based on the +`SPEI.R library `_. +``recipes/roughts/recipe_spei.yml`` is an example how to calculate and plot +SPEI using ``diag_scripts/droughts/pet.R`` and ``diag_scripts/droughts/spei.R``. -Available recipes and diagnostics ---------------------------------- -Recipes are stored in ``recipes/droughts/`` - -* recipe_spei.yml - - -Diagnostics are stored in ``diag_scripts/droughts/`` +pet.R +----- + +The Potential Evapo-Transpiration (PET) is a measure of the evaporative demand +of the atmosphere. It represents the amount of water, that would evaporate from +a reference surface, i.e. an fully watered alfalfa field. ``pet.R`` is able to +calculate PET based on a method of users choice (``pet_type``) using the +`SPEI.R package `_. The +approximations require different input variables. To controll which variables +are available to which diagnostic script in a more complex recipe they can be +set explicitly as ancestors. + +- Thornthwaite: tas +- Hargreaves: tasmin, tasmax, rsdt, (pr) +- Penman: tasmin, tasmax, sfcWind, ps, rsds, (rsdt, clt, hurs) + +The Thornthwaite equation (Thornthwaite, 1948) is the simplest one based solely +on temperature. Hargreaves (1994) provides an equation based on daily minimum +(tasmin) and maximum temperature (tasmax) and external radiation (rsdt). +If precipitation data (pr) is provided and `use_pr: TRUE` it will be used as a +proxy for irradation to correct PET following Droogers and Allen (2002). +The Penman-Monteith formular additionally considers surface windspeed (sfcWind), +pressure (ps), and relative humidity (hurs). Some of these variables can be +approximated if not available (for example by providing clt instaed of rsds). +There are further modifications to the Penman-Monteith equation, that can be +selected using the ``method`` key for ``pet_type: Penman``. Details about +the different method can be found in the SPEI.R package documentation +Beguería and Vicente-Serrano (2011). -* pet.R: calculate the PET as input variable for SPEI -* spei.R: calculate the SPI and SPEI index User settings -------------- - -pet.R -~~~~~ +~~~~~~~~~~~~~ pet_type: str The method used to calculate the potential evapotranspiration. Some settings and required variables depend on the calculation method. Options are: Penman, Thornthwaite, Hargreaves +use_pr: boolean, optional + Use precipitation as proxy for irradation to correct PET. Only used for + `pet_type: Hargreaves`. + By default FALSE. + +method: str, optional + Method used for PET calculation. Only used for `pet_type: Penman`. + Options are: ICID, FAO, ASCE. + By default: ICID + +crop: str, optional + Crop type for PET calculation. Only used for `pet_type: Penman`. + Options are: short, tall. + By default: tall + spei.R -~~~~~~ +------ SPI is considered as a special case of the SPEI. Provide only precipitation as input variable or ancestor and set ``distribution="Gamma"``, to calculate SPI. +User settings +~~~~~~~~~~~~~ smooth_month: int The number of months (scale) to accumulate. Common choices are 3, 6, 9, 12. @@ -103,3 +139,13 @@ References * McKee, T. B., Doesken, N. J., & Kleist, J. (1993). The relationship of drought frequency and duration to time scales. In Proceedings of the 8th Conference on Applied Climatology (Vol. 17, No. 22, pp. 179-183). Boston, MA: American Meteorological Society. * Vicente-Serrano, S. M., Beguería, S., & López-Moreno, J. I. (2010). A multiscalar drought index sensitive to global warming: the standardized precipitation evapotranspiration index. Journal of climate, 23(7), 1696-1718. + +* Beguería, S., & Vicente-Serrano, S. M. (2011). SPEI: Calculation of the Standardized Precipitation-Evapotranspiration Index (p. 1.8.1) [Dataset]. https://doi.org/10.32614/CRAN.package.SPEI + +* Thornthwaite, C. W., (1948). An approach toward a rational classification of climate. Geogr. Rev., 38, 55-94. https://doi. org/10.1097/00010694-194807000-00007 + +* Hargreaves G.H., (1994). Defining and using reference evapotranspiration. Journal of Irrigation and Drainage Engineering 120: 1132-1139. + +* Droogers P., Allen R. G., (2002). Estimating reference evapotranspiration under inaccurate data conditions. Irrigation and Drainage Systems 16: 33-45. + +* Monteith, J.L., 1965. Evaporation and Environment. 19th Symposia of the Society for Experimental Biology, University Press, Cambridge, 19:205-234. \ No newline at end of file diff --git a/esmvaltool/diag_scripts/droughts/pet.R b/esmvaltool/diag_scripts/droughts/pet.R index 27d5d79729..9f095d97c9 100644 --- a/esmvaltool/diag_scripts/droughts/pet.R +++ b/esmvaltool/diag_scripts/droughts/pet.R @@ -35,6 +35,9 @@ # # NOTE: Remove pr as reference, since pr is not mandatory input for all methods. # +# NOTE: all PET methods pass data as t() and do therefore not account for leap +# years. +# # Authors: [Peter Berg, Katja Weigel, Lukas Lindenlaub] library(yaml) @@ -46,10 +49,10 @@ setwd(dirname(commandArgs(asValues=TRUE)$file)) source("utils.R") -calculate_hargreaves <- function(metas, xprov) { - data <- list(tasmin=NULL, tasmax=NULL, rsdt=NULL) #, pr=NULL) +calculate_hargreaves <- function(metas, xprov, use_pr=FALSE) { + data <- list(tasmin=NULL, tasmax=NULL, rsdt=NULL, pr=NULL) for (meta in metas) { - if (meta$short_name %in% names(data)) { + if (meta$short_name %in% names(data) && !(meta$short_name == "pr" && !use_pr)) { print(paste("read", meta$short_name)) data[[meta$short_name]] <- get_var_from_nc(meta) if (meta$short_name == "tasmin") { @@ -63,6 +66,8 @@ calculate_hargreaves <- function(metas, xprov) { } dpet <- data$tasmin * NA for (i in 1:dim(dpet)[2]) { + print("IS TS?") + print(is.ts(t(data$tasmin[, i, ]))) pet_tmp <- hargreaves( t(data$tasmin[, i, ]), t(data$tasmax[, i, ]), @@ -104,7 +109,7 @@ calculate_thornthwaite <- function(metas, xprov) { } -calculate_penman <- function(metas, xprov) { +calculate_penman <- function(metas, xprov, method="ICID", crop="tall") { data <- list( tasmin = NULL, tasmax = NULL, @@ -144,8 +149,8 @@ calculate_penman <- function(metas, xprov) { Ra = t_or_null(data$rsdt[, i, ]), Rs = t_or_null(data$rsds[, i, ]), na.rm = TRUE, - # method="FAO", - crop = "tall" # TODO: read from params with fallback? + method = method, + crop = "tall" ) d2 <- dim(pet_tmp) pet_tmp <- as.numeric(pet_tmp) @@ -161,6 +166,8 @@ calculate_penman <- function(metas, xprov) { # ---------------------------------------------------------------------------- # params <- read_yaml(commandArgs(trailingOnly = TRUE)[1]) +ifelse(!is.null(params$method), params$method, "ICID") +ifelse(!is.null(params$crop), params$crop, "tall") dir.create(params$work_dir, recursive = TRUE) dir.create(params$plot_dir, recursive = TRUE) fillfloat <- 1.e+20 @@ -183,7 +190,8 @@ for (dataset in names(grouped_meta)){ metas <- grouped_meta[[dataset]] # list of files for this dataset xprov$ancestors <- list() switch(params$pet_type, - Penman = {pet <- calculate_penman(metas, xprov)}, + Penman = {pet <- calculate_penman(metas, xprov, + method=params$method, crop=params$crop)}, Thornthwaite = {pet <- calculate_thornthwaite(metas, xprov)}, Hargreaves = {pet <- calculate_hargreaves(metas, xprov)}, stop("pet_type must be one of: Penman, Hargreaves, Thornthwaite") From a51f13e3ba8056185179f536dcdc1fa369a78189 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 21 Feb 2025 11:45:39 +0100 Subject: [PATCH 19/66] drought folder for recipe docs --- .../recipes/{ => droughts}/recipe_consecdrydays.rst | 0 .../source/recipes/{ => droughts}/recipe_martin18grl.rst | 0 doc/sphinx/source/recipes/{ => droughts}/recipe_spei.rst | 0 doc/sphinx/source/recipes/index.rst | 3 --- doc/sphinx/source/recipes/recipe_droughts.rst | 9 +++++---- 5 files changed, 5 insertions(+), 7 deletions(-) rename doc/sphinx/source/recipes/{ => droughts}/recipe_consecdrydays.rst (100%) rename doc/sphinx/source/recipes/{ => droughts}/recipe_martin18grl.rst (100%) rename doc/sphinx/source/recipes/{ => droughts}/recipe_spei.rst (100%) diff --git a/doc/sphinx/source/recipes/recipe_consecdrydays.rst b/doc/sphinx/source/recipes/droughts/recipe_consecdrydays.rst similarity index 100% rename from doc/sphinx/source/recipes/recipe_consecdrydays.rst rename to doc/sphinx/source/recipes/droughts/recipe_consecdrydays.rst diff --git a/doc/sphinx/source/recipes/recipe_martin18grl.rst b/doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst similarity index 100% rename from doc/sphinx/source/recipes/recipe_martin18grl.rst rename to doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst diff --git a/doc/sphinx/source/recipes/recipe_spei.rst b/doc/sphinx/source/recipes/droughts/recipe_spei.rst similarity index 100% rename from doc/sphinx/source/recipes/recipe_spei.rst rename to doc/sphinx/source/recipes/droughts/recipe_spei.rst diff --git a/doc/sphinx/source/recipes/index.rst b/doc/sphinx/source/recipes/index.rst index 01eb8de78b..3a5b3889e7 100644 --- a/doc/sphinx/source/recipes/index.rst +++ b/doc/sphinx/source/recipes/index.rst @@ -37,7 +37,6 @@ Atmosphere recipe_clouds recipe_cmug_h2o recipe_crem - recipe_consecdrydays recipe_deangelis15nat recipe_diurnal_temperature_index recipe_droughts @@ -55,8 +54,6 @@ Atmosphere recipe_mpqb_xch4 recipe_quantilebias recipe_bock20jgr - recipe_spei - recipe_martin18grl recipe_autoassess_stratosphere recipe_autoassess_landsurface_permafrost recipe_autoassess_landsurface_surfrad diff --git a/doc/sphinx/source/recipes/recipe_droughts.rst b/doc/sphinx/source/recipes/recipe_droughts.rst index 4a97d14fe2..ca081b62d2 100644 --- a/doc/sphinx/source/recipes/recipe_droughts.rst +++ b/doc/sphinx/source/recipes/recipe_droughts.rst @@ -53,12 +53,13 @@ More details and usage examples can be found in the Available recipes and diagnostics --------------------------------- +.. toctree:: + :maxdepth: 1 -Recipes: + droughts/recipes_consecdrydays + droughts/recipe_spei + droughts/recipe_martin18grl -* :ref:`recipes_consecdrydays` -* :ref:`recipes_spei` -* :ref:`recipes_martin18grl` Diagnostics are stored in ``diag_scripts/droughts/``. An incomplete list of diagnostics that are used in different recipes is shown below. Some recipes From 121aa1eea3ad09e215e2a52365f61ced892231ad Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 21 Feb 2025 14:44:06 +0100 Subject: [PATCH 20/66] ensure mm/day as default unit and convert without leap days --- esmvaltool/diag_scripts/droughts/pet.R | 21 +++---- esmvaltool/diag_scripts/droughts/spei.R | 14 ++--- esmvaltool/diag_scripts/droughts/utils.R | 63 ++++++++++++++++--- .../recipes/droughts/recipe_martin18grl.yml | 12 ++-- esmvaltool/recipes/droughts/recipe_spei.yml | 15 ++++- 5 files changed, 85 insertions(+), 40 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/pet.R b/esmvaltool/diag_scripts/droughts/pet.R index 9f095d97c9..b204f6d354 100644 --- a/esmvaltool/diag_scripts/droughts/pet.R +++ b/esmvaltool/diag_scripts/droughts/pet.R @@ -198,34 +198,29 @@ for (dataset in names(grouped_meta)){ ) mask <- get_merged_mask(metas) for (t in 1:dim(pet)[3]){ - # print(t) tmp <- pet[, , t] - print(dim(tmp)) tmp[is.na(mask)] <- NA - print(dim(tmp)) pet[, , t] <- tmp } + pet <- monthly_to_daily(pet, dim=3) - # postprocess and write PET + # write PET to file first_meta = metas[[names(metas)[1]]] filename <- write_nc_file_like( params, first_meta, pet, fillfloat, short_name="evspsblpot", long_name="Potential Evapotranspiration", - units="mm month-1") # temp default units to be converted in python - #units="kg m-2 s-1") - input_meta = select_var(metas, "tasmin") # TODO: create duplicate()? - input_meta$filename = filename - input_meta$short_name = "evspsblpot" - input_meta$long_name = "Potential Evapotranspiration" - input_meta$units = "mm month-1" + units="mm day-1") + input_meta <- select_var(metas, "tasmin") # TODO: create duplicate()? + input_meta$filename <- filename + input_meta$short_name <- "evspsblpot" + input_meta$long_name <- "Potential Evapotranspiration" + input_meta$units <- "mm day-1" meta[[filename]] <- input_meta xprov$caption <- "PET per grid point." provenance[[filename]] <- xprov } - - write_yaml(provenance, provenance_file) write_yaml(meta, meta_file) \ No newline at end of file diff --git a/esmvaltool/diag_scripts/droughts/spei.R b/esmvaltool/diag_scripts/droughts/spei.R index deb548c57b..ffdaa41ad8 100644 --- a/esmvaltool/diag_scripts/droughts/spei.R +++ b/esmvaltool/diag_scripts/droughts/spei.R @@ -84,6 +84,7 @@ fill_refperiod <- function(cfg, tsvec) { cfg <- list_default(cfg, "refstart_month", tsvec[2]) cfg <- list_default(cfg, "refend_year", tsvec[3]) cfg <- list_default(cfg, "refend_month", tsvec[4]) + return(cfg) } # ---------------------------------------------------------------------------- # @@ -135,7 +136,7 @@ for (dataset in names(grouped_meta)){ provenance[[filename_wb]] <- list(caption="Water balance per grid point.") } - fill_refperiod(cfg, tsvec) + cfg <- fill_refperiod(cfg, tsvec) pme_spei <- pme * NA coeffs <- list() for (i in 1:dim(pme)[1]){ @@ -163,13 +164,12 @@ for (dataset in names(grouped_meta)){ } } pme_spei[pme_spei > 10000] <- NA # replaced with fillfloat in write function - # TODO: check if we need to apply mask to pme_spei # apply mask - # for (t in 1:dim(pme)[3]) { - # tmp <- pme_spei[, , t] - # tmp[is.na(mask)] <- NA - # pme_spei[, , t] <- tmp - # } + for (t in 1:dim(pme)[3]) { + tmp <- pme_spei[, , t] + tmp[is.na(mask)] <- NA + pme_spei[, , t] <- tmp + } filename <- write_nc_file_like(cfg, pr_meta, pme_spei, fillfloat, short_name=cfg$indexname) new_meta = list(filename=filename, short_name=tolower(cfg$indexname), long_name=cfg$indexname, units="1", dataset=dataset) diff --git a/esmvaltool/diag_scripts/droughts/utils.R b/esmvaltool/diag_scripts/droughts/utils.R index f4e4f2ae47..02c95d7c1f 100644 --- a/esmvaltool/diag_scripts/droughts/utils.R +++ b/esmvaltool/diag_scripts/droughts/utils.R @@ -81,13 +81,49 @@ convert_to_monthly <- function(id, v) { return(v) } -convert_to_monthly_simple <- function(id, v) { - # converts kg/m2/s to mm/month - return(v * 30 * 24 * 3600.) +daily_to_monthly <- function(v, dim=1) { + # multiplies by number of days (i.e. mm/day -> mm/mon) + # ignores leap years to be compatible to SPEI library + mlen <- c(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + mlen_rep <- rep(mlen, length.out = dim(v)[dim]) + for (i in 1:dim(v)[dim]){ + if (dim == 1) { + v[i,,] <- v[i,,] * mlen_rep[i] + } else if (dim == 2) { + v[,i,] <- v[,i,] * mlen_rep[i] + } else if (dim == 3) { + v[,,i] <- v[,,i] * mlen_rep[i] + } else { + stop("time dimension must be 1, 2 or 3") + } + } + return(v) +} + +monthly_to_daily <- function(v, dim=1){ + # divide by number of days (i.e. mm/mon -> mm/day) + # ignores leap years to be compatible to SPEI library + mlen <- c(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + mlen_rep <- rep(mlen, length.out = dim(v)[dim]) + for (i in 1:dim(v)[dim]){ + if (dim == 1) { + v[i,,] <- v[i,,] / mlen_rep[i] + } else if (dim == 2) { + v[,i,] <- v[,i,] / mlen_rep[i] + } else if (dim == 3) { + v[,,i] <- v[,,i] / mlen_rep[i] + } else { + stop("time dimension must be 1, 2 or 3") + } + } + return(v) } convert_to_cf <- function(id, v) { + # NOTE: use mm/day converted by preprocessor if possible and convert + # it to/from month using daily_to_monthly and monthly_to_daily instead. + # # converts mm/mon back to kg/m2/s assuming the input ncfile to have the # correct calendar set. (inverse of `convert_to_monthly`) tcal <- ncatt_get(id, "time", attname = "calendar") @@ -166,6 +202,8 @@ get_var_from_nc <- function(meta, custom_var=FALSE) { else var <- meta$short_name id <- nc_open(meta$filename, readunlim=FALSE) data <- ncvar_get(id, var) + dim_names <- attributes(id$dim)$names + time_dim <- which(sapply(dim_names, function(x) x == "time")) # convert to required units if (var == "time") { tcal <- ncatt_get(id, "time", attname = "calendar") @@ -188,16 +226,21 @@ get_var_from_nc <- function(meta, custom_var=FALSE) { data <- data - 273.15 } else if (var %in% list("rsdt", "rsds")) { data <- data * (86400.0 / 1e6) # W/(m2) to MJ/(m2 d) - } else if (var %in% list("pr", "evspsbl")) { - # TODO: pet convert temp removed - # TODO: don't convert only by name, check units or attributes - data <- convert_to_monthly(id, data) + } else if (var %in% list("pr", "evspsbl", "evspsblpot")) { + if (meta$units == "mm/day") { + data <- daily_to_monthly(data, dim=time_dim) + } else if (meta$units == "mm/month") { # do nothing + } else { + stop(paste(var, + " is expected to be in mm/day or mm/month not in ", meta$units)) + } } else if (var == "sfcWind") { + if (meta$units != "m s-1") {stop("sfcWind is expected to at 10m in m/s")} data <- data * (4.87/(log(67.9 * 10.0 - 5.42))) # U10m to U2m (*0.74778) - } else if (var == "psl") { - data <- data * 0.001 # Pa to kPa - } else if (var == "ps") { + } else if (var %in% list("psl", "ps")) { data <- data * 0.001 # Pa to kPa + } else { + print(paste("No conversion for", var)) } nc_close(id) return(data) diff --git a/esmvaltool/recipes/droughts/recipe_martin18grl.yml b/esmvaltool/recipes/droughts/recipe_martin18grl.yml index 0ef802e270..51b73194b7 100644 --- a/esmvaltool/recipes/droughts/recipe_martin18grl.yml +++ b/esmvaltool/recipes/droughts/recipe_martin18grl.yml @@ -19,21 +19,19 @@ documentation: - eval4cmip preprocessors: - preprocessor1: - regrid: - target_grid: 2x2 - scheme: linear - preprocessor2: + default: regrid: target_grid: 2x2 scheme: linear + convert_units: + units: mm/day diagnostics: diagnostic1: variables: pr: reference_dataset: MIROC-ESM - preprocessor: preprocessor1 + preprocessor: default field: T2Ms start_year: 1901 end_year: 2000 @@ -100,7 +98,7 @@ diagnostics: variables: pr: reference_dataset: MIROC-ESM - preprocessor: preprocessor2 + preprocessor: default field: T2Ms mip: Amon project: CMIP5 diff --git a/esmvaltool/recipes/droughts/recipe_spei.yml b/esmvaltool/recipes/droughts/recipe_spei.yml index f365ca9e46..5ca4d5f832 100644 --- a/esmvaltool/recipes/droughts/recipe_spei.yml +++ b/esmvaltool/recipes/droughts/recipe_spei.yml @@ -24,6 +24,7 @@ documentation: CMIP6: &cmip6 {project: CMIP6, mip: Amon, ensemble: r1i1p1f1, grid: gn} datasets: +# - {dataset: ERA-Interim, project: OBS6, type: reanaly, version: 1, tier: 3} - {<<: *cmip6, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS} preprocessors: @@ -31,20 +32,28 @@ preprocessors: regrid: target_grid: reference_dataset scheme: linear + perday: + regrid: + target_grid: reference_dataset + scheme: linear + convert_units: + units: mm/day diagnostics: diagnostic: description: Calculating SPI and SPEI index variables: - pr: &var + tasmin: &var reference_dataset: ACCESS-CM2 preprocessor: preprocessor start_year: 2000 end_year: 2005 mip: Amon exp: [historical] - tasmin: *var tasmax: *var + pr: + <<: *var + preprocessor: perday scripts: spi: script: droughts/spei.R @@ -59,4 +68,4 @@ diagnostics: script: droughts/spei.R ancestors: [pr, pet] distribution: log-Logistic - smooth_month: 6 + smooth_month: 6 \ No newline at end of file From b4bf3510a5b1cf083103b62237d17dfad8f000cf Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 21 Feb 2025 15:14:05 +0100 Subject: [PATCH 21/66] rename collect_drought_func to utils.py --- esmvaltool/diag_scripts/droughts/collect_drought.py | 11 +++++++---- .../droughts/{collect_drought_func.py => utils.py} | 0 2 files changed, 7 insertions(+), 4 deletions(-) rename esmvaltool/diag_scripts/droughts/{collect_drought_func.py => utils.py} (100%) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index 33bb9dd3c1..dd877dd8d8 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -9,7 +9,8 @@ This diagnostic applies drought charactristics based on Martin (2018) to data produced by spei.R. This characteristics and differences to a reference dataset or between different time periods are plotted for each -dataset and multi-model mean. +dataset and multi-model mean. The calculated frequency, duration and severity +of drought events are saved to netcdf files for further use. It expects multiple datasets for a particular index as input. The reference dataset can be specified with ``reference_dataset`` and is not part of the multi-model mean. @@ -35,16 +36,18 @@ plot_models: bool, false Save plots for each individual model, in addition to multi-model mean. start_year: int - This option is used to select the time slices for comparison if ``compare_intervals=True``. + This option is used to select the time slices for comparison if + ``compare_intervals=True``. end_year: int - This option is used to select the time slices for comparison if ``compare_intervals=True``. + This option is used to select the time slices for comparison if + ``compare_intervals=True``. """ import iris import numpy as np import datetime as dt import esmvaltool.diag_scripts.shared as e -from esmvaltool.diag_scripts.droughts.collect_drought_func import ( +from esmvaltool.diag_scripts.droughts.utils import ( _get_drought_data, _plot_multi_model_maps, _plot_single_maps) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought_func.py b/esmvaltool/diag_scripts/droughts/utils.py similarity index 100% rename from esmvaltool/diag_scripts/droughts/collect_drought_func.py rename to esmvaltool/diag_scripts/droughts/utils.py From 85ae1b44213911b88b48390d6dd3f813b0ee8606 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 24 Feb 2025 15:17:10 +0100 Subject: [PATCH 22/66] use acronym as shortname and added long_name --- esmvaltool/diag_scripts/droughts/spei.R | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esmvaltool/diag_scripts/droughts/spei.R b/esmvaltool/diag_scripts/droughts/spei.R index ffdaa41ad8..df843931a1 100644 --- a/esmvaltool/diag_scripts/droughts/spei.R +++ b/esmvaltool/diag_scripts/droughts/spei.R @@ -124,9 +124,11 @@ for (dataset in names(grouped_meta)){ pet_meta <- select_var(metas, cfg$short_name_pet, strict=FALSE) if (is.null(pet_meta)) { cfg$indexname <- "SPI" + cfg$long_name <- "Standardized Precipitation Index" pme <- pr } else { cfg$indexname <- "SPEI" + cfg$long_name <- "Standardized Precipitation Evapotranspiration Index" pet <- get_var_from_nc(pet_meta) pme <- pr - pet } @@ -171,7 +173,7 @@ for (dataset in names(grouped_meta)){ pme_spei[, , t] <- tmp } filename <- write_nc_file_like(cfg, pr_meta, pme_spei, fillfloat, short_name=cfg$indexname) - new_meta = list(filename=filename, short_name=tolower(cfg$indexname), + new_meta = list(filename=filename, short_name=cfg$indexname, long_name=cfg$indexname, units="1", dataset=dataset) meta[[filename]] <- modifyList(pr_meta, new_meta) provenance[[filename]] = list(caption=paste(cfg$indexname, " index per grid point.")) From 98e20afc0681e1d8053d1c1ec37417a3d5d20ee7 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 24 Feb 2025 15:18:02 +0100 Subject: [PATCH 23/66] fix units --- esmvaltool/diag_scripts/droughts/utils.R | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/utils.R b/esmvaltool/diag_scripts/droughts/utils.R index 02c95d7c1f..c630483e75 100644 --- a/esmvaltool/diag_scripts/droughts/utils.R +++ b/esmvaltool/diag_scripts/droughts/utils.R @@ -8,7 +8,7 @@ # time -> start_year, start_month, end_year, end_month # lat -> fails > 90 # tas/min/max -> kelvin to celsius -# pr -> to mm/month TODO: same for PET when output is fixed +# pr -> to mm month-1 TODO: same for PET when output is fixed # sfcWind -> U10 to U2 # psl -> hPa to kPa # rsdt/rsds -> Wm-2 to MJm-2d-1 @@ -47,7 +47,7 @@ xprov <- list( # ---------------------------------------------------------------------------- # convert_to_monthly <- function(id, v) { - # converts kg/m2/s to mm/month depending on the calendar of the nc files + # converts kg/m2/s to mm month-1 depending on the calendar of the nc files # time coordinate. Assuming a density of 1000 kg m-3 tcal <- ncatt_get(id, "time", attname = "calendar") if (tcal$value == "360_day") return(v* 30 * 24 * 3600.) @@ -82,7 +82,7 @@ convert_to_monthly <- function(id, v) { } daily_to_monthly <- function(v, dim=1) { - # multiplies by number of days (i.e. mm/day -> mm/mon) + # multiplies by number of days (i.e. mm day-1 -> mm/mon) # ignores leap years to be compatible to SPEI library mlen <- c(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) mlen_rep <- rep(mlen, length.out = dim(v)[dim]) @@ -101,7 +101,7 @@ daily_to_monthly <- function(v, dim=1) { } monthly_to_daily <- function(v, dim=1){ - # divide by number of days (i.e. mm/mon -> mm/day) + # divide by number of days (i.e. mm/mon -> mm day-1) # ignores leap years to be compatible to SPEI library mlen <- c(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) mlen_rep <- rep(mlen, length.out = dim(v)[dim]) @@ -121,7 +121,7 @@ monthly_to_daily <- function(v, dim=1){ convert_to_cf <- function(id, v) { - # NOTE: use mm/day converted by preprocessor if possible and convert + # NOTE: use mm day-1 converted by preprocessor if possible and convert # it to/from month using daily_to_monthly and monthly_to_daily instead. # # converts mm/mon back to kg/m2/s assuming the input ncfile to have the @@ -227,12 +227,12 @@ get_var_from_nc <- function(meta, custom_var=FALSE) { } else if (var %in% list("rsdt", "rsds")) { data <- data * (86400.0 / 1e6) # W/(m2) to MJ/(m2 d) } else if (var %in% list("pr", "evspsbl", "evspsblpot")) { - if (meta$units == "mm/day") { + if (meta$units == "mm day-1") { data <- daily_to_monthly(data, dim=time_dim) - } else if (meta$units == "mm/month") { # do nothing + } else if (meta$units == "mm month-1") { # do nothing } else { stop(paste(var, - " is expected to be in mm/day or mm/month not in ", meta$units)) + " is expected to be in mm day-1 or mm month-1 not in ", meta$units)) } } else if (var == "sfcWind") { if (meta$units != "m s-1") {stop("sfcWind is expected to at 10m in m/s")} From 22f6146614be6e0052c7f896108d8a3122d51e9f Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 24 Feb 2025 15:19:23 +0100 Subject: [PATCH 24/66] added first plot diagnostic and utils --- esmvaltool/diag_scripts/droughts/diffmap.py | 349 +++++ esmvaltool/diag_scripts/droughts/diffmap.yml | 45 + esmvaltool/diag_scripts/droughts/utils.py | 1296 +++++++++++++++++- esmvaltool/recipes/droughts/recipe_spei.yml | 23 +- 4 files changed, 1681 insertions(+), 32 deletions(-) create mode 100644 esmvaltool/diag_scripts/droughts/diffmap.py create mode 100644 esmvaltool/diag_scripts/droughts/diffmap.yml diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py new file mode 100644 index 0000000000..75cc6f6f3e --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Creates a difference map for any given drought index. + +A global map is plotted for each dataset with an index (must be unique). +The map shows the difference of the first and last N years +(N = comparison_period). +For multiple datasets a multi-model mean is calculated by default. This can be +disabled using `plot_mmm: False`. To plot only mmm and skip maps for individual +datasets use `plot_models: False`. +The diagnostic is applied to each variable by default, but for single variables +another meta key can be chosen for grouping like `group_by: project` to treat +observations and models seperatly. +The produced maps can be clipped to non polar landmasses (220, 170, -55, 90) +with `clip_land: True`. + +TODO: Can MMM be preprocessed in this case? all operations should be linear. +also percent? + +TODO: rename metric and group to their real keys in plotkwargs (allow +multi match?) and make group_by accept a list. make sure diffmap_metrics is +always added so that it can be consiedered as extra facet for this diagnostic. +see yml for more notes.. + +TODO: plot_kwargs overwrites from cfg seems not to overwrite those from +diffmap.yml rename them to 'extra_plot_kwargs' or 'meta_plot_kwargs' and add a +meta key to match any selection (order matters). This allows more flexibility. + +Configuration options in recipe +------------------------------- +plot_mmm: bool, optional (default: True) + Calculate and plot the average over all datasets. +plot_models: bool, optional (default: True) + Plot maps for each dataset. +basename: str, optional + Format string for the plot filename. Can use meta keys and diffmap_metric. + For multi-model mean the dataset will be set to "MMM". Data will be saved + as same name with .nc extension. + By default: "{short_name}_{exp}_{diffmap_metric}_{dataset}" +plot_kwargs: dict, optional + Kwargs passed to diag_scripts.shared.plot.global_contourf function. + The "cbar_label" parameter is formatted with meta keys. So placeholders + like "{short_name}" or "{units}" can be used. + By default {"cmap": "RdYlBu", "extend": "both"} +plot_kwargs_overwrite: list, optional (default: []) + List of plot_kwargs dicts for specific metrics (diff, first, latest, total) + and group_by values (ie. pr, tas for group_by: short_name). + `group` and `metric` can either be strings or lists of strings to be + applied to all matching plots. Leave any of them empty to apply to all. + All other given keys are applied to the plot_kwargs dict for this plot. + Settings will be applied in order of the list, so later entries can + overwrite previous ones. +comparison_period: int, optional (default: 10) + Number of years to compare (first and last N years). Must be less or equal + half of the total time period. +group_by: str, optional (default: short_name) + Meta key to loop over for multiple datasets. +clip_land: bool, optional (default: False) + Clips map plots to non polar land area (220, 170, -55, 90). +strip_plots: bool, optional (default: False) + Removes titles, margins and colorbars from plots (to use them in panels). +mdtol: float, optional (default: 0.5) + Tolerance for missing data in multi-model mean calculation. 0 means no + missing data is allowed. For 1 mean is calculated if any data is available. +metrics: list, optional + List of metrics to calculate and plot. For the difference ("percent" and + "diff") the mean over two comparison periods ("first" and "last") is + calculated. The "total" periods mean can be calculated and plotted as well. + By default ["first", "last", "diff", "total", "percent"] +""" + +import iris +import os +import numpy as np +import yaml +import matplotlib as mpl +from esmvalcore import preprocessor as pp +from iris.analysis import MEAN +import logging +from collections import defaultdict +import esmvaltool.diag_scripts.droughts.utils as ut +import matplotlib.pyplot as plt +import esmvaltool.diag_scripts.shared as e +from cartopy.util import add_cyclic_point + +# from esmvaltool.diag_scripts.droughts import colors # noqa: F401 + +log = logging.getLogger(__file__) + + +TITLES = { + "first": "Mean Historical", + "last": "Mean Future", + "trend": "Future - Historical", + "diff": "Future - Historical", + "total": "Mean Full Period", + "percent": "Relative Change", +} + +METRICS = ["first", "last", "diff", "total", "percent"] + + +def plot_colorbar( + cfg: dict, + plotfile: str, + plot_kwargs: dict, + orientation="vertical", + mappable=None, +) -> None: + # fig, ax = plt.subplots(figsize=(1, 4), layout="constrained") + fig = plt.figure(figsize=(1.5, 3)) + # fixed size axes in fixed size figure + cbar_ax = fig.add_axes([0.01, 0.04, 0.2, 0.92]) + if mappable is None: + cmap = plot_kwargs.get("cmap", "RdYlBu") + norm = mpl.colors.Normalize( + vmin=plot_kwargs.get("vmin"), vmax=plot_kwargs.get("vmax") + ) + mappable = mpl.cm.ScalarMappable(norm=norm, cmap=cmap) + cb = fig.colorbar( + mappable, + cax=cbar_ax, + orientation=orientation, + label=plot_kwargs["cbar_label"], + pad=0.0, + ) + if "cbar_ticks" in plot_kwargs: + cb.set_ticks(plot_kwargs["cbar_ticks"], minor=False) + fontsize = plot_kwargs.get("cbar_fontsize", 14) + cb.ax.tick_params(labelsize=fontsize) + cb.set_label( + plot_kwargs["cbar_label"], fontsize=fontsize, labelpad=fontsize + ) + + if plotfile.endswith(".png"): + plotfile = plotfile[:-4] + fig.savefig(plotfile + "_cb.png") # , bbox_inches="tight") + + +def plot(cfg, meta, cube, basename, kwargs=None): + """Plot map using diag_scripts.shared module.""" + plotfile = e.get_plot_filename(basename, cfg) + plot_kwargs = cfg.get("plot_kwargs", {}).copy() + if kwargs is not None: + plot_kwargs.update(kwargs) + if "vmax" in plot_kwargs and "vmin" in plot_kwargs: + plot_kwargs["levels"] = np.linspace( + plot_kwargs["vmin"], plot_kwargs["vmax"], 9 + ) + label = plot_kwargs.get("cbar_label", "{short_name} ({units})") + plot_kwargs["cbar_label"] = label.format(**meta) + for coord in cube.coords(dim_coords=True): + if not coord.has_bounds(): + log.info("NO BOUNDS GUESSING: %s", coord.name()) + cube.coord(coord.name()).guess_bounds() + cyclic_data, cyclic_lon = add_cyclic_point( + cube.data, cube.coord("longitude").points + ) + if ( + meta["dataset"] == "ERA5" + and meta["short_name"] == "evspsblpot" + and len(cube.data[0]) == 360 + ): + # NOTE: fill missing gap at 360 for era5 pet calculation + cube.data[:, 359] = cube.data[:, 0] + mapplot = e.plot.global_contourf(cube, **plot_kwargs) + if cfg.get("clip_land", False): + plt.gca().set_extent((220, 170, -55, 90)) + # plt.gcf().set_size_inches(6, 3) + plt.title(meta.get("title", basename)) + if cfg.get("strip_plots", False): + plt.gca().set_title(None) + plt.gca().set_ylabel(None) + plt.gca().set_xlabel(None) + cb_mappable = mapplot.colorbar.mappable + mapplot.colorbar.remove() + plot_colorbar(cfg, plotfile, plot_kwargs, mappable=cb_mappable) + fig = mapplot.get_figure() + fig.savefig(plotfile, bbox_inches="tight") + plt.close() + log.info("saved figure: %s", plotfile) + + +def apply_plot_kwargs_overwrite(kwargs, overwrites, metric, group): + """Apply plot_kwargs_overwrite to kwargs dict for selected plots.""" + for overwrite in overwrites: + # print(overwrite) + new_kwargs = overwrite.copy() + groups = new_kwargs.pop("group", []) + if not isinstance(groups, list): + groups = [groups] + if len(groups) > 0 and group not in groups: + continue + metrics = new_kwargs.pop("metric", []) + if not isinstance(metrics, list): + metrics = [metrics] + if len(metric) > 0 and metric not in metrics: + continue + kwargs.update(new_kwargs) + return kwargs + + + +def calculate_diff(cfg, meta, mm, output_meta, group, norm): + """absolute difference between first and last years of a cube. + Calculates the absolut difference between the first and last period of + a cube. Writing data to mm and plotting each dataset depends on cfg. + """ + fname = meta["filename"] + cube = iris.load_cube(fname) + if meta["short_name"] in cfg.get("convert_units", {}): + pp.convert_units(cube, cfg["convert_units"][meta["short_name"]]) + try: # TODO: maybe don't keep this from cmorizer + cube.remove_coord("Number of stations") # dropped by unit conversions + except Exception: + pass + if "start_year" in cfg.keys() or "end_year" in cfg.keys(): + log.info("selecting time period") + # print(cfg.keys()) + cube = pp.extract_time( + cube, cfg["start_year"], 1, 1, cfg["end_year"], 12, 31 + ) + print(cube.data.shape) + # if meta["short_name"] in ["evpsblpot"]: + # cube.convert_units("1.e-5 kg m-2 s-1") + dtime = cfg.get("comparison_period", 10) * 12 + cubes = {} + cubes["total"] = cube.collapsed("time", MEAN) + do_metrics = cfg.get("metrics", METRICS) + calc_metrics = ["first", "last", "diff", "percent"] + if any(m in do_metrics for m in calc_metrics): + cubes["first"] = cube[0:dtime].collapsed("time", MEAN) + cubes["last"] = cube[-dtime:].collapsed("time", MEAN) + if any(m in do_metrics for m in ["diff", "percent"]): + cubes["diff"] = cubes["last"] - cubes["first"] + cubes["diff"].data /= norm + if cubes["diff"].data[0, 0] != np.nan: + print(cubes["diff"]) + cubes["diff"].units = str(cubes["diff"].units) + " / 10 years" + cubes["percent"] = cubes["diff"] / cubes["first"] * 100 + cubes["percent"].units = "% / 10 years" + if cfg.get("plot_mmm", True): + for key in do_metrics: + mm[key].append(cubes[key]) + for key, cube in cubes.items(): + if key not in do_metrics: + continue # i.e. first/last if only diff is needed + meta["diffmap_metric"] = key + meta["exp"] = meta.get("exp", "exp") + basename = cfg["basename"].format(**meta) + meta["title"] = f" {basename} ({TITLES[key]})" + if cfg.get("plot_models", True): + plot_kwargs = cfg.get("plot_kwargs", {}).copy() + overwrites = cfg.get("plot_kwargs_overwrite", []) + apply_plot_kwargs_overwrite(plot_kwargs, overwrites, key, group) + plot(cfg, meta, cube, basename, kwargs=plot_kwargs) + plt.close() + if cfg.get("save_models", True): + work_file = os.path.join(cfg["work_dir"], f"{basename}.nc") + iris.save(cube, work_file) + meta["filename"] = work_file + output_meta[work_file] = meta.copy() + + +def calculate_mmm(cfg, meta, mm, output_meta, group, key="diff"): + """Calculate multi-model mean for a given metric.""" + drop = cfg.get("dropcoords", ["time", "height"]) + meta = meta.copy() # don't modify meta in place: + meta["dataset"] = "MMM" + meta["diffmap_metric"] = key + basename = cfg["basename"].format(**meta) + mmm, _ = ut.mmm( + mm[key], + dropcoords=drop, + dropmethods=key != "diff", + mdtol=cfg.get("mdtol", 0.3), + # mdtol=0, + ) + meta["title"] = f"Multi-model Mean ({cfg['titles'][key]})" + if cfg.get("plot_mmm", True): + plot_kwargs = cfg.get("plot_kwargs", {}).copy() + overwrites = cfg.get("plot_kwargs_overwrite", []) + apply_plot_kwargs_overwrite(plot_kwargs, overwrites, key, group) + plot(cfg, meta, mmm, basename, kwargs=plot_kwargs) + if cfg.get("save_mmm", True): + work_file = os.path.join(cfg["work_dir"], f"{basename}.nc") + meta["filename"] = work_file + meta["diffmap_metric"] = key + output_meta[work_file] = meta.copy() + iris.save(mmm, work_file) + + +def set_defaults(cfg): + """update cfg with default values from diffmap.yml""" + config_file = os.path.realpath(__file__)[:-3] + ".yml" + with open(config_file, "r", encoding="utf-8") as f: + defaults = yaml.safe_load(f) + for key, val in defaults.items(): + cfg.setdefault(key, val) + if cfg["plot_kwargs_overwrite"] is not defaults["plot_kwargs_overwrite"]: + cfg["plot_kwargs_overwrite"].extend(defaults["plot_kwargs_overwrite"]) + + +def main(cfg): + """Main function.""" + # cfg["group_by"] = cfg.get("group_by", "short_name") + set_defaults(cfg) + groups = e.group_metadata(cfg["input_data"].values(), cfg["group_by"]) + output = {} + for group, metas in groups.items(): + mm = defaultdict(list) + skipped = 0 + for meta in metas: + # TODO: fix diag_spei output to contain all relevant meta data + ut.guess_experiment(meta) + if "end_year" not in meta: + try: + meta.update(ut.get_time_range(meta["filename"])) + except Exception: + log.error( + "failed to get time range for %s", meta["filename"] + ) + skipped += 1 + log.error("skipped datasets: %s", skipped) + continue + # adjust norm for selected time period + meta["end_year"] = cfg.get("end_year", meta["end_year"]) + meta["start_year"] = cfg.get("start_year", meta["start_year"]) + norm = ( + int(meta["end_year"]) + - int(meta["start_year"]) + + 1 # count full end year + - cfg.get("comparison_period", 10) # decades center to center + ) / 10 + calculate_diff(cfg, meta, mm, output, group, norm) + do_mmm = cfg.get("plot_mmm", True) or cfg.get("save_mmm", True) + if do_mmm and len(metas) > 1: + for metric in cfg.get("metrics", METRICS): + calculate_mmm(cfg, metas[0], mm, output, group, metric) + ut.save_metadata(cfg, output) + # TODO close all and everything to free up memory + # if "panels" in cfg: + # for grid in cfg["panels"]: + # create_panels(cfg, output, grid) + + +if __name__ == "__main__": + with e.run_diagnostic() as config: + main(config) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.yml b/esmvaltool/diag_scripts/droughts/diffmap.yml new file mode 100644 index 0000000000..9f25e97331 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/diffmap.yml @@ -0,0 +1,45 @@ +# this file contains default setup for the diffmap map plots i.e. +# limits, colormaps, etc. every root level key will be overwritten by +# optional script parameters in the recipe file. +# The plot_kwargs_overwrite list however will get merged with the script +# parameters to allow modifications only for specific variables/metrics. +# All options are explained in diffmap.py and the ESMValTool documentation. + + +group_by: short_name +comparison_period: 15 +plot_kwargs: + cmap: RdYlBu + extend: both +plot_models: False +save_models: False +plot_mmm: True +save_mmm: True +clip_land: True +strip_plots: False +basename: "{short_name}_{exp}_{diffmap_metric}_{dataset}" +convert_units: + tas: degC + tasmax: degC + tasmin: degC + pr: mm day-1 + evspsblpot: mm day-1 + ps: hPa + sm: "%" + +titles: + first: "Mean Historical" + last: "Mean Future" + trend: "Future - Historical" + diff: "Future - Historical" + total: "Mean Full Period" + percent: "Relative Change" + +metrics: + - diff + - total + - first + - last + - percent + +plot_kwargs_overwrite: {} \ No newline at end of file diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 908212c6c7..2468503f4a 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -1,43 +1,1279 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +import datetime as dt +import itertools as it +import logging +import os +from contextlib import suppress +from os.path import dirname as par_dir +from pathlib import Path +from pprint import pformat + +import cartopy as ct +import cartopy.crs as cart +import iris +import iris.plot as iplt +import matplotlib as mpl +import matplotlib.dates as mda +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import xarray as xr +import yaml +from cf_units import Unit +from esmvalcore import preprocessor as pp +from iris.analysis import Aggregator +from iris.coords import AuxCoord +from iris.util import equalise_attributes + +import esmvaltool.diag_scripts.shared.names as n +from esmvaltool.diag_scripts import shared +from esmvaltool.diag_scripts.shared import ( + ProvenanceLogger, + get_cfg, + get_diagnostic_filename, + get_plot_filename, + select_metadata, + group_metadata, +) +from esmvaltool.diag_scripts.shared._base import _get_input_data_files + +logger = logging.getLogger(os.path.basename(__file__)) + +# fmt: off +DENSITY = AuxCoord( + 1000, + long_name="density", + units="kg m-3") + +# fmt: off +FNAME_FORMAT = "{project}_{reference_dataset}_{mip}_{exp}_{ensemble}_{short_name}_{start_year}-{end_year}" + +CONTINENTAL_REGIONS = { + "Global": ["GLO"], # global + "North America": ["GIC", "NWN", "NEN", "WNA", "CNA", "ENA"], + "Central America": ["NCA", "SCA", "CAR"], + "Southern America": ["NWS", "NSA", "NES", "SAM", "SWS", "SES", "SSA"], + "Europe": ["NEU", "WCE", "EEU", "MED"], + "Africa": ["SAH", "WAF", "CAF", "NEAF", "SEAF", "WSAF", "ESAF", "MDG"], + "Asia": ["RAR", "WSB", "ESB", "RFE", "WCA", "ECA", "TIB", "EAS", "ARP", "SAS", "SEA"], + "Australia": ["NAU", "CAU", "EAU", "SAU", "NZ", "WAN", "EAN"], +} +# fmt: on + +# REGION_NAMES = { +# 'Arabian-Peninsula', +# 'Arabian-Sea', +# 'Arctic-Ocean', +# 'Bay-of-Bengal' +# 'C.Australia', +# 'C.North-America', +# 'Caribbean', +# 'Central-Africa' +# 'E.Antarctica', +# 'E.Asia', +# 'E.Australia', +# 'E.C.Asia', +# 'E.Europe' +# 'E.North-America', +# 'E.Siberia', +# 'E.Southern-Africa' +# 'Equatorial.Atlantic-Ocean', +# 'Equatorial.Indic-Ocean' +# 'Equatorial.Pacific-Ocean', +# 'Greenland/Iceland', +# 'Madagascar', +# 'Mediterranean', +# 'N.Atlantic-Ocean', +# 'N.Australia', +# 'N.Central-America' +# 'N.E.North-America', +# 'N.E.South-America', +# 'N.Eastern-Africa', +# 'N.Europe', +# 'N.Pacific-Ocean', +# 'N.South-America', +# 'N.W.North-America' +# 'N.W.South-America', +# 'New-Zealand', +# 'Russian-Arctic', +# 'Russian-Far-East', +# 'S.Asia', +# 'S.Atlantic-Ocean', +# 'S.Australia', +# 'S.Central-America', +# 'S.E.Asia', +# 'S.E.South-America', +# 'S.Eastern-Africa', +# 'S.Indic-Ocean', +# 'S.Pacific-Ocean', +# 'S.South-America', +# 'S.W.South-America', +# 'Sahara', +# 'South-American-Monsoon', +# 'Southern-Ocean', +# 'Tibetan-Plateau', +# 'W.Antarctica', +# 'W.C.Asia', +# 'W.North-America', +# 'W.Siberia', +# 'W.Southern-Africa', +# 'West&Central-Europe', +# 'Western-Africa' +# } + + + +def merge_list_cube(cube_list, aux_name="dataset", points=None, equalize=True): + """ + Merges a list of cubes into a single cube with an enumerated auxiliary + variable. + Useful for applying statistics along multiple cubes. The time coordinate is + removed by this function. + + Parameters + ---------- + cube_list : list + List or iterable of cubes with the same coordinates. + aux_name : str, optional + Name of the new auxiliary coordinate. Defaults to "dataset". + equalize : bool, optional + Drops differences in attributes, otherwise raises an error. + Defaults to True. + points : list, optional + Set values or labels as new coordinate points. + + Returns + ------- + iris.cube.Cube + Merged cube with an enumerated auxiliary variable. + """ + for ds_index, ds_cube in enumerate(cube_list): + coord = iris.coords.AuxCoord(ds_index, long_name=aux_name) + ds_cube.add_aux_coord(coord) + cubes = iris.cube.CubeList(cube_list) + logger.info(f"formed {type(cubes)}: {cubes}") + if equalize: + removed = equalise_attributes(cubes) + logger.info(f"removed different attributes: {removed}") + for cube in cubes: + cube.remove_coord("time") + merged = cubes.merge_cube() + return merged + + +def fold_meta(cfg, meta, cfg_keys=["locations", "intervals"], + meta_keys=["dataset", "exp"], vars=None): + """ + Creates all combinations of available metadata (meta_keys) and data + specific constraints (cfg_keys). cfg.variables overwrites meta.short_names. + + Parameters + ---------- + cfg : dict + Plot specific configuration with cfg_keys on root level. + meta : list + Full meta data including ancestor files. + cfg_keys : list, optional + Config entries used for product. Defaults to ["locations", "intervals"]. + meta_keys : list, optional + Keys for each meta used for product, short_name added automatically. + Defaults to ["dataset", "exp"]. + vars : list, optional + Variables to be used. Defaults to None. + + Returns + ------- + combinations : itertools.product + All combinations of the metadata and constraints. + groups : dict + Grouped metadata. + meta_keys : list + List of metadata keys. + """ + if not vars: + vars = cfg.get("variables", ["pdsi", "spi"]) + + groups = {gk: list(group_metadata(select_metadata(meta, short_name=vars[0]), gk).keys()) + for gk in meta_keys} + meta_keys.append("short_name") + groups["short_name"] = vars + g_map = {"locations": "location", "intervals": "interval"} + for ck in cfg_keys: + try: + groups[g_map.get(ck, ck)] = cfg[ck] + except KeyError: + logger.warning(f"No '{ck}' found in plot config") + combinations = it.product(*groups.values()) + return combinations, groups, meta_keys + + +def select_meta_from_combi(meta, combi, groups): + """selects one meta data from list (filter valid keys) + + Args: + meta (list): [description] + combi (dict): [description] + groups (dict): [description] + """ + this_cfg = dict(zip(groups.keys(), combi)) + filter_cfg = clean_meta(this_cfg) # remove non meta keys + this_meta = select_metadata(meta, **filter_cfg)[0] + return this_meta, this_cfg + + +def list_meta_keys(meta, group): + return list(group_metadata(meta, group).keys()) + + + +def mkplotdir(cfg, dname): + new_dir = os.path.join(cfg["plot_dir"], dname) + if not os.path.isdir(new_dir): + os.mkdir(new_dir) + + +def sort_cube(cube, coord="longitude"): + """ return a cube with data sorted along a one + dimensional numerical coordinate. + Source: https://gist.github.com/pelson/9763057 + + :param cube: iris cube that should be sorted + :param coord: 1dim coord to sort the cube + :return: + """ + coord_to_sort = cube.coord(coord) + assert coord_to_sort.ndim == 1, 'One dim coords only please.' + dim, = cube.coord_dims(coord_to_sort) + index = [slice(None)] * cube.ndim + index[dim] = np.argsort(coord_to_sort.points) + return cube[tuple(index)] + + +def fix_longitude(cube): + """ return a cube with 0 centered longitude coords. + updating the longitude coord and sorting the data accordingly + """ + # make sure coords are -180 to 180 + fixed_lons = [l if l < 180 else l - + 360 for l in cube.coord("longitude").points] + try: + cube.add_aux_coord(iris.coords.AuxCoord( + fixed_lons, long_name='fixed_lon'), 2) + except Exception as e: + logger.warning("TODO: hardcoded dimensions in ut.fix_longitude") + cube.add_aux_coord(iris.coords.AuxCoord( + fixed_lons, long_name='fixed_lon'), 1) + # sort data and fixed coordinates + cube = sort_cube(cube, coord='fixed_lon') + # set new coordinates as dimcoords + new_lon = cube.coord('fixed_lon') + new_lon_dims = cube.coord_dims(new_lon) + # print(new_lon_dims) + # Create a new coordinate which is a DimCoord. + # NOTE: The var name becomes the dim name + longitude = iris.coords.DimCoord.from_coord(new_lon) + longitude.rename('longitude') + # Remove the AuxCoord, old longitude and add the new DimCoord. + cube.remove_coord(new_lon) + cube.remove_coord(cube.coord('longitude')) + cube.add_dim_coord(longitude, new_lon_dims) + return cube + + +def get_datetime_coords(cube, coord="time"): + """ returns the time coordinate of the cube converted + from cf_date to mpl compatible datetime objects. + TODO: this seems not to work with current Iris version? + but cf_date.dateime contains the year aswell + TODO: the difference behaviour may be caused by different time coords in datasets + nc.num2date default behaviour changed to use only_cf_times, change back with + only_use_python_datetimes=True + """ + tc = cube.coord(coord) + # logger.info(tc.units) + fixed = iplt._fixup_dates(tc, tc.points) + # logger.info(tc.points[0:12]) + # fixed = num2date(tc.points, str(tc.units), only_use_python_datetimes=True) + # logger.info(fixed) + # try: + # fixed = [f.datetime for f in fixed] + # except Exception as e: + # logger.info("probably already in datetime format?") + # logger.info(e) + return fixed + + +def get_start_year(cube, coord="time"): + """ returns the year of the first time coord point. + Works for datetime and cf_calendar types + """ + tc = cube.coord(coord) + first = iplt._fixup_dates(tc, tc.points)[0] + try: + return first.datetime.year + except: + return first.year + + +def get_meta_list(meta, group, select=None): + """list all entries found for the group key as a list. + with a given selection, the meta data will be filtered first. + + Args: + meta (dict): full meta data + group (str): key to search for. Defaults to "alias" + select (dict, optional): dict like {'short_name': 'pdsi'} that is passed to a selection. + + Returns: + list: of collected values for group key. + """ + if select is not None: + meta = select_metadata(meta, **select) + return list(group_metadata(meta, group).keys()) + + +def get_datasets(cfg): + metadata = cfg['input_data'].values() + return group_metadata(metadata, 'dataset').keys() + + +def get_dataset_scenarios(cfg): + """ + returns iterable of meta data for all dataset/scenario combinations + :param cfg: + :return: + """ + metadata = cfg['input_data'].values() + input_datasets = group_metadata(metadata, 'dataset').keys() + input_scenarios = group_metadata(metadata, 'alias').keys() + return it.product(input_datasets, input_scenarios) + + +def date_tick_layout(fig, ax, + dates=None, + label="Time", + auto=True, + years=1): + """ update a figure (timeline) to use + date/year ticks and grid. + :param fig: figure that will be updated + :param ax: ax to set ticks/labels/limits on + :param dates: optional, to set limits + :param label: ax label + :param auto: if true auto format instead of year + :param years: if not auto, tick every x years + :return: nothing, updates figure in place + """ + ax.set_xlabel(label) + if dates is not None: + datemin = np.datetime64(dates[0], 'Y') + datemax = np.datetime64(dates[-1], 'Y') + np.timedelta64(1, 'Y') + ax.set_xlim(datemin, datemax) + if auto: + locator = mdates.AutoDateLocator() + min_locator = mdates.YearLocator(1) + else: + locator = mdates.YearLocator(years) + min_locator = mdates.YearLocator(1) + year_formatter = mdates.DateFormatter('%Y') + ax.grid(True) + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(year_formatter) + ax.xaxis.set_minor_locator(min_locator) + fig.autofmt_xdate() # align, rotate and space for tick labels + + +def auto_tick_layout(fig, ax, dates=None): + ax.set_xlabel('Time') + if dates is not None: + datemin = np.datetime64(dates[0], 'Y') + datemax = np.datetime64(dates[-1], 'Y') + np.timedelta64(1, 'Y') + ax.set_xlim(datemin, datemax) + year_locator = mdates.YearLocator(1) + months_locator = mdates.MonthLocator() + year_formatter = mdates.DateFormatter('%Y') + ax.grid(True) + ax.xaxis.set_major_locator(year_locator) + ax.xaxis.set_major_formatter(year_formatter) + ax.xaxis.set_minor_locator(months_locator) + fig.autofmt_xdate() # align, rotate and space for tick labels + + +def map_land_layout(fig, ax, plot, bounds, var, cbar=True): + """ plot style for rectangular drought maps + masks the ocean by overlay, + add gridlines, + set left/bottom tick labels + TODO: make this a method of MapPlot class + """ + ax.coastlines() + ax.add_feature(ct.feature.OCEAN, + edgecolor='black', + facecolor='white', + zorder=1) + gl = ax.gridlines(crs=ct.crs.PlateCarree(), + linewidth=1, + color='black', + alpha=0.6, + linestyle='--', + draw_labels=True, zorder=2) + gl.xlabels_top = False + gl.ylabels_right = False + if bounds is not None and cbar: + cb = plt.colorbar(plot, ax=ax, ticks=bounds, + extend="both", fraction=0.022) + # cb = plt.colorbar(plot, ax=ax, ticks=bounds[0:-1:]+[bounds[-1]], extend="both", fraction=0.022) + elif cbar: + cb = plt.colorbar(plot, ax=ax, extend="both", fraction=0.022) + # cb.set_label(f"{var.upper()}") + # fig.tight_layout() + + +def get_cubes_dataset_alias(cfg): + pass + + +def get_scenarios(meta, **kwargs): + selected = select_metadata(meta, **kwargs) + return list(group_metadata(selected, 'alias').keys()) + + +def add_preprocessor_input(cfg): + """ + NOTE: One can add preprocessor output/variables as ancestor too, no need + to do it in the diagnostic... + """ + logger.warning("Please add variables to the ancestor list instead. This function will be removed in the future.") + run_dir = os.path.dirname(cfg['run_dir']) + pp_dir = '/preproc/'.join(run_dir.rsplit('/run/', 1)) + fake_cfg = {'input_files': [pp_dir]} + cfg['input_data'].update(_get_input_data_files(fake_cfg)) + + +def add_ancestor_input(cfg): + """Read ancestors settings.yml and + add it's input_data to this config. + TODO: make sure it don't break for non ancestor scripts + TODO: recursive? (optional) + """ + logger.info(f"add ancestors for {cfg[n.INPUT_FILES]}") + for input_file in cfg[n.INPUT_FILES]: + cfg_anc_file = '/run/'.join(input_file.rsplit('/work/', 1) + ) + '/settings.yml' + cfg_anc = get_cfg(cfg_anc_file) + cfg['input_data'].update(_get_input_data_files(cfg_anc)) + + +def add_meta_files(cfg): + pass + + +def _fixup_dates(coord, values): + """ copy from iris plot.py source code """ + if coord.units.calendar is not None and values.ndim == 1: + # Convert coordinate values into tuples of + # (year, month, day, hour, min, sec) + dates = [coord.units.num2date(val).timetuple()[0:6] for val in values] + if coord.units.calendar == "gregorian": + r = [dt.datetime(*date) for date in dates] + else: + try: + import cftime + import nc_time_axis + except ImportError: + msg = ( + "Cannot plot against time in a non-gregorian " + 'calendar, because "nc_time_axis" is not available : ' + "Install the package from " + "https://github.com/SciTools/nc-time-axis to enable " + "this usage." + ) + raise iris.IrisError(msg) + + r = [ + nc_time_axis.CalendarDateTime( + cftime.datetime(*date), coord.units.calendar + ) + for date in dates + ] + values = np.empty(len(r), dtype=object) + values[:] = r + return values + + +def quick_save(cube, name, cfg): + """Simply save cube to netcdf file without additional information + + Args: + cube: iris.cube object to save + name: basename for the created netcdf file + cfg: user configuration containing file output path + """ + if cfg.get('write_netcdf', True): + diag_file = get_diagnostic_filename(name, cfg) + logger.info(f'quick save {diag_file}') + iris.save(cube, target=diag_file) + + +def quick_load(cfg, context, strict=True): + """ + select input files from config wich matches the selection. loads and returns the first match. + raises an error (if strict) or a warning for multiple matches. + """ + meta = cfg["input_data"].values() + var_meta = select_metadata(meta, **context) + if len(var_meta) != 1: + if strict: + raise Exception() + # logger.warning(f"Unexpected amount of matching meta data: {len(var_meta)}") + print("warning meta data missmatch") + return iris.load_cube(var_meta[0]["filename"]) + + +def smooth(dat, window=32, mode="same"): + # smooth + # from scipy.ndimage.filters import uniform_filter1d as unifilter + # TODO: iris can also directly filter on cubes: + # https://scitools-iris.readthedocs.io/en/latest/generated/gallery/general/plot_SOI_filtering.html#sphx-glr-generated-gallery-general-plot-soi-filtering-py + # smoothed = unifilter(dat, 32) # smooth over 4 year window + # using numpy convol + filter = np.ones(window) + return np.convolve(dat, filter, mode) / window + + +def get_basename(cfg, meta, prefix=None, suffix=None): + formats = { # TODO: load this from config-developer.yml? + 'CMIP6': '{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_{grid}_{start_year}-{end_year}', + 'OBS': '{project}_{dataset}_{type}_{version}_{mip}_{short_name}_{start_year}-{end_year}' + } + basename = formats[meta['project']].format(**meta) + if suffix: basename += f"_{suffix}" + if prefix: basename = f"{prefix}_{basename}" + return basename + + +def get_custom_basename(meta, folder="plots", prefix=None, suffix=None): + """manually create basenames for output files + + NOTE: for basename in configured naming format use get_basename + """ + defaults = { + "variable": "variable", + "alias": "alias", + } + defaults.update(meta) + base = f"{folder}/" + if prefix: + base += f"{prefix}_" + base += "{alias}_{variable}".format(**defaults) + if suffix: + base += f"_{suffix}" + return base + + +def clean_meta(meta, **kwargs): + # TODO: incomplete keylist + valid_keys = ["short_name", "dataset", "alias", "exp"] + return {key: val for key, val in meta.items() if key in valid_keys} + + +def select_single_metadata(meta, strict=True, **kwargs): + """Filter meta data by arbitrary keys and return one matching result. + + For more/less then one match the first/none is returned or an error is + raised with strict=True. + + Parameters + ---------- + meta + esmvaltool meta data dict + strict, optional + Raise error if not exactly one match exists, by default True + + Returns + ------- + Dict: One value from the input meta + + Raises + ------ + ValueError + Too many matching entries + ValueError + No matching entry + """ + selected_meta = select_metadata(meta, **kwargs) + # logger.info("dataset from alias: " + dataset) + if len(selected_meta) > 1: + logger.warning(f"Multiple entries found for Metadata: {selected_meta}") + if strict: + raise ValueError("Too many matching entries") + elif len(selected_meta) == 0: + logger.warning(f"No Metadata found! For: {kwargs}") + logger.debug(f"{meta}") + if strict: + raise ValueError("No matching entry") + return None + return selected_meta[0] + + +def select_single_meta(*args, **kwargs): + return select_single_metadata(*args, **kwargs) + + +def date_to_months(date, start_year): + """translates datestings YYYY-MM to + total months since begin of start_year + """ + years, months = [int(x) for x in date.split("-")] + return 12*(years-start_year)+months + + +def fix_interval(interval): + """Ensure that an interval has a label and a range. + TODO: replace "/" with "_" in diagnostics who use this. + """ + if "range" not in interval: + interval["range"] = f"{interval['start']}/{interval['end']}" + if "label" not in interval: + interval["label"] = interval["range"] + return interval + + +def get_plot_filename(cfg, basename, meta=dict(), replace=dict()): + """Get a valid path for saving a diagnostic plot. + This is an alternative to shared.get_diagnostic_filename. + It uses cfg as first argument and accept metadata to format the basename. + + Parameters + ---------- + cfg: dict + Dictionary with diagnostic configuration. + basename: str + The basename of the file. + meta: dict + Metadata to format the basename. + replace: dict + Dictionary with strings to replace in the basename. + + Returns + ------- + str: + A valid path for saving a diagnostic plot. + """ + basename = basename.format(**meta) + for key, value in replace.items(): + basename = basename.replace(key, value) + return os.path.join( + cfg['plot_dir'], + f"{basename}.{cfg['output_file_type']}", + ) + + +def slice_cube_interval(cube, interval): + """ returns a cube slice for given interval + which is a list of strings (YYYY-mm) or int (index of cube) + NOTE: For 3D cubes (time first) + """ + if isinstance(interval[0], int) and isinstance(interval[1], int): + return cube[interval[0]:interval[1], :, :] + dt_start = dt.datetime.strptime(interval[0], "%Y-%m") + dt_end = dt.datetime.strptime(interval[1], "%Y-%m") + time = cube.coord("time") + t_start = time.nearest_neighbour_index(time.units.date2num(dt_start)) + t_end = time.nearest_neighbour_index(time.units.date2num(dt_end)) + return cube[t_start:t_end, :, :] + + +def find_first(nparr): + """finds first nonzero element of numpy array and returns index or -1. + Its faster than looping or using numpy.where. + https://stackoverflow.com/a/61117770/7268121 + nparr: (numpy.array) requires numerical data without negative zeroes. + """ + idx = nparr.view(bool).argmax() // nparr.itemsize + return idx if np.arr[idx] else -1 + + +def add_spei_meta(cfg, name="spei", pos=0): + """ NOTE: workaround to add missing meta for specific ancestor script + """ + logger.info('adding meta file for save_spei output') + spei_fname = cfg['tmp_meta']['filename'].split( + '/')[-1].replace('_pr_', f'_{name}_') + # FIXME: hardcoded index in input files.. needs to match recipe + spei_file = os.path.join(cfg['input_files'][pos], spei_fname) + logger.info(f'spei file path: {spei_file}') + logger.info('generating missing meta info') + meta = cfg["tmp_meta"].copy() + meta['filename'] = spei_file + meta['short_name'] = name + meta['long_name'] = 'Standardised Precipitation-Evapotranspiration Index' + # meta['standard_name'] = 'standardised_precipitation_evapotranspiration_index' + if name.lower() == "spi": + meta['long_name'] = 'Standardised Precipitation Index' + # meta['standard_name'] = 'standardised_precipitation_index' + cfg["input_data"][spei_file] = meta + + +def fix_calendar(cube): + """Fix Calendar. + Fixes wrong calendars to 'gregorian' instead of 'proleptic_gregorian' or '365_day' or any other. + TODO: use pp.regrid_time when available in esmvalcore. + + Args: + cube (iris.cube.Cube): Input cubes which need to be fixed. + + Returns: + iris.cube.Cube: with fixed calendars + + """ + time = cube.coord('time') + # if time.units.name == "days since 1850-1-1 00:00:00": + logger.info("renaming unit") + time.units = Unit("days since 1850-01-01", + calendar=time.units.calendar) + if time.units.calendar == 'proleptic_gregorian': + time.units = Unit(time.units.name, calendar='gregorian') + logger.info(f"renamed calendar: {time.units.calendar}") + if time.units.calendar != 'gregorian': + time.convert_units(Unit("days since 1850-01-01", + calendar='gregorian')) + logger.info(f"converted time to: {time.units}") + return cube + + +def latlon_coords(cube): + """replace latitude and longitude + with lat and lon inplace + TODO: make this good! + """ + try: + cube.coord("longitude").rename("lon") + except Exception as e: + logging.info("no coord named longitude") + print(e) + try: + cube.coord("latitude").rename("lat") + except: + logging.info("no coord named latitude") + + +def standard_time(cubes): + """Make sure all cubes' share the standard time coordinate. + This function extracts the date information from the cube and + reconstructs the time coordinate, resetting the actual dates to the + 15th of the month or 1st of july for yearly data (consistent with + `regrid_time`), so that there are no mismatches in the time arrays. + It will use reset the calendar to + a default gregorian calendar with unit "days since 1850-01-01". + Might not work for (sub)daily data, because different calendars may have + different number of days in the year. + NOTE: this might be replaced by preprocessor + """ + from datetime import datetime + + from esmvalcore.iris_helpers import date2num + t_unit = Unit("days since 1850-01-01", calendar="standard") + + for cube in cubes: + # Extract date info from cube + coord = cube.coord('time') + years = [p.year for p in coord.units.num2date(coord.points)] + months = [p.month for p in coord.units.num2date(coord.points)] + days = [p.day for p in coord.units.num2date(coord.points)] + + # Reconstruct default calendar + if 0 not in np.diff(years): + # yearly data + dates = [datetime(year, 7, 1, 0, 0, 0) for year in years] + elif 0 not in np.diff(months): + # monthly data + dates = [ + datetime(year, month, 15, 0, 0, 0) + for year, month in zip(years, months) + ] + elif 0 not in np.diff(days): + # daily data + dates = [ + datetime(year, month, day, 0, 0, 0) + for year, month, day in zip(years, months, days) + ] + if coord.units != t_unit: + logger.warning( + "Multimodel encountered (sub)daily data and inconsistent " + "time units or calendars. Attempting to continue, but " + "might produce unexpected results.") + else: + raise ValueError( + "Multimodel statistics preprocessor currently does not " + "support sub-daily data.") + + # Update the cubes' time coordinate (both point values and the units!) + cube.coord('time').points = date2num(dates, t_unit, coord.dtype) + cube.coord('time').units = t_unit + cube.coord('time').bounds = None + cube.coord('time').guess_bounds() + + +def guess_lat_lon_bounds(cube): + """guesses bounds for latitude and longitude if not existent. + """ + if not cube.coord("latitude").has_bounds(): + cube.coord("latitude").guess_bounds() + if not cube.coord("longitude").has_bounds(): + cube.coord("longitude").guess_bounds() + + +def mmm(cube_list, mdtol=0, dropcoords=["time"], dropmethods=False): + """calculates mean and stdev along a cube list over all cubes returns two (mean and stdev) of same shape + TODO: merge alreadey exist, use that one, mean and std is trivial than. + Args: + cube_list ([type]): [description] + """ + for i, cube in enumerate(cube_list): + # code.interact(local=locals()) + for coord_name in dropcoords: + if cube.coords(coord_name): + cube.remove_coord(coord_name) + if dropmethods: + cube.cell_methods = None + cube.add_aux_coord(iris.coords.AuxCoord( + i, long_name="dataset")) + # equalise_attributes(cube_list) + # cube.remove_coord("season_number") # add as drop_coord + cube_list = iris.cube.CubeList(cube_list) + equalise_attributes(cube_list) + # common_depth = + try: + # Note: just for testing: + merged = cube_list.merge_cube() + except iris.exceptions.MergeError as err: + # unique_seasons = set() + # for c in cube_list: + # unique_seasons.add(c.coord("season_number")) + # print(unique_seasons) + # for c in cube_list: + # print(c.coord("depth")) + iris.util.describe_diff(cube_list[0], cube_list[1]) + raise err + if mdtol > 0: + logger.info(f"performing MMM with tolerance: {mdtol}") + mean = merged.collapsed('dataset', iris.analysis.MEAN, mdtol=mdtol) + sdev = merged.collapsed('dataset', iris.analysis.STD_DEV) + return mean, sdev + + +def get_hex_positions(): + return { + "NWN": [2, 0], + "NEN": [4, 0], + "GIC": [6.5, -0.5], + "NEU": [14, 0], + "RAR": [20, 0], + "WNA": [1, 1], + "CNA": [3, 1], + "ENA": [5, 1], + "WCE": [13, 1], + "EEU": [15, 1], + "WSB": [17, 1], + "ESB": [19, 1], + "RFE": [21, 1], + "NCA": [2, 2], + "MED": [14, 2], + "WCA": [16, 2], + "ECA": [18, 2], + "TIB": [20, 2], + "EAS": [22, 2], + "SCA": [3, 3], + # "CAR": [5, 3], + "SAH": [13, 3], + "ARP": [15, 3], + "SAS": [19, 3], + "SEA": [23, 3], + # "PAC": [27.5, 3.3], + "NWS": [6, 4], + "NSA": [8, 4], + "WAF": [12, 4], + "CAF": [14, 4], + "NEAF": [16, 4], + "NAU": [24.5, 4.3], + "SAM": [7, 5], + "NES": [9, 5], + "WSAF": [13, 5], + "SEAF": [15, 5], + "MDG": [17.5, 5.3], + "CAU": [23.5, 5.3], + "EAU": [25.5, 5.3], + "SWS": [6, 6], + "SES": [8, 6], + "ESAF": [14, 6], + "SAU": [24.5, 6.3], + "NZ": [27, 6.5], + "SSA": [7, 7], + } + + +def get_region_data(): + """reads shapes.txt as csv file and returns a list of region names + """ + fname = "/work/bd0854/b309169/ESMValTool-private/esmvaltool/diag_scripts/droughtindex/shapes.txt" + data = pd.read_csv(fname, sep=",", header=0, skiprows=0) + print(data) + return data + + +def get_region_abbrs(): + # abbr_positions = get_hex_positions() + data = get_region_data() + data.set_index("Name", inplace=True) + return { + i: data.loc[i]["Abbr"] + for i in data.index.tolist() + } + + +def add_aux_regions(cube, mask=None): + """ Add an auxilary coordinate with region numbers. + Dimension names: 'lat' and 'lon'. + TODO: add option/fallback to use pp with shapefile instead + """ + import regionmask + region = regionmask.defined_regions.ar6.land + xarr = xr.DataArray.from_iris(cube) + mask2d = mask if mask else region.mask(xarr).to_iris() + # newcube = cube.copy() + region_coord = iris.coords.AuxCoord( + mask2d.data, long_name="AR6 reference region", var_name="region") + cube.add_aux_coord(region_coord, [1, 2]) + return cube + + +def regional_mean(cube, mask=None, minmax=False, stddev=False,): + """ Returns a cube with one dim_coord 'region' + which contains weighted means over 'lat', 'lon'. + TODO: allow minmax and stddev at the same time. Different returns or + return a dict? maybe add an metrics array: [mean, min, max, sum ...] + TODO: provide an alternative based on shape file and preprocessor in case + regionmask is not installed? + TODO: pass config and/or aux file path? + TODO: DEPRECATED use more general regiona_stats instead (includes maean) + """ + logger.warning("Please use regional_stats instead of regional_mean") + if minmax: + res = regional_stats_xarr({}, cube, operators=["min", "mean", "max"]).values() + return res["min", "mean", "max"] + if stddev: + res = regional_stats_xarr({}, cube, operators['mean', "std_dev"]) + return res["mean"], res["std_dev"] + res = regional_stats_xarr({}, cube, operators=["min", "mean", "max"]).values() + return res["mean"] + + +def regional_stats_xarr(cfg, cube, operators=['mean'], shapefile=None): + results = {} + if shapefile: + return regional_stats(cfg, cube, operators, shapefile) + try: + import regionmask + except: + err = "No Module regionmask. Install it or provide a shapefile." + raise ModuleError(err) + latlon_coords(cube) + region = regionmask.defined_regions.ar6.land + xarr = xr.DataArray.from_iris(cube) + mask3d = mask if mask else region.mask_3D(xarr) + weights = np.cos(np.deg2rad(xarr.lat)) # TODO: use cell area + weights3d = mask3d * weights + weighted = xarr.weighted(weights3d) + if "mean" in operators: + mean = weighted.mean(dim=("lat", "lon")) + result['mean'] = mean.to_iris(), + # stddev = weighted.sum_of_squares() / weights3d.sum() + if "std_dev" in operators: + stddev = weighted.std(dim=("lat", "lon")) + result['std_dev'] = stddev.to_iris() + if "min" in operators: + mini = xarr.where(mask3d).min(dim=("lat", "lon")) + result['min'] = mini.to_iris() + if "max" in operators: + maxi = xarr.where(mask3d).max(dim=("lat", "lon")) + result['max'] = maxi.to_iris() + return result + + +def regional_stats(cfg, cube, operator='mean'): + """Mean over IPCC Reference regions using shape file. + The shapefile (string) must be contained in cft (given by recipe). + It can be an absolute path or relative to the folder for auxilary data + configured in esmvaltool config. + """ + # if "shapefile" not in cfg: + # raise ValueError("A shapefile must be given or \ + # utils.regional_stats_xarr() be used with module regionmask.") + # shapefile = Path(cfg['auxiliary_data_dir']) / cfg['shapefile'] + guess_lat_lon_bounds(cube) + extracted = pp.extract_shape(cube, 'ar6', decomposed=True) + + return pp.area_statistics(extracted, operator) + + +def transpose_by_names(cube, names): + """ transposes an iris cube by dim-coords or their names """ + new_dims = [cube.coord_dims(name)[0] for name in names] + print(new_dims) + cube.transpose(new_dims) + + +def generate_metadata(work_folder, diag=None): + """create metadata from ancestor config and nc files. + + Can be used as workaround, to handle output of ancestors, that don't create + metadata.yml, similar to preprocessed variables. If metadata.yml exists in + the work folder (subfolders ignored) its content is returned instead. + """ + try: + return yaml.load(os.path.join(work_folder), "metadata.yml") + except: + logger.warning(f"no metadata.yml found in {work_folder}.") + raise NotImplementedError("TODO: generate metadata from cubes and cfg") + # TODO: provide fixes for some diagnostics or directly implement it. + # meta = {} + # cfg = yaml.read() + # nc_files = + + +def save_metadata(cfg, metadata): + """save dict as metadata.yml in workfolder.""" + with open(os.path.join(cfg["work_dir"], "metadata.yml"), "w") as wom: + yaml.dump(metadata, wom) + + +def get_meta(index): + index = index.lower() + if index == "pdsi": + return dict( + variable_group='index', + standard_name='palmer_drought_severity_index', + short_name='pdsi', + long_name='Palmer Drought Severity Index', + units='1') + elif index in ["scpdsi", "sc-pdsi"]: + return dict( + variable_group='index', + standard_name='self_calibrated_palmer_drought_severity_index', + short_name='scpdsi', + long_name='Self Calibrated Palmer Drought Severity Index', + units='1') + else: + logger.error(f"No default meta data for Index: {index}") + raise NotImplementedError + + +def set_defaults(target, defaults): + """Applies set_default on target for each entry of defaults + + This checks if a key exists in target, and only if not the keys are set with + values. It does not checks recursively for nested entries. + + NOTE: this might be obsolete since python 3.9, as there are direct + fallback assignments like: `target = defaults | target` + + Parameters + ---------- + target + dictionary to set the defaults on + defaults + dictionary containtaining defaults to be set on target + """ + for key in defaults.keys(): + target.setdefault(key, defaults[key]) + + +def sub_cfg(cfg, plot, key): + """Get get merged general and plot type specific kwargs.""" + if isinstance(cfg.get(key, {}), dict): + general = cfg.get(key, {}).copy() + specific = cfg.get(plot, {}).get(key, {}) + general.update(specific) + return general + else: + try: + return cfg[plot][key] + except KeyError: + return cfg[key] + + +def aux_path(cfg, path): + """returns absolut path of an aux file.""" + if os.path.isabs(path): + return path + else: + return os.path.join(cfg["auxiliary_data_dir"], path) + + +def remove_attributes(cube, ignore=[]): + """remove attributes of cubes or coords in place + used to clean up differences in cubes coordinates before merging + + Parameters + ---------- + cube + iris.Cube or iris.Coord + ignore + Optional: List of Strings of attributes, that are not removed + Default: [] + """ + remove = [] + for attr in cube.attributes: + if not attr in ignore: + remove.append(attr) + for attr in remove: + del cube.attributes[attr] + + +def convert_to_mmday_xarray(pr): + """convert precipitation of xarray from kg/m2/s to mm/day + + Args: + pr: xarray.dataarrray precipitation in kgm-2s-1 + """ + pr.values = pr.values * 60 *60 * 24 + pr.attrs['units'] = "mm day-1" + return pr + + +def font_color(background): + """black or white depending on greyscale of the background + + Parameters + ---------- + bacgkround + matplotlib color + """ + if sum(mpl.colors.to_rgb(background)) > 1.5: + return "black" + else: + return "white" + + +def log_provenance(cfg, fname, record): + + with ProvenanceLogger(cfg) as provenance_logger: + provenance_logger.log(fname, record) + + +def get_time_range(cube): + """guesses the period of a cube based on the time coordinate. + """ + if not isinstance(cube, iris.cube.Cube): + cube = iris.load_cube(cube) + time = cube.coord("time") + print(time) + print(time.points) + start = time.units.num2date(time.points[0]) + end = time.units.num2date(time.points[-1]) + return {'start_year': start.year, 'end_year': end.year} + + +def guess_experiment(meta): + """guess experiment from filename + TODO: this is a workaround for incomplete meta data.. + fix this in ancestor diagnostics rather than use this function. + """ + exps = ["historical", "ssp126", "ssp245", "ssp370", "ssp585"] + for exp in exps: + if exp in meta["filename"]: + meta["exp"] = exp + + +def monthly_to_daily(cube, units="mm day-1", leap_years=True): + """convert monthly data to daily data inplace ignoring leap years + """ + months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + months = months * int((cube.shape[0] / 12)+1) + for i, s in enumerate(cube.slices_over(["time"])): + if not leap_years: + days = months[i] + cube.data[i] = cube.data[i] / days + try: + from calendar import monthrange + time = s.coord("time") + date = time.units.num2date(time.points[0]) + days = monthrange(date.year, date.month)[1] + except Exception as e: + logger.warning("date failed, using fixed days without leap year") + logger.warning(e) + days = months[i] + cube.data[i] = cube.data[i] / days + cube.units = units + + +def daily_to_monthly(cube, units="mm month-1", leap_years=True): + """convert daily data to monthly data inplace + with leap_years=False this is similar to the same named function in utils.R + and compatible with pet.R + """ + months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + months = months * int((cube.shape[0] / 12)+1) + for i, s in enumerate(cube.slices_over(["time"])): + if not leap_years: + days = months[i] + cube.data[i] = cube.data[i] * days + continue + try: # consider leapday + from calendar import monthrange + time = s.coord("time") + date = time.units.num2date(time.points[0]) + days = monthrange(date.year, date.month)[1] + except Exception as e: + logger.warning("date failed, using fixed days without leap year") + logger.warning(e) + days = months[i] + cube.data[i] = cube.data[i] * days + cube.units = units + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -"""Drought characteristics and plots based on Martin (2018). -############################################################################### -droughtindex/collect_drought_obs_multi.py -Author: Katja Weigel, Kemisola Adeniyi (IUP, Uni Bremen, Germany) -EVal4CMIP project -############################################################################### -Description ------------ - Functions for: - collect_drought_obs_multi.py and droughtindex/collect_drought_model.py. -Configuration options ---------------------- - None -############################################################################### -""" -import logging -import os -from pprint import pformat -import numpy as np -import iris -from iris.analysis import Aggregator -import cartopy.crs as cart -import matplotlib.pyplot as plt -import matplotlib.dates as mda -from esmvaltool.diag_scripts.shared import (ProvenanceLogger, - get_diagnostic_filename, - get_plot_filename) -logger = logging.getLogger(os.path.basename(__file__)) def _get_data_hlp(axis, data, ilat, ilon): diff --git a/esmvaltool/recipes/droughts/recipe_spei.yml b/esmvaltool/recipes/droughts/recipe_spei.yml index 5ca4d5f832..abb31fc08f 100644 --- a/esmvaltool/recipes/droughts/recipe_spei.yml +++ b/esmvaltool/recipes/droughts/recipe_spei.yml @@ -37,7 +37,7 @@ preprocessors: target_grid: reference_dataset scheme: linear convert_units: - units: mm/day + units: mm day-1 diagnostics: diagnostic: @@ -65,7 +65,26 @@ diagnostics: pet_type: "Hargreaves" # "Penman_clt" script: droughts/pet.R spei: + indexname: SPEI script: droughts/spei.R ancestors: [pr, pet] distribution: log-Logistic - smooth_month: 6 \ No newline at end of file + smooth_month: 6 + plot: + scripts: + map: + script: droughts/diffmap.py + plot_models: True + plot_mmm: False + comparison_period: 1 + metrics: "last" + strip_plots: False + plot_kwargs: + cbar_label: "{long_name}" + cmap: RdYlBu + extend: both + vmin: -2 + vmax: 2 + titles: + last: "Mean 2005" + ancestors: [diagnostic/spi, diagnostic/spei] \ No newline at end of file From 31ae8a9733460c8d7cabf0989939b37cdc574fe9 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 24 Feb 2025 15:23:49 +0100 Subject: [PATCH 25/66] fix unit string in martin recipe --- esmvaltool/recipes/recipe_martin18grl.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/esmvaltool/recipes/recipe_martin18grl.yml b/esmvaltool/recipes/recipe_martin18grl.yml index ead526f164..8b109affb0 100644 --- a/esmvaltool/recipes/recipe_martin18grl.yml +++ b/esmvaltool/recipes/recipe_martin18grl.yml @@ -37,6 +37,7 @@ diagnostics: field: T2Ms start_year: 1901 end_year: 2000 + units: mm day-1 additional_datasets: # - {dataset: ERA-Interim, project: OBS6, mip: Amon, type: reanaly, # version: 1, start_year: 1979, end_year: 2005, tier: 3} From b0ee72bd67e99861d13fadf9e4115706d4a3c2ae Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 24 Feb 2025 18:36:50 +0100 Subject: [PATCH 26/66] fixed some ruff complains --- esmvaltool/diag_scripts/droughts/diffmap.py | 156 ++++++++++---------- 1 file changed, 79 insertions(+), 77 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index 75cc6f6f3e..296ba6c574 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -14,9 +14,6 @@ The produced maps can be clipped to non polar landmasses (220, 170, -55, 90) with `clip_land: True`. -TODO: Can MMM be preprocessed in this case? all operations should be linear. -also percent? - TODO: rename metric and group to their real keys in plotkwargs (allow multi match?) and make group_by accept a list. make sure diffmap_metrics is always added so that it can be consiedered as extra facet for this diagnostic. @@ -65,23 +62,29 @@ metrics: list, optional List of metrics to calculate and plot. For the difference ("percent" and "diff") the mean over two comparison periods ("first" and "last") is - calculated. The "total" periods mean can be calculated and plotted as well. + calculated. The "total" periods mean can be calculated and plotted as well. By default ["first", "last", "diff", "total", "percent"] """ -import iris +from __future__ import annotations + +import contextlib +import logging import os +from collections import defaultdict +from pathlib import Path + +import iris +import matplotlib as mpl +import matplotlib.pyplot as plt import numpy as np import yaml -import matplotlib as mpl +from cartopy.util import add_cyclic_point from esmvalcore import preprocessor as pp from iris.analysis import MEAN -import logging -from collections import defaultdict + import esmvaltool.diag_scripts.droughts.utils as ut -import matplotlib.pyplot as plt import esmvaltool.diag_scripts.shared as e -from cartopy.util import add_cyclic_point # from esmvaltool.diag_scripts.droughts import colors # noqa: F401 @@ -101,20 +104,21 @@ def plot_colorbar( - cfg: dict, + cfg: dict, # noqa: ARG001 plotfile: str, plot_kwargs: dict, - orientation="vertical", - mappable=None, + orientation: str = "vertical", + mappable: mpl.cm.ScalarMappable | None = None, ) -> None: - # fig, ax = plt.subplots(figsize=(1, 4), layout="constrained") + """Plot colorbar in its own figure for strip_plots.""" fig = plt.figure(figsize=(1.5, 3)) # fixed size axes in fixed size figure - cbar_ax = fig.add_axes([0.01, 0.04, 0.2, 0.92]) + cbar_ax = fig.add_axes([0.01, 0.04, 0.2, 0.92]) # type: ignore[call-overload] if mappable is None: cmap = plot_kwargs.get("cmap", "RdYlBu") norm = mpl.colors.Normalize( - vmin=plot_kwargs.get("vmin"), vmax=plot_kwargs.get("vmax") + vmin=plot_kwargs.get("vmin"), + vmax=plot_kwargs.get("vmax"), ) mappable = mpl.cm.ScalarMappable(norm=norm, cmap=cmap) cb = fig.colorbar( @@ -129,15 +133,21 @@ def plot_colorbar( fontsize = plot_kwargs.get("cbar_fontsize", 14) cb.ax.tick_params(labelsize=fontsize) cb.set_label( - plot_kwargs["cbar_label"], fontsize=fontsize, labelpad=fontsize + plot_kwargs["cbar_label"], + fontsize=fontsize, + labelpad=fontsize, ) - - if plotfile.endswith(".png"): - plotfile = plotfile[:-4] + plotfile = plotfile.removesuffix(".png") fig.savefig(plotfile + "_cb.png") # , bbox_inches="tight") -def plot(cfg, meta, cube, basename, kwargs=None): +def plot( + cfg: dict, + meta: dict, + cube: iris.cube, + basename: str, + kwargs: dict | None = None, +) -> None: """Plot map using diag_scripts.shared module.""" plotfile = e.get_plot_filename(basename, cfg) plot_kwargs = cfg.get("plot_kwargs", {}).copy() @@ -145,7 +155,9 @@ def plot(cfg, meta, cube, basename, kwargs=None): plot_kwargs.update(kwargs) if "vmax" in plot_kwargs and "vmin" in plot_kwargs: plot_kwargs["levels"] = np.linspace( - plot_kwargs["vmin"], plot_kwargs["vmax"], 9 + plot_kwargs["vmin"], + plot_kwargs["vmax"], + 9, ) label = plot_kwargs.get("cbar_label", "{short_name} ({units})") plot_kwargs["cbar_label"] = label.format(**meta) @@ -159,14 +171,13 @@ def plot(cfg, meta, cube, basename, kwargs=None): if ( meta["dataset"] == "ERA5" and meta["short_name"] == "evspsblpot" - and len(cube.data[0]) == 360 + and len(cube.data[0]) == 360 # noqa: PLR2004 ): # NOTE: fill missing gap at 360 for era5 pet calculation cube.data[:, 359] = cube.data[:, 0] mapplot = e.plot.global_contourf(cube, **plot_kwargs) if cfg.get("clip_land", False): - plt.gca().set_extent((220, 170, -55, 90)) - # plt.gcf().set_size_inches(6, 3) + plt.gca().set_extent((220, 170, -55, 90)) # type: ignore[attr-defined] plt.title(meta.get("title", basename)) if cfg.get("strip_plots", False): plt.gca().set_title(None) @@ -181,10 +192,11 @@ def plot(cfg, meta, cube, basename, kwargs=None): log.info("saved figure: %s", plotfile) -def apply_plot_kwargs_overwrite(kwargs, overwrites, metric, group): +def apply_plot_kwargs_overwrite( + kwargs: dict, overwrites: dict, metric: str, group: str, +) -> dict: """Apply plot_kwargs_overwrite to kwargs dict for selected plots.""" for overwrite in overwrites: - # print(overwrite) new_kwargs = overwrite.copy() groups = new_kwargs.pop("group", []) if not isinstance(groups, list): @@ -200,9 +212,9 @@ def apply_plot_kwargs_overwrite(kwargs, overwrites, metric, group): return kwargs - def calculate_diff(cfg, meta, mm, output_meta, group, norm): - """absolute difference between first and last years of a cube. + """Absolute difference between first and last years of a cube. + Calculates the absolut difference between the first and last period of a cube. Writing data to mm and plotting each dataset depends on cfg. """ @@ -210,19 +222,14 @@ def calculate_diff(cfg, meta, mm, output_meta, group, norm): cube = iris.load_cube(fname) if meta["short_name"] in cfg.get("convert_units", {}): pp.convert_units(cube, cfg["convert_units"][meta["short_name"]]) - try: # TODO: maybe don't keep this from cmorizer + with contextlib.suppress(Exception): + # TODO: maybe fix this within cmorizer cube.remove_coord("Number of stations") # dropped by unit conversions - except Exception: - pass - if "start_year" in cfg.keys() or "end_year" in cfg.keys(): + if "start_year" in cfg or "end_year" in cfg: log.info("selecting time period") - # print(cfg.keys()) cube = pp.extract_time( cube, cfg["start_year"], 1, 1, cfg["end_year"], 12, 31 ) - print(cube.data.shape) - # if meta["short_name"] in ["evpsblpot"]: - # cube.convert_units("1.e-5 kg m-2 s-1") dtime = cfg.get("comparison_period", 10) * 12 cubes = {} cubes["total"] = cube.collapsed("time", MEAN) @@ -234,8 +241,6 @@ def calculate_diff(cfg, meta, mm, output_meta, group, norm): if any(m in do_metrics for m in ["diff", "percent"]): cubes["diff"] = cubes["last"] - cubes["first"] cubes["diff"].data /= norm - if cubes["diff"].data[0, 0] != np.nan: - print(cubes["diff"]) cubes["diff"].units = str(cubes["diff"].units) + " / 10 years" cubes["percent"] = cubes["diff"] / cubes["first"] * 100 cubes["percent"].units = "% / 10 years" @@ -248,7 +253,8 @@ def calculate_diff(cfg, meta, mm, output_meta, group, norm): meta["diffmap_metric"] = key meta["exp"] = meta.get("exp", "exp") basename = cfg["basename"].format(**meta) - meta["title"] = f" {basename} ({TITLES[key]})" + titles = cfg.get("titles", TITLES) + meta["title"] = titles[key].format(**meta) if cfg.get("plot_models", True): plot_kwargs = cfg.get("plot_kwargs", {}).copy() overwrites = cfg.get("plot_kwargs_overwrite", []) @@ -256,44 +262,44 @@ def calculate_diff(cfg, meta, mm, output_meta, group, norm): plot(cfg, meta, cube, basename, kwargs=plot_kwargs) plt.close() if cfg.get("save_models", True): - work_file = os.path.join(cfg["work_dir"], f"{basename}.nc") + work_file = str(Path(cfg["work_dir"]) / f"{basename}.nc") iris.save(cube, work_file) meta["filename"] = work_file output_meta[work_file] = meta.copy() -def calculate_mmm(cfg, meta, mm, output_meta, group, key="diff"): +def calculate_mmm(cfg, meta, mm, output_meta, group) -> None: """Calculate multi-model mean for a given metric.""" - drop = cfg.get("dropcoords", ["time", "height"]) - meta = meta.copy() # don't modify meta in place: - meta["dataset"] = "MMM" - meta["diffmap_metric"] = key - basename = cfg["basename"].format(**meta) - mmm, _ = ut.mmm( - mm[key], - dropcoords=drop, - dropmethods=key != "diff", - mdtol=cfg.get("mdtol", 0.3), - # mdtol=0, - ) - meta["title"] = f"Multi-model Mean ({cfg['titles'][key]})" - if cfg.get("plot_mmm", True): - plot_kwargs = cfg.get("plot_kwargs", {}).copy() - overwrites = cfg.get("plot_kwargs_overwrite", []) - apply_plot_kwargs_overwrite(plot_kwargs, overwrites, key, group) - plot(cfg, meta, mmm, basename, kwargs=plot_kwargs) - if cfg.get("save_mmm", True): - work_file = os.path.join(cfg["work_dir"], f"{basename}.nc") - meta["filename"] = work_file - meta["diffmap_metric"] = key - output_meta[work_file] = meta.copy() - iris.save(mmm, work_file) + for metric in cfg.get("metrics", METRICS): + drop = cfg.get("dropcoords", ["time", "height"]) + meta = meta.copy() # don't modify meta in place: + meta["dataset"] = "MMM" + meta["diffmap_metric"] = metric + basename = cfg["basename"].format(**meta) + mmm, _ = ut.mmm( + mm[metric], + dropcoords=drop, + dropmethods=metric != "diff", + mdtol=cfg.get("mdtol", 0.3), + ) + meta["title"] = f"Multi-model Mean ({cfg['titles'][metric]})" + if cfg.get("plot_mmm", True): + plot_kwargs = cfg.get("plot_kwargs", {}).copy() + overwrites = cfg.get("plot_kwargs_overwrite", []) + apply_plot_kwargs_overwrite(plot_kwargs, overwrites, metric, group) + plot(cfg, meta, mmm, basename, kwargs=plot_kwargs) + if cfg.get("save_mmm", True): + work_file = str(Path(cfg["work_dir"]) / f"{basename}.nc") + meta["filename"] = work_file + meta["diffmap_metric"] = metric + output_meta[work_file] = meta.copy() + iris.save(mmm, work_file) -def set_defaults(cfg): - """update cfg with default values from diffmap.yml""" +def set_defaults(cfg: dict) -> None: + """Update cfg with default values from diffmap.yml in place.""" config_file = os.path.realpath(__file__)[:-3] + ".yml" - with open(config_file, "r", encoding="utf-8") as f: + with open(config_file, encoding="utf-8") as f: defaults = yaml.safe_load(f) for key, val in defaults.items(): cfg.setdefault(key, val) @@ -301,9 +307,8 @@ def set_defaults(cfg): cfg["plot_kwargs_overwrite"].extend(defaults["plot_kwargs_overwrite"]) -def main(cfg): - """Main function.""" - # cfg["group_by"] = cfg.get("group_by", "short_name") +def main(cfg) -> None: + """Execute Diagnostic.""" set_defaults(cfg) groups = e.group_metadata(cfg["input_data"].values(), cfg["group_by"]) output = {} @@ -311,7 +316,7 @@ def main(cfg): mm = defaultdict(list) skipped = 0 for meta in metas: - # TODO: fix diag_spei output to contain all relevant meta data + # TODO@lukruh: fix diag_spei output to contain all relevant meta data ut.guess_experiment(meta) if "end_year" not in meta: try: @@ -338,10 +343,7 @@ def main(cfg): for metric in cfg.get("metrics", METRICS): calculate_mmm(cfg, metas[0], mm, output, group, metric) ut.save_metadata(cfg, output) - # TODO close all and everything to free up memory - # if "panels" in cfg: - # for grid in cfg["panels"]: - # create_panels(cfg, output, grid) + # TODO@lukruh: close all and everything to free up memory if __name__ == "__main__": From 7b047a148d8ad3bda2772bd4826a65a7728bc4ba Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 24 Feb 2025 18:39:36 +0100 Subject: [PATCH 27/66] add example plots for spei recipe --- .../recipes/droughts/recipe_martin18grl.rst | 4 ++-- .../source/recipes/droughts/recipe_spei.rst | 22 +++++++++++++++++- .../recipes/figures/droughts/spei_example.png | Bin 0 -> 216577 bytes .../recipes/figures/droughts/spi_example.png | Bin 0 -> 211809 bytes doc/sphinx/source/recipes/recipe_droughts.rst | 2 +- esmvaltool/recipes/droughts/recipe_spei.yml | 4 ++-- 6 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 doc/sphinx/source/recipes/figures/droughts/spei_example.png create mode 100644 doc/sphinx/source/recipes/figures/droughts/spi_example.png diff --git a/doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst b/doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst index 86f3d9452e..8a00646317 100644 --- a/doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst +++ b/doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst @@ -74,14 +74,14 @@ Example plots .. _martin18grl_fig1: .. figure:: /recipes/figures/droughtindex/martin18grl_fig1.png :align: center - :width: 50% + :width: 80% Global map of the percentage difference between multi-model mean of 15 CMIP models and the CRU data for the number of drought events [%] based on SPI. .. _martin18grl_fig2: .. figure:: /recipes/figures/droughtindex/martin18grl_fig2.png :align: center - :width: 50% + :width: 80% Global map of the percentage difference between multi-model mean for RCP8.5 scenarios (2050-2100) runs and historical data (1950-2000) for 15 CMIP models for the number of drought events [%] based on SPI. diff --git a/doc/sphinx/source/recipes/droughts/recipe_spei.rst b/doc/sphinx/source/recipes/droughts/recipe_spei.rst index 5aea912496..05eca0420e 100644 --- a/doc/sphinx/source/recipes/droughts/recipe_spei.rst +++ b/doc/sphinx/source/recipes/droughts/recipe_spei.rst @@ -148,4 +148,24 @@ References * Droogers P., Allen R. G., (2002). Estimating reference evapotranspiration under inaccurate data conditions. Irrigation and Drainage Systems 16: 33-45. -* Monteith, J.L., 1965. Evaporation and Environment. 19th Symposia of the Society for Experimental Biology, University Press, Cambridge, 19:205-234. \ No newline at end of file +* Monteith, J.L., 1965. Evaporation and Environment. 19th Symposia of the Society for Experimental Biology, University Press, Cambridge, 19:205-234. + + +Example plots +------------- + +.. _fig_spei_fig1: +.. figure:: /recipes/figures/droughts/spi_example.png + :align: center + :width: 80% + + Example plot of SPI averaged over the year 2005. The reference period for + index calibration is 2000-2005. + +.. _fig_spei_fig2: +.. figure:: /recipes/figures/droughts/spei_example.png + :align: center + :width: 80% + + Example plot of SPEI averaged over the year 2005. The reference period for + index calibration is 2000-2005. \ No newline at end of file diff --git a/doc/sphinx/source/recipes/figures/droughts/spei_example.png b/doc/sphinx/source/recipes/figures/droughts/spei_example.png new file mode 100644 index 0000000000000000000000000000000000000000..d350f124c402569b8250dde263d87c2a77a90453 GIT binary patch literal 216577 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%0AfC-aE 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 WATpHm1C8zhpO-4Hm1~tO!~X~6hiljX literal 0 HcmV?d00001 diff --git a/doc/sphinx/source/recipes/recipe_droughts.rst b/doc/sphinx/source/recipes/recipe_droughts.rst index ca081b62d2..49fa73e91a 100644 --- a/doc/sphinx/source/recipes/recipe_droughts.rst +++ b/doc/sphinx/source/recipes/recipe_droughts.rst @@ -56,7 +56,7 @@ Available recipes and diagnostics .. toctree:: :maxdepth: 1 - droughts/recipes_consecdrydays + droughts/recipe_consecdrydays droughts/recipe_spei droughts/recipe_martin18grl diff --git a/esmvaltool/recipes/droughts/recipe_spei.yml b/esmvaltool/recipes/droughts/recipe_spei.yml index abb31fc08f..43a7c2d631 100644 --- a/esmvaltool/recipes/droughts/recipe_spei.yml +++ b/esmvaltool/recipes/droughts/recipe_spei.yml @@ -80,11 +80,11 @@ diagnostics: metrics: "last" strip_plots: False plot_kwargs: - cbar_label: "{long_name}" + cbar_label: "{short_name}" cmap: RdYlBu extend: both vmin: -2 vmax: 2 titles: - last: "Mean 2005" + last: "{dataset} Yearly Average 2005" ancestors: [diagnostic/spi, diagnostic/spei] \ No newline at end of file From de83aa18cf58d623830261721469982367d4ee69 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 24 Feb 2025 19:18:55 +0100 Subject: [PATCH 28/66] fix function call and auto format utils.py --- esmvaltool/diag_scripts/droughts/diffmap.py | 3 +- esmvaltool/diag_scripts/droughts/utils.py | 1436 +++++++++++-------- 2 files changed, 824 insertions(+), 615 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index 296ba6c574..d0120a9e27 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -340,8 +340,7 @@ def main(cfg) -> None: calculate_diff(cfg, meta, mm, output, group, norm) do_mmm = cfg.get("plot_mmm", True) or cfg.get("save_mmm", True) if do_mmm and len(metas) > 1: - for metric in cfg.get("metrics", METRICS): - calculate_mmm(cfg, metas[0], mm, output, group, metric) + calculate_mmm(cfg, metas[0], mm, output, group) ut.save_metadata(cfg, output) # TODO@lukruh: close all and everything to free up memory diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 2468503f4a..1040b314f4 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -1,3 +1,4 @@ +from __future__ import annotations import datetime as dt import itertools as it import logging @@ -6,6 +7,7 @@ from os.path import dirname as par_dir from pathlib import Path from pprint import pformat +from tkinter import W import cartopy as ct import cartopy.crs as cart @@ -61,72 +63,70 @@ # fmt: on # REGION_NAMES = { -# 'Arabian-Peninsula', -# 'Arabian-Sea', -# 'Arctic-Ocean', +# 'Arabian-Peninsula', +# 'Arabian-Sea', +# 'Arctic-Ocean', # 'Bay-of-Bengal' -# 'C.Australia', -# 'C.North-America', -# 'Caribbean', +# 'C.Australia', +# 'C.North-America', +# 'Caribbean', # 'Central-Africa' -# 'E.Antarctica', -# 'E.Asia', -# 'E.Australia', -# 'E.C.Asia', +# 'E.Antarctica', +# 'E.Asia', +# 'E.Australia', +# 'E.C.Asia', # 'E.Europe' -# 'E.North-America', -# 'E.Siberia', +# 'E.North-America', +# 'E.Siberia', # 'E.Southern-Africa' -# 'Equatorial.Atlantic-Ocean', +# 'Equatorial.Atlantic-Ocean', # 'Equatorial.Indic-Ocean' -# 'Equatorial.Pacific-Ocean', -# 'Greenland/Iceland', +# 'Equatorial.Pacific-Ocean', +# 'Greenland/Iceland', # 'Madagascar', -# 'Mediterranean', -# 'N.Atlantic-Ocean', -# 'N.Australia', +# 'Mediterranean', +# 'N.Atlantic-Ocean', +# 'N.Australia', # 'N.Central-America' -# 'N.E.North-America', -# 'N.E.South-America', -# 'N.Eastern-Africa', +# 'N.E.North-America', +# 'N.E.South-America', +# 'N.Eastern-Africa', # 'N.Europe', -# 'N.Pacific-Ocean', -# 'N.South-America', +# 'N.Pacific-Ocean', +# 'N.South-America', # 'N.W.North-America' -# 'N.W.South-America', -# 'New-Zealand', -# 'Russian-Arctic', +# 'N.W.South-America', +# 'New-Zealand', +# 'Russian-Arctic', # 'Russian-Far-East', -# 'S.Asia', -# 'S.Atlantic-Ocean', -# 'S.Australia', -# 'S.Central-America', +# 'S.Asia', +# 'S.Atlantic-Ocean', +# 'S.Australia', +# 'S.Central-America', # 'S.E.Asia', -# 'S.E.South-America', -# 'S.Eastern-Africa', -# 'S.Indic-Ocean', +# 'S.E.South-America', +# 'S.Eastern-Africa', +# 'S.Indic-Ocean', # 'S.Pacific-Ocean', -# 'S.South-America', -# 'S.W.South-America', -# 'Sahara', +# 'S.South-America', +# 'S.W.South-America', +# 'Sahara', # 'South-American-Monsoon', -# 'Southern-Ocean', -# 'Tibetan-Plateau', -# 'W.Antarctica', +# 'Southern-Ocean', +# 'Tibetan-Plateau', +# 'W.Antarctica', # 'W.C.Asia', -# 'W.North-America', -# 'W.Siberia', -# 'W.Southern-Africa', +# 'W.North-America', +# 'W.Siberia', +# 'W.Southern-Africa', # 'West&Central-Europe', # 'Western-Africa' # } - def merge_list_cube(cube_list, aux_name="dataset", points=None, equalize=True): - """ - Merges a list of cubes into a single cube with an enumerated auxiliary - variable. + """Merge a list of cubes into a single one with an auxiliary variable. + Useful for applying statistics along multiple cubes. The time coordinate is removed by this function. @@ -151,21 +151,26 @@ def merge_list_cube(cube_list, aux_name="dataset", points=None, equalize=True): coord = iris.coords.AuxCoord(ds_index, long_name=aux_name) ds_cube.add_aux_coord(coord) cubes = iris.cube.CubeList(cube_list) - logger.info(f"formed {type(cubes)}: {cubes}") + logger.info("formed %s: %s", type(cubes), cubes) if equalize: removed = equalise_attributes(cubes) - logger.info(f"removed different attributes: {removed}") + logger.info("removed different attributes: %s", removed) for cube in cubes: cube.remove_coord("time") merged = cubes.merge_cube() return merged -def fold_meta(cfg, meta, cfg_keys=["locations", "intervals"], - meta_keys=["dataset", "exp"], vars=None): - """ - Creates all combinations of available metadata (meta_keys) and data - specific constraints (cfg_keys). cfg.variables overwrites meta.short_names. +def fold_meta( + cfg: dict, + meta: dict, + cfg_keys: list|None=None, + meta_keys: list|None=None, + variables: list|None=None, +) -> tuple: + """Create combinations of meta data and data constraints. + + cfg["variables"] overwrites meta["short_names"]. Parameters ---------- @@ -174,11 +179,12 @@ def fold_meta(cfg, meta, cfg_keys=["locations", "intervals"], meta : list Full meta data including ancestor files. cfg_keys : list, optional - Config entries used for product. Defaults to ["locations", "intervals"]. + Data constraints as config entries used for product. + Defaults to ["locations", "intervals"]. meta_keys : list, optional - Keys for each meta used for product, short_name added automatically. + Keys for each meta used for product, short_name added automatically. Defaults to ["dataset", "exp"]. - vars : list, optional + variables : list, optional Variables to be used. Defaults to None. Returns @@ -190,30 +196,49 @@ def fold_meta(cfg, meta, cfg_keys=["locations", "intervals"], meta_keys : list List of metadata keys. """ - if not vars: - vars = cfg.get("variables", ["pdsi", "spi"]) - - groups = {gk: list(group_metadata(select_metadata(meta, short_name=vars[0]), gk).keys()) - for gk in meta_keys} + if variables is None: + variables = cfg.get("variables", ["pdsi", "spi"]) + if cfg_keys is None: + cfg_keys = ["locations", "intervals"] + if meta_keys is None: + meta_keys = ["dataset", "exp"] + + groups = { + gk: list( + group_metadata( + select_metadata(meta, short_name=variables[0]), gk + ).keys() + ) + for gk in meta_keys + } meta_keys.append("short_name") - groups["short_name"] = vars + groups["short_name"] = variables g_map = {"locations": "location", "intervals": "interval"} - for ck in cfg_keys: + for ckey in cfg_keys: try: - groups[g_map.get(ck, ck)] = cfg[ck] + groups[g_map.get(ckey, ckey)] = cfg[ckey] except KeyError: - logger.warning(f"No '{ck}' found in plot config") + logger.warning("No '%s' found in plot config", ckey) combinations = it.product(*groups.values()) return combinations, groups, meta_keys -def select_meta_from_combi(meta, combi, groups): - """selects one meta data from list (filter valid keys) +def select_meta_from_combi(meta: list, combi: dict, groups: dict) -> tuple: + """Select one meta data from list (filter valid keys). - Args: - meta (list): [description] - combi (dict): [description] - groups (dict): [description] + Parameters + ---------- + meta : list + List of metadata dictionaries. + combi : dict + Dictionary containing the combination of metadata values. + groups : dict + Dictionary containing the groups of metadata. + + Returns + ------- + tuple + A tuple containing the selected metadata and the configuration dictionary. """ this_cfg = dict(zip(groups.keys(), combi)) filter_cfg = clean_meta(this_cfg) # remove non meta keys @@ -221,72 +246,88 @@ def select_meta_from_combi(meta, combi, groups): return this_meta, this_cfg -def list_meta_keys(meta, group): +def list_meta_keys(meta:list, group:dict) -> list: + """Return a list of all keys found for a group in the meta data.""" return list(group_metadata(meta, group).keys()) +def mkplotdir(cfg: dict, dname: str|Path)-> None: + """Create a sub directory for plots if it does not exist.""" + new_dir = Path(cfg["plot_dir"] / dname) + if not new_dir.is_dir(): + Path.mkdir(new_dir) -def mkplotdir(cfg, dname): - new_dir = os.path.join(cfg["plot_dir"], dname) - if not os.path.isdir(new_dir): - os.mkdir(new_dir) +def sort_cube(cube:iris.cube, coord:str="longitude")-> iris.cube: + """Sort data along a one-dimensional numerical coordinate. -def sort_cube(cube, coord="longitude"): - """ return a cube with data sorted along a one - dimensional numerical coordinate. - Source: https://gist.github.com/pelson/9763057 + Parameters + ---------- + cube : iris.cube.Cube + The iris cube that should be sorted. + coord : str + The name of the one-dimensional coordinate to sort the cube by. - :param cube: iris cube that should be sorted - :param coord: 1dim coord to sort the cube - :return: + Returns + ------- + iris.cube.Cube + The sorted cube. + + Source + ------ + https://gist.github.com/pelson/9763057 """ coord_to_sort = cube.coord(coord) - assert coord_to_sort.ndim == 1, 'One dim coords only please.' - dim, = cube.coord_dims(coord_to_sort) + if coord_to_sort.ndim == 1: + msg = "Only dim coords are supported." + raise NotImplementedError(msg) + (dim,) = cube.coord_dims(coord_to_sort) index = [slice(None)] * cube.ndim index[dim] = np.argsort(coord_to_sort.points) return cube[tuple(index)] -def fix_longitude(cube): - """ return a cube with 0 centered longitude coords. +def fix_longitude(cube: iris.cube) -> iris.cube.Cube: + """Return a cube with 0 centered longitude coords. + updating the longitude coord and sorting the data accordingly """ # make sure coords are -180 to 180 - fixed_lons = [l if l < 180 else l - - 360 for l in cube.coord("longitude").points] + fixed_lons = [ + l if l < 180 else l - 360 for l in cube.coord("longitude").points + ] try: - cube.add_aux_coord(iris.coords.AuxCoord( - fixed_lons, long_name='fixed_lon'), 2) + cube.add_aux_coord( + iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), 2, + ) except Exception as e: logger.warning("TODO: hardcoded dimensions in ut.fix_longitude") - cube.add_aux_coord(iris.coords.AuxCoord( - fixed_lons, long_name='fixed_lon'), 1) + cube.add_aux_coord( + iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), 1 + ) # sort data and fixed coordinates - cube = sort_cube(cube, coord='fixed_lon') + cube = sort_cube(cube, coord="fixed_lon") # set new coordinates as dimcoords - new_lon = cube.coord('fixed_lon') + new_lon = cube.coord("fixed_lon") new_lon_dims = cube.coord_dims(new_lon) - # print(new_lon_dims) # Create a new coordinate which is a DimCoord. # NOTE: The var name becomes the dim name longitude = iris.coords.DimCoord.from_coord(new_lon) - longitude.rename('longitude') + longitude.rename("longitude") # Remove the AuxCoord, old longitude and add the new DimCoord. cube.remove_coord(new_lon) - cube.remove_coord(cube.coord('longitude')) + cube.remove_coord(cube.coord("longitude")) cube.add_dim_coord(longitude, new_lon_dims) return cube def get_datetime_coords(cube, coord="time"): - """ returns the time coordinate of the cube converted + """returns the time coordinate of the cube converted from cf_date to mpl compatible datetime objects. - TODO: this seems not to work with current Iris version? + TODO: this seems not to work with current Iris version? but cf_date.dateime contains the year aswell TODO: the difference behaviour may be caused by different time coords in datasets - nc.num2date default behaviour changed to use only_cf_times, change back with + nc.num2date default behaviour changed to use only_cf_times, change back with only_use_python_datetimes=True """ tc = cube.coord(coord) @@ -304,7 +345,7 @@ def get_datetime_coords(cube, coord="time"): def get_start_year(cube, coord="time"): - """ returns the year of the first time coord point. + """returns the year of the first time coord point. Works for datetime and cf_calendar types """ tc = cube.coord(coord) @@ -333,8 +374,8 @@ def get_meta_list(meta, group, select=None): def get_datasets(cfg): - metadata = cfg['input_data'].values() - return group_metadata(metadata, 'dataset').keys() + metadata = cfg["input_data"].values() + return group_metadata(metadata, "dataset").keys() def get_dataset_scenarios(cfg): @@ -343,18 +384,14 @@ def get_dataset_scenarios(cfg): :param cfg: :return: """ - metadata = cfg['input_data'].values() - input_datasets = group_metadata(metadata, 'dataset').keys() - input_scenarios = group_metadata(metadata, 'alias').keys() + metadata = cfg["input_data"].values() + input_datasets = group_metadata(metadata, "dataset").keys() + input_scenarios = group_metadata(metadata, "alias").keys() return it.product(input_datasets, input_scenarios) -def date_tick_layout(fig, ax, - dates=None, - label="Time", - auto=True, - years=1): - """ update a figure (timeline) to use +def date_tick_layout(fig, ax, dates=None, label="Time", auto=True, years=1): + """update a figure (timeline) to use date/year ticks and grid. :param fig: figure that will be updated :param ax: ax to set ticks/labels/limits on @@ -366,8 +403,8 @@ def date_tick_layout(fig, ax, """ ax.set_xlabel(label) if dates is not None: - datemin = np.datetime64(dates[0], 'Y') - datemax = np.datetime64(dates[-1], 'Y') + np.timedelta64(1, 'Y') + datemin = np.datetime64(dates[0], "Y") + datemax = np.datetime64(dates[-1], "Y") + np.timedelta64(1, "Y") ax.set_xlim(datemin, datemax) if auto: locator = mdates.AutoDateLocator() @@ -375,7 +412,7 @@ def date_tick_layout(fig, ax, else: locator = mdates.YearLocator(years) min_locator = mdates.YearLocator(1) - year_formatter = mdates.DateFormatter('%Y') + year_formatter = mdates.DateFormatter("%Y") ax.grid(True) ax.xaxis.set_major_locator(locator) ax.xaxis.set_major_formatter(year_formatter) @@ -384,14 +421,14 @@ def date_tick_layout(fig, ax, def auto_tick_layout(fig, ax, dates=None): - ax.set_xlabel('Time') + ax.set_xlabel("Time") if dates is not None: - datemin = np.datetime64(dates[0], 'Y') - datemax = np.datetime64(dates[-1], 'Y') + np.timedelta64(1, 'Y') + datemin = np.datetime64(dates[0], "Y") + datemax = np.datetime64(dates[-1], "Y") + np.timedelta64(1, "Y") ax.set_xlim(datemin, datemax) year_locator = mdates.YearLocator(1) months_locator = mdates.MonthLocator() - year_formatter = mdates.DateFormatter('%Y') + year_formatter = mdates.DateFormatter("%Y") ax.grid(True) ax.xaxis.set_major_locator(year_locator) ax.xaxis.set_major_formatter(year_formatter) @@ -400,28 +437,31 @@ def auto_tick_layout(fig, ax, dates=None): def map_land_layout(fig, ax, plot, bounds, var, cbar=True): - """ plot style for rectangular drought maps + """plot style for rectangular drought maps masks the ocean by overlay, add gridlines, set left/bottom tick labels TODO: make this a method of MapPlot class """ ax.coastlines() - ax.add_feature(ct.feature.OCEAN, - edgecolor='black', - facecolor='white', - zorder=1) - gl = ax.gridlines(crs=ct.crs.PlateCarree(), - linewidth=1, - color='black', - alpha=0.6, - linestyle='--', - draw_labels=True, zorder=2) + ax.add_feature( + ct.feature.OCEAN, edgecolor="black", facecolor="white", zorder=1 + ) + gl = ax.gridlines( + crs=ct.crs.PlateCarree(), + linewidth=1, + color="black", + alpha=0.6, + linestyle="--", + draw_labels=True, + zorder=2, + ) gl.xlabels_top = False gl.ylabels_right = False if bounds is not None and cbar: - cb = plt.colorbar(plot, ax=ax, ticks=bounds, - extend="both", fraction=0.022) + cb = plt.colorbar( + plot, ax=ax, ticks=bounds, extend="both", fraction=0.022 + ) # cb = plt.colorbar(plot, ax=ax, ticks=bounds[0:-1:]+[bounds[-1]], extend="both", fraction=0.022) elif cbar: cb = plt.colorbar(plot, ax=ax, extend="both", fraction=0.022) @@ -435,19 +475,21 @@ def get_cubes_dataset_alias(cfg): def get_scenarios(meta, **kwargs): selected = select_metadata(meta, **kwargs) - return list(group_metadata(selected, 'alias').keys()) + return list(group_metadata(selected, "alias").keys()) def add_preprocessor_input(cfg): - """ + """ NOTE: One can add preprocessor output/variables as ancestor too, no need to do it in the diagnostic... """ - logger.warning("Please add variables to the ancestor list instead. This function will be removed in the future.") - run_dir = os.path.dirname(cfg['run_dir']) - pp_dir = '/preproc/'.join(run_dir.rsplit('/run/', 1)) - fake_cfg = {'input_files': [pp_dir]} - cfg['input_data'].update(_get_input_data_files(fake_cfg)) + logger.warning( + "Please add variables to the ancestor list instead. This function will be removed in the future." + ) + run_dir = os.path.dirname(cfg["run_dir"]) + pp_dir = "/preproc/".join(run_dir.rsplit("/run/", 1)) + fake_cfg = {"input_files": [pp_dir]} + cfg["input_data"].update(_get_input_data_files(fake_cfg)) def add_ancestor_input(cfg): @@ -458,10 +500,11 @@ def add_ancestor_input(cfg): """ logger.info(f"add ancestors for {cfg[n.INPUT_FILES]}") for input_file in cfg[n.INPUT_FILES]: - cfg_anc_file = '/run/'.join(input_file.rsplit('/work/', 1) - ) + '/settings.yml' + cfg_anc_file = ( + "/run/".join(input_file.rsplit("/work/", 1)) + "/settings.yml" + ) cfg_anc = get_cfg(cfg_anc_file) - cfg['input_data'].update(_get_input_data_files(cfg_anc)) + cfg["input_data"].update(_get_input_data_files(cfg_anc)) def add_meta_files(cfg): @@ -469,7 +512,7 @@ def add_meta_files(cfg): def _fixup_dates(coord, values): - """ copy from iris plot.py source code """ + """copy from iris plot.py source code""" if coord.units.calendar is not None and values.ndim == 1: # Convert coordinate values into tuples of # (year, month, day, hour, min, sec) @@ -509,9 +552,9 @@ def quick_save(cube, name, cfg): name: basename for the created netcdf file cfg: user configuration containing file output path """ - if cfg.get('write_netcdf', True): + if cfg.get("write_netcdf", True): diag_file = get_diagnostic_filename(name, cfg) - logger.info(f'quick save {diag_file}') + logger.info(f"quick save {diag_file}") iris.save(cube, target=diag_file) @@ -543,18 +586,20 @@ def smooth(dat, window=32, mode="same"): def get_basename(cfg, meta, prefix=None, suffix=None): formats = { # TODO: load this from config-developer.yml? - 'CMIP6': '{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_{grid}_{start_year}-{end_year}', - 'OBS': '{project}_{dataset}_{type}_{version}_{mip}_{short_name}_{start_year}-{end_year}' + "CMIP6": "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_{grid}_{start_year}-{end_year}", + "OBS": "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_{start_year}-{end_year}", } - basename = formats[meta['project']].format(**meta) - if suffix: basename += f"_{suffix}" - if prefix: basename = f"{prefix}_{basename}" + basename = formats[meta["project"]].format(**meta) + if suffix: + basename += f"_{suffix}" + if prefix: + basename = f"{prefix}_{basename}" return basename def get_custom_basename(meta, folder="plots", prefix=None, suffix=None): """manually create basenames for output files - + NOTE: for basename in configured naming format use get_basename """ defaults = { @@ -621,11 +666,11 @@ def select_single_meta(*args, **kwargs): def date_to_months(date, start_year): - """translates datestings YYYY-MM to + """translates datestings YYYY-MM to total months since begin of start_year """ years, months = [int(x) for x in date.split("-")] - return 12*(years-start_year)+months + return 12 * (years - start_year) + months def fix_interval(interval): @@ -643,7 +688,7 @@ def get_plot_filename(cfg, basename, meta=dict(), replace=dict()): """Get a valid path for saving a diagnostic plot. This is an alternative to shared.get_diagnostic_filename. It uses cfg as first argument and accept metadata to format the basename. - + Parameters ---------- cfg: dict @@ -664,18 +709,18 @@ def get_plot_filename(cfg, basename, meta=dict(), replace=dict()): for key, value in replace.items(): basename = basename.replace(key, value) return os.path.join( - cfg['plot_dir'], + cfg["plot_dir"], f"{basename}.{cfg['output_file_type']}", ) def slice_cube_interval(cube, interval): - """ returns a cube slice for given interval + """returns a cube slice for given interval which is a list of strings (YYYY-mm) or int (index of cube) NOTE: For 3D cubes (time first) """ if isinstance(interval[0], int) and isinstance(interval[1], int): - return cube[interval[0]:interval[1], :, :] + return cube[interval[0] : interval[1], :, :] dt_start = dt.datetime.strptime(interval[0], "%Y-%m") dt_end = dt.datetime.strptime(interval[1], "%Y-%m") time = cube.coord("time") @@ -686,7 +731,7 @@ def slice_cube_interval(cube, interval): def find_first(nparr): """finds first nonzero element of numpy array and returns index or -1. - Its faster than looping or using numpy.where. + Its faster than looping or using numpy.where. https://stackoverflow.com/a/61117770/7268121 nparr: (numpy.array) requires numerical data without negative zeroes. """ @@ -695,22 +740,22 @@ def find_first(nparr): def add_spei_meta(cfg, name="spei", pos=0): - """ NOTE: workaround to add missing meta for specific ancestor script - """ - logger.info('adding meta file for save_spei output') - spei_fname = cfg['tmp_meta']['filename'].split( - '/')[-1].replace('_pr_', f'_{name}_') + """NOTE: workaround to add missing meta for specific ancestor script""" + logger.info("adding meta file for save_spei output") + spei_fname = ( + cfg["tmp_meta"]["filename"].split("/")[-1].replace("_pr_", f"_{name}_") + ) # FIXME: hardcoded index in input files.. needs to match recipe - spei_file = os.path.join(cfg['input_files'][pos], spei_fname) - logger.info(f'spei file path: {spei_file}') - logger.info('generating missing meta info') + spei_file = os.path.join(cfg["input_files"][pos], spei_fname) + logger.info(f"spei file path: {spei_file}") + logger.info("generating missing meta info") meta = cfg["tmp_meta"].copy() - meta['filename'] = spei_file - meta['short_name'] = name - meta['long_name'] = 'Standardised Precipitation-Evapotranspiration Index' + meta["filename"] = spei_file + meta["short_name"] = name + meta["long_name"] = "Standardised Precipitation-Evapotranspiration Index" # meta['standard_name'] = 'standardised_precipitation_evapotranspiration_index' if name.lower() == "spi": - meta['long_name'] = 'Standardised Precipitation Index' + meta["long_name"] = "Standardised Precipitation Index" # meta['standard_name'] = 'standardised_precipitation_index' cfg["input_data"][spei_file] = meta @@ -727,23 +772,21 @@ def fix_calendar(cube): iris.cube.Cube: with fixed calendars """ - time = cube.coord('time') + time = cube.coord("time") # if time.units.name == "days since 1850-1-1 00:00:00": logger.info("renaming unit") - time.units = Unit("days since 1850-01-01", - calendar=time.units.calendar) - if time.units.calendar == 'proleptic_gregorian': - time.units = Unit(time.units.name, calendar='gregorian') + time.units = Unit("days since 1850-01-01", calendar=time.units.calendar) + if time.units.calendar == "proleptic_gregorian": + time.units = Unit(time.units.name, calendar="gregorian") logger.info(f"renamed calendar: {time.units.calendar}") - if time.units.calendar != 'gregorian': - time.convert_units(Unit("days since 1850-01-01", - calendar='gregorian')) + if time.units.calendar != "gregorian": + time.convert_units(Unit("days since 1850-01-01", calendar="gregorian")) logger.info(f"converted time to: {time.units}") return cube def latlon_coords(cube): - """replace latitude and longitude + """replace latitude and longitude with lat and lon inplace TODO: make this good! """ @@ -773,11 +816,12 @@ def standard_time(cubes): from datetime import datetime from esmvalcore.iris_helpers import date2num + t_unit = Unit("days since 1850-01-01", calendar="standard") for cube in cubes: # Extract date info from cube - coord = cube.coord('time') + coord = cube.coord("time") years = [p.year for p in coord.units.num2date(coord.points)] months = [p.month for p in coord.units.num2date(coord.points)] days = [p.day for p in coord.units.num2date(coord.points)] @@ -802,22 +846,23 @@ def standard_time(cubes): logger.warning( "Multimodel encountered (sub)daily data and inconsistent " "time units or calendars. Attempting to continue, but " - "might produce unexpected results.") + "might produce unexpected results." + ) else: raise ValueError( "Multimodel statistics preprocessor currently does not " - "support sub-daily data.") + "support sub-daily data." + ) # Update the cubes' time coordinate (both point values and the units!) - cube.coord('time').points = date2num(dates, t_unit, coord.dtype) - cube.coord('time').units = t_unit - cube.coord('time').bounds = None - cube.coord('time').guess_bounds() + cube.coord("time").points = date2num(dates, t_unit, coord.dtype) + cube.coord("time").units = t_unit + cube.coord("time").bounds = None + cube.coord("time").guess_bounds() def guess_lat_lon_bounds(cube): - """guesses bounds for latitude and longitude if not existent. - """ + """guesses bounds for latitude and longitude if not existent.""" if not cube.coord("latitude").has_bounds(): cube.coord("latitude").guess_bounds() if not cube.coord("longitude").has_bounds(): @@ -837,13 +882,12 @@ def mmm(cube_list, mdtol=0, dropcoords=["time"], dropmethods=False): cube.remove_coord(coord_name) if dropmethods: cube.cell_methods = None - cube.add_aux_coord(iris.coords.AuxCoord( - i, long_name="dataset")) + cube.add_aux_coord(iris.coords.AuxCoord(i, long_name="dataset")) # equalise_attributes(cube_list) - # cube.remove_coord("season_number") # add as drop_coord + # cube.remove_coord("season_number") # add as drop_coord cube_list = iris.cube.CubeList(cube_list) equalise_attributes(cube_list) - # common_depth = + # common_depth = try: # Note: just for testing: merged = cube_list.merge_cube() @@ -858,8 +902,8 @@ def mmm(cube_list, mdtol=0, dropcoords=["time"], dropmethods=False): raise err if mdtol > 0: logger.info(f"performing MMM with tolerance: {mdtol}") - mean = merged.collapsed('dataset', iris.analysis.MEAN, mdtol=mdtol) - sdev = merged.collapsed('dataset', iris.analysis.STD_DEV) + mean = merged.collapsed("dataset", iris.analysis.MEAN, mdtol=mdtol) + sdev = merged.collapsed("dataset", iris.analysis.STD_DEV) return mean, sdev @@ -914,8 +958,7 @@ def get_hex_positions(): def get_region_data(): - """reads shapes.txt as csv file and returns a list of region names - """ + """reads shapes.txt as csv file and returns a list of region names""" fname = "/work/bd0854/b309169/ESMValTool-private/esmvaltool/diag_scripts/droughtindex/shapes.txt" data = pd.read_csv(fname, sep=",", header=0, skiprows=0) print(data) @@ -926,30 +969,34 @@ def get_region_abbrs(): # abbr_positions = get_hex_positions() data = get_region_data() data.set_index("Name", inplace=True) - return { - i: data.loc[i]["Abbr"] - for i in data.index.tolist() - } + return {i: data.loc[i]["Abbr"] for i in data.index.tolist()} def add_aux_regions(cube, mask=None): - """ Add an auxilary coordinate with region numbers. + """Add an auxilary coordinate with region numbers. Dimension names: 'lat' and 'lon'. TODO: add option/fallback to use pp with shapefile instead """ import regionmask + region = regionmask.defined_regions.ar6.land xarr = xr.DataArray.from_iris(cube) mask2d = mask if mask else region.mask(xarr).to_iris() # newcube = cube.copy() region_coord = iris.coords.AuxCoord( - mask2d.data, long_name="AR6 reference region", var_name="region") + mask2d.data, long_name="AR6 reference region", var_name="region" + ) cube.add_aux_coord(region_coord, [1, 2]) return cube -def regional_mean(cube, mask=None, minmax=False, stddev=False,): - """ Returns a cube with one dim_coord 'region' +def regional_mean( + cube, + mask=None, + minmax=False, + stddev=False, +): + """Returns a cube with one dim_coord 'region' which contains weighted means over 'lat', 'lon'. TODO: allow minmax and stddev at the same time. Different returns or return a dict? maybe add an metrics array: [mean, min, max, sum ...] @@ -960,16 +1007,20 @@ def regional_mean(cube, mask=None, minmax=False, stddev=False,): """ logger.warning("Please use regional_stats instead of regional_mean") if minmax: - res = regional_stats_xarr({}, cube, operators=["min", "mean", "max"]).values() + res = regional_stats_xarr( + {}, cube, operators=["min", "mean", "max"] + ).values() return res["min", "mean", "max"] if stddev: - res = regional_stats_xarr({}, cube, operators['mean', "std_dev"]) + res = regional_stats_xarr({}, cube, operators["mean", "std_dev"]) return res["mean"], res["std_dev"] - res = regional_stats_xarr({}, cube, operators=["min", "mean", "max"]).values() + res = regional_stats_xarr( + {}, cube, operators=["min", "mean", "max"] + ).values() return res["mean"] - -def regional_stats_xarr(cfg, cube, operators=['mean'], shapefile=None): + +def regional_stats_xarr(cfg, cube, operators=["mean"], shapefile=None): results = {} if shapefile: return regional_stats(cfg, cube, operators, shapefile) @@ -987,24 +1038,24 @@ def regional_stats_xarr(cfg, cube, operators=['mean'], shapefile=None): weighted = xarr.weighted(weights3d) if "mean" in operators: mean = weighted.mean(dim=("lat", "lon")) - result['mean'] = mean.to_iris(), + result["mean"] = (mean.to_iris(),) # stddev = weighted.sum_of_squares() / weights3d.sum() if "std_dev" in operators: stddev = weighted.std(dim=("lat", "lon")) - result['std_dev'] = stddev.to_iris() + result["std_dev"] = stddev.to_iris() if "min" in operators: mini = xarr.where(mask3d).min(dim=("lat", "lon")) - result['min'] = mini.to_iris() + result["min"] = mini.to_iris() if "max" in operators: maxi = xarr.where(mask3d).max(dim=("lat", "lon")) - result['max'] = maxi.to_iris() + result["max"] = maxi.to_iris() return result -def regional_stats(cfg, cube, operator='mean'): +def regional_stats(cfg, cube, operator="mean"): """Mean over IPCC Reference regions using shape file. - The shapefile (string) must be contained in cft (given by recipe). - It can be an absolute path or relative to the folder for auxilary data + The shapefile (string) must be contained in cft (given by recipe). + It can be an absolute path or relative to the folder for auxilary data configured in esmvaltool config. """ # if "shapefile" not in cfg: @@ -1012,13 +1063,13 @@ def regional_stats(cfg, cube, operator='mean'): # utils.regional_stats_xarr() be used with module regionmask.") # shapefile = Path(cfg['auxiliary_data_dir']) / cfg['shapefile'] guess_lat_lon_bounds(cube) - extracted = pp.extract_shape(cube, 'ar6', decomposed=True) + extracted = pp.extract_shape(cube, "ar6", decomposed=True) return pp.area_statistics(extracted, operator) def transpose_by_names(cube, names): - """ transposes an iris cube by dim-coords or their names """ + """transposes an iris cube by dim-coords or their names""" new_dims = [cube.coord_dims(name)[0] for name in names] print(new_dims) cube.transpose(new_dims) @@ -1026,7 +1077,7 @@ def transpose_by_names(cube, names): def generate_metadata(work_folder, diag=None): """create metadata from ancestor config and nc files. - + Can be used as workaround, to handle output of ancestors, that don't create metadata.yml, similar to preprocessed variables. If metadata.yml exists in the work folder (subfolders ignored) its content is returned instead. @@ -1039,7 +1090,7 @@ def generate_metadata(work_folder, diag=None): # TODO: provide fixes for some diagnostics or directly implement it. # meta = {} # cfg = yaml.read() - # nc_files = + # nc_files = def save_metadata(cfg, metadata): @@ -1052,18 +1103,20 @@ def get_meta(index): index = index.lower() if index == "pdsi": return dict( - variable_group='index', - standard_name='palmer_drought_severity_index', - short_name='pdsi', - long_name='Palmer Drought Severity Index', - units='1') + variable_group="index", + standard_name="palmer_drought_severity_index", + short_name="pdsi", + long_name="Palmer Drought Severity Index", + units="1", + ) elif index in ["scpdsi", "sc-pdsi"]: return dict( - variable_group='index', - standard_name='self_calibrated_palmer_drought_severity_index', - short_name='scpdsi', - long_name='Self Calibrated Palmer Drought Severity Index', - units='1') + variable_group="index", + standard_name="self_calibrated_palmer_drought_severity_index", + short_name="scpdsi", + long_name="Self Calibrated Palmer Drought Severity Index", + units="1", + ) else: logger.error(f"No default meta data for Index: {index}") raise NotImplementedError @@ -1075,7 +1128,7 @@ def set_defaults(target, defaults): This checks if a key exists in target, and only if not the keys are set with values. It does not checks recursively for nested entries. - NOTE: this might be obsolete since python 3.9, as there are direct + NOTE: this might be obsolete since python 3.9, as there are direct fallback assignments like: `target = defaults | target` Parameters @@ -1114,7 +1167,7 @@ def aux_path(cfg, path): def remove_attributes(cube, ignore=[]): """remove attributes of cubes or coords in place used to clean up differences in cubes coordinates before merging - + Parameters ---------- cube @@ -1137,8 +1190,8 @@ def convert_to_mmday_xarray(pr): Args: pr: xarray.dataarrray precipitation in kgm-2s-1 """ - pr.values = pr.values * 60 *60 * 24 - pr.attrs['units'] = "mm day-1" + pr.values = pr.values * 60 * 60 * 24 + pr.attrs["units"] = "mm day-1" return pr @@ -1157,14 +1210,12 @@ def font_color(background): def log_provenance(cfg, fname, record): - with ProvenanceLogger(cfg) as provenance_logger: provenance_logger.log(fname, record) def get_time_range(cube): - """guesses the period of a cube based on the time coordinate. - """ + """guesses the period of a cube based on the time coordinate.""" if not isinstance(cube, iris.cube.Cube): cube = iris.load_cube(cube) time = cube.coord("time") @@ -1172,12 +1223,12 @@ def get_time_range(cube): print(time.points) start = time.units.num2date(time.points[0]) end = time.units.num2date(time.points[-1]) - return {'start_year': start.year, 'end_year': end.year} + return {"start_year": start.year, "end_year": end.year} def guess_experiment(meta): """guess experiment from filename - TODO: this is a workaround for incomplete meta data.. + TODO: this is a workaround for incomplete meta data.. fix this in ancestor diagnostics rather than use this function. """ exps = ["historical", "ssp126", "ssp245", "ssp370", "ssp585"] @@ -1187,16 +1238,16 @@ def guess_experiment(meta): def monthly_to_daily(cube, units="mm day-1", leap_years=True): - """convert monthly data to daily data inplace ignoring leap years - """ + """convert monthly data to daily data inplace ignoring leap years""" months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - months = months * int((cube.shape[0] / 12)+1) + months = months * int((cube.shape[0] / 12) + 1) for i, s in enumerate(cube.slices_over(["time"])): if not leap_years: days = months[i] cube.data[i] = cube.data[i] / days try: from calendar import monthrange + time = s.coord("time") date = time.units.num2date(time.points[0]) days = monthrange(date.year, date.month)[1] @@ -1214,14 +1265,15 @@ def daily_to_monthly(cube, units="mm month-1", leap_years=True): and compatible with pet.R """ months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - months = months * int((cube.shape[0] / 12)+1) + months = months * int((cube.shape[0] / 12) + 1) for i, s in enumerate(cube.slices_over(["time"])): if not leap_years: days = months[i] cube.data[i] = cube.data[i] * days continue - try: # consider leapday + try: # consider leapday from calendar import monthrange + time = s.coord("time") date = time.units.num2date(time.points[0]) days = monthrange(date.year, date.month)[1] @@ -1233,49 +1285,6 @@ def daily_to_monthly(cube, units="mm month-1", leap_years=True): cube.units = units - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _get_data_hlp(axis, data, ilat, ilon): """Get data_help dependend on axis.""" if axis == 0: @@ -1292,58 +1301,82 @@ def _get_drought_data(cfg, cube): """Prepare data and calculate characteristics.""" # make a new cube to increase the size of the data array # Make an aggregator from the user function. - spell_no = Aggregator('spell_count', - count_spells, - units_func=lambda units: 1) + spell_no = Aggregator( + "spell_count", count_spells, units_func=lambda units: 1 + ) new_cube = _make_new_cube(cube) # calculate the number of drought events and their average duration - drought_show = new_cube.collapsed('time', spell_no, - threshold=cfg['threshold']) - drought_show.rename('Drought characteristics') + drought_show = new_cube.collapsed( + "time", spell_no, threshold=cfg["threshold"] + ) + drought_show.rename("Drought characteristics") # length of time series - time_length = len(new_cube.coord('time').points) / 12.0 + time_length = len(new_cube.coord("time").points) / 12.0 # Convert number of droughtevents to frequency (per year) - drought_show.data[:, :, 0] = drought_show.data[:, :, - 0] / time_length + drought_show.data[:, :, 0] = drought_show.data[:, :, 0] / time_length return drought_show def _provenance_map_spei(cfg, name_dict, spei, dataset_name): """Set provenance for plot_map_spei.""" - caption = 'Global map of ' + \ - name_dict['drought_char'] + \ - ' [' + name_dict['unit'] + '] ' + \ - 'based on ' + cfg['indexname'] + '.' - - if cfg['indexname'].lower == "spei": - set_refs = ['martin18grl', 'vicente10jclim', ] - elif cfg['indexname'].lower == "spi": - set_refs = ['martin18grl', 'mckee93proc', ] + caption = ( + "Global map of " + + name_dict["drought_char"] + + " [" + + name_dict["unit"] + + "] " + + "based on " + + cfg["indexname"] + + "." + ) + + if cfg["indexname"].lower == "spei": + set_refs = [ + "martin18grl", + "vicente10jclim", + ] + elif cfg["indexname"].lower == "spi": + set_refs = [ + "martin18grl", + "mckee93proc", + ] else: - set_refs = ['martin18grl', ] - - provenance_record = get_provenance_record([name_dict['input_filenames']], - caption, - ['global'], - set_refs) - - diagnostic_file = get_diagnostic_filename(cfg['indexname'] + '_map' + - name_dict['add_to_filename'] + - '_' + - dataset_name, cfg) - plot_file = get_plot_filename(cfg['indexname'] + '_map' + - name_dict['add_to_filename'] + - '_' + - dataset_name, cfg) + set_refs = [ + "martin18grl", + ] + + provenance_record = get_provenance_record( + [name_dict["input_filenames"]], caption, ["global"], set_refs + ) + + diagnostic_file = get_diagnostic_filename( + cfg["indexname"] + + "_map" + + name_dict["add_to_filename"] + + "_" + + dataset_name, + cfg, + ) + plot_file = get_plot_filename( + cfg["indexname"] + + "_map" + + name_dict["add_to_filename"] + + "_" + + dataset_name, + cfg, + ) logger.info("Saving analysis results to %s", diagnostic_file) cubesave = cube_to_save_ploted(spei, name_dict) iris.save(cubesave, target=diagnostic_file) - logger.info("Recording provenance of %s:\n%s", diagnostic_file, - pformat(provenance_record)) + logger.info( + "Recording provenance of %s:\n%s", + diagnostic_file, + pformat(provenance_record), + ) with ProvenanceLogger(cfg) as provenance_logger: provenance_logger.log(plot_file, provenance_record) provenance_logger.log(diagnostic_file, provenance_record) @@ -1351,35 +1384,62 @@ def _provenance_map_spei(cfg, name_dict, spei, dataset_name): def _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames): """Set provenance for plot_map_spei_multi.""" - caption = 'Global map of the multi-model mean of ' + \ - data_dict['drought_char'] + \ - ' [' + data_dict['unit'] + '] ' + \ - 'based on ' + cfg['indexname'] + '.' - - if cfg['indexname'].lower == "spei": - set_refs = ['martin18grl', 'vicente10jclim', ] - elif cfg['indexname'].lower == "spi": - set_refs = ['martin18grl', 'mckee93proc', ] + caption = ( + "Global map of the multi-model mean of " + + data_dict["drought_char"] + + " [" + + data_dict["unit"] + + "] " + + "based on " + + cfg["indexname"] + + "." + ) + + if cfg["indexname"].lower == "spei": + set_refs = [ + "martin18grl", + "vicente10jclim", + ] + elif cfg["indexname"].lower == "spi": + set_refs = [ + "martin18grl", + "mckee93proc", + ] else: - set_refs = ['martin18grl', ] + set_refs = [ + "martin18grl", + ] - provenance_record = get_provenance_record(input_filenames, caption, - ['global'], - set_refs) + provenance_record = get_provenance_record( + input_filenames, caption, ["global"], set_refs + ) - diagnostic_file = get_diagnostic_filename(cfg['indexname'] + '_map' + - data_dict['filename'] + '_' + - data_dict['datasetname'], cfg) - plot_file = get_plot_filename(cfg['indexname'] + '_map' + - data_dict['filename'] + '_' + - data_dict['datasetname'], cfg) + diagnostic_file = get_diagnostic_filename( + cfg["indexname"] + + "_map" + + data_dict["filename"] + + "_" + + data_dict["datasetname"], + cfg, + ) + plot_file = get_plot_filename( + cfg["indexname"] + + "_map" + + data_dict["filename"] + + "_" + + data_dict["datasetname"], + cfg, + ) logger.info("Saving analysis results to %s", diagnostic_file) iris.save(cube_to_save_ploted(spei, data_dict), target=diagnostic_file) - logger.info("Recording provenance of %s:\n%s", diagnostic_file, - pformat(provenance_record)) + logger.info( + "Recording provenance of %s:\n%s", + diagnostic_file, + pformat(provenance_record), + ) with ProvenanceLogger(cfg) as provenance_logger: provenance_logger.log(plot_file, provenance_record) provenance_logger.log(diagnostic_file, provenance_record) @@ -1387,39 +1447,53 @@ def _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames): def _provenance_time_series_spei(cfg, data_dict): """Provenance for time series plots.""" - caption = 'Time series of ' + \ - data_dict['var'] + \ - ' at' + data_dict['area'] + '.' - - if cfg['indexname'].lower == "spei": - set_refs = ['vicente10jclim', ] - elif cfg['indexname'].lower == "spi": - set_refs = ['mckee93proc', ] + caption = ( + "Time series of " + data_dict["var"] + " at" + data_dict["area"] + "." + ) + + if cfg["indexname"].lower == "spei": + set_refs = [ + "vicente10jclim", + ] + elif cfg["indexname"].lower == "spi": + set_refs = [ + "mckee93proc", + ] else: - set_refs = ['martin18grl', ] - - provenance_record = get_provenance_record([data_dict['filename']], - caption, - ['reg'], set_refs, - plot_type='times') - - diagnostic_file = get_diagnostic_filename(cfg['indexname'] + - '_time_series_' + - data_dict['area'] + - '_' + - data_dict['dataset_name'], cfg) - plot_file = get_plot_filename(cfg['indexname'] + - '_time_series_' + - data_dict['area'] + - '_' + - data_dict['dataset_name'], cfg) + set_refs = [ + "martin18grl", + ] + + provenance_record = get_provenance_record( + [data_dict["filename"]], caption, ["reg"], set_refs, plot_type="times" + ) + + diagnostic_file = get_diagnostic_filename( + cfg["indexname"] + + "_time_series_" + + data_dict["area"] + + "_" + + data_dict["dataset_name"], + cfg, + ) + plot_file = get_plot_filename( + cfg["indexname"] + + "_time_series_" + + data_dict["area"] + + "_" + + data_dict["dataset_name"], + cfg, + ) logger.info("Saving analysis results to %s", diagnostic_file) cubesave = cube_to_save_ploted_ts(data_dict) iris.save(cubesave, target=diagnostic_file) - logger.info("Recording provenance of %s:\n%s", diagnostic_file, - pformat(provenance_record)) + logger.info( + "Recording provenance of %s:\n%s", + diagnostic_file, + pformat(provenance_record), + ) with ProvenanceLogger(cfg) as provenance_logger: provenance_logger.log(plot_file, provenance_record) provenance_logger.log(diagnostic_file, provenance_record) @@ -1427,49 +1501,68 @@ def _provenance_time_series_spei(cfg, data_dict): def cube_to_save_ploted(var, data_dict): """Create cube to prepare plotted data for saving to netCDF.""" - plot_cube = iris.cube.Cube(var, var_name=data_dict['var'], - long_name=data_dict['drought_char'], - units=data_dict['unit']) - plot_cube.add_dim_coord(iris.coords.DimCoord(data_dict['latitude'], - var_name='lat', - long_name='latitude', - units='degrees_north'), 0) - plot_cube.add_dim_coord(iris.coords.DimCoord(data_dict['longitude'], - var_name='lon', - long_name='longitude', - units='degrees_east'), 1) + plot_cube = iris.cube.Cube( + var, + var_name=data_dict["var"], + long_name=data_dict["drought_char"], + units=data_dict["unit"], + ) + plot_cube.add_dim_coord( + iris.coords.DimCoord( + data_dict["latitude"], + var_name="lat", + long_name="latitude", + units="degrees_north", + ), + 0, + ) + plot_cube.add_dim_coord( + iris.coords.DimCoord( + data_dict["longitude"], + var_name="lon", + long_name="longitude", + units="degrees_east", + ), + 1, + ) return plot_cube def cube_to_save_ploted_ts(data_dict): """Create cube to prepare plotted time series for saving to netCDF.""" - plot_cube = iris.cube.Cube(data_dict['data'], var_name=data_dict['var'], - long_name=data_dict['var'], - units=data_dict['unit']) - plot_cube.add_dim_coord(iris.coords.DimCoord(data_dict['time'], - var_name='time', - long_name='Time', - units='month'), 0) + plot_cube = iris.cube.Cube( + data_dict["data"], + var_name=data_dict["var"], + long_name=data_dict["var"], + units=data_dict["unit"], + ) + plot_cube.add_dim_coord( + iris.coords.DimCoord( + data_dict["time"], var_name="time", long_name="Time", units="month" + ), + 0, + ) return plot_cube -def get_provenance_record(ancestor_files, caption, - domains, refs, plot_type='geo'): +def get_provenance_record( + ancestor_files, caption, domains, refs, plot_type="geo" +): """Get Provenance record.""" record = { - 'caption': caption, - 'statistics': ['mean'], - 'domains': domains, - 'plot_type': plot_type, - 'themes': ['phys'], - 'authors': [ - 'weigel_katja', - 'adeniyi_kemisola', + "caption": caption, + "statistics": ["mean"], + "domains": domains, + "plot_type": plot_type, + "themes": ["phys"], + "authors": [ + "weigel_katja", + "adeniyi_kemisola", ], - 'references': refs, - 'ancestors': ancestor_files, + "references": refs, + "ancestors": ancestor_files, } return record @@ -1479,146 +1572,190 @@ def _make_new_cube(cube): new_shape = cube.shape + (4,) new_data = iris.util.broadcast_to_shape(cube.data, new_shape, [0, 1, 2]) new_cube = iris.cube.Cube(new_data) - new_cube.add_dim_coord(iris.coords.DimCoord( - cube.coord('time').points, long_name='time'), 0) - new_cube.add_dim_coord(iris.coords.DimCoord( - cube.coord('latitude').points, long_name='latitude'), 1) - new_cube.add_dim_coord(iris.coords.DimCoord( - cube.coord('longitude').points, long_name='longitude'), 2) - new_cube.add_dim_coord(iris.coords.DimCoord( - [0, 1, 2, 3], long_name='z'), 3) + new_cube.add_dim_coord( + iris.coords.DimCoord(cube.coord("time").points, long_name="time"), 0 + ) + new_cube.add_dim_coord( + iris.coords.DimCoord( + cube.coord("latitude").points, long_name="latitude" + ), + 1, + ) + new_cube.add_dim_coord( + iris.coords.DimCoord( + cube.coord("longitude").points, long_name="longitude" + ), + 2, + ) + new_cube.add_dim_coord( + iris.coords.DimCoord([0, 1, 2, 3], long_name="z"), 3 + ) return new_cube -def _plot_multi_model_maps(cfg, all_drought_mean, lats_lons, input_filenames, - tstype): +def _plot_multi_model_maps( + cfg, all_drought_mean, lats_lons, input_filenames, tstype +): """Prepare plots for multi-model mean.""" - data_dict = {'latitude': lats_lons[0], - 'longitude': lats_lons[1], - 'model_kind': tstype - } - if tstype == 'Difference': + data_dict = { + "latitude": lats_lons[0], + "longitude": lats_lons[1], + "model_kind": tstype, + } + if tstype == "Difference": # RCP85 Percentage difference - data_dict.update({'data': all_drought_mean[:, :, 0], - 'var': 'diffnumber', - 'datasetname': 'Percentage', - 'drought_char': 'Number of drought events', - 'unit': '%', - 'filename': 'Percentage_difference_of_No_of_Events', - 'drought_numbers_level': np.arange(-100, 110, 10)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='rainbow') - - data_dict.update({'data': all_drought_mean[:, :, 1], - 'var': 'diffduration', - 'drought_char': 'Duration of drought events', - 'filename': 'Percentage_difference_of_Dur_of_Events', - 'drought_numbers_level': np.arange(-100, 110, 10)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='rainbow') - - data_dict.update({'data': all_drought_mean[:, :, 2], - 'var': 'diffseverity', - 'drought_char': 'Severity Index of drought events', - 'filename': 'Percentage_difference_of_Sev_of_Events', - 'drought_numbers_level': np.arange(-50, 60, 10)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='rainbow') - - data_dict.update({'data': all_drought_mean[:, :, 3], - 'var': 'diff' + (cfg['indexname']).lower(), - 'drought_char': 'Average ' + cfg['indexname'] + - ' of drought events', - 'filename': 'Percentage_difference_of_Avr_of_Events', - 'drought_numbers_level': np.arange(-50, 60, 10)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='rainbow') + data_dict.update({ + "data": all_drought_mean[:, :, 0], + "var": "diffnumber", + "datasetname": "Percentage", + "drought_char": "Number of drought events", + "unit": "%", + "filename": "Percentage_difference_of_No_of_Events", + "drought_numbers_level": np.arange(-100, 110, 10), + }) + plot_map_spei_multi( + cfg, data_dict, input_filenames, colormap="rainbow" + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 1], + "var": "diffduration", + "drought_char": "Duration of drought events", + "filename": "Percentage_difference_of_Dur_of_Events", + "drought_numbers_level": np.arange(-100, 110, 10), + }) + plot_map_spei_multi( + cfg, data_dict, input_filenames, colormap="rainbow" + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 2], + "var": "diffseverity", + "drought_char": "Severity Index of drought events", + "filename": "Percentage_difference_of_Sev_of_Events", + "drought_numbers_level": np.arange(-50, 60, 10), + }) + plot_map_spei_multi( + cfg, data_dict, input_filenames, colormap="rainbow" + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 3], + "var": "diff" + (cfg["indexname"]).lower(), + "drought_char": "Average " + + cfg["indexname"] + + " of drought events", + "filename": "Percentage_difference_of_Avr_of_Events", + "drought_numbers_level": np.arange(-50, 60, 10), + }) + plot_map_spei_multi( + cfg, data_dict, input_filenames, colormap="rainbow" + ) else: - data_dict.update({'data': all_drought_mean[:, :, 0], - 'var': 'frequency', - 'unit': 'year-1', - 'drought_char': 'Number of drought events per year', - 'filename': tstype + '_No_of_Events_per_year', - 'drought_numbers_level': np.arange(0, 0.4, 0.05)}) - if tstype == 'Observations': - data_dict['datasetname'] = 'Mean' + data_dict.update({ + "data": all_drought_mean[:, :, 0], + "var": "frequency", + "unit": "year-1", + "drought_char": "Number of drought events per year", + "filename": tstype + "_No_of_Events_per_year", + "drought_numbers_level": np.arange(0, 0.4, 0.05), + }) + if tstype == "Observations": + data_dict["datasetname"] = "Mean" else: - data_dict['datasetname'] = 'MultiModelMean' - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='gnuplot') - - data_dict.update({'data': all_drought_mean[:, :, 1], - 'var': 'duration', - 'unit': 'month', - 'drought_char': 'Duration of drought events [month]', - 'filename': tstype + '_Dur_of_Events', - 'drought_numbers_level': np.arange(0, 6, 1)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='gnuplot') - - data_dict.update({'data': all_drought_mean[:, :, 2], - 'var': 'severity', - 'unit': '1', - 'drought_char': 'Severity Index of drought events', - 'filename': tstype + '_Sev_index_of_Events', - 'drought_numbers_level': np.arange(0, 9, 1)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='gnuplot') - namehlp = 'Average ' + cfg['indexname'] + ' of drought events' - namehlp2 = tstype + '_Average_' + cfg['indexname'] + '_of_Events' - data_dict.update({'data': all_drought_mean[:, :, 3], - 'var': (cfg['indexname']).lower(), - 'unit': '1', - 'drought_char': namehlp, - 'filename': namehlp2, - 'drought_numbers_level': np.arange(-2.8, -1.8, 0.2)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='gnuplot') + data_dict["datasetname"] = "MultiModelMean" + plot_map_spei_multi( + cfg, data_dict, input_filenames, colormap="gnuplot" + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 1], + "var": "duration", + "unit": "month", + "drought_char": "Duration of drought events [month]", + "filename": tstype + "_Dur_of_Events", + "drought_numbers_level": np.arange(0, 6, 1), + }) + plot_map_spei_multi( + cfg, data_dict, input_filenames, colormap="gnuplot" + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 2], + "var": "severity", + "unit": "1", + "drought_char": "Severity Index of drought events", + "filename": tstype + "_Sev_index_of_Events", + "drought_numbers_level": np.arange(0, 9, 1), + }) + plot_map_spei_multi( + cfg, data_dict, input_filenames, colormap="gnuplot" + ) + namehlp = "Average " + cfg["indexname"] + " of drought events" + namehlp2 = tstype + "_Average_" + cfg["indexname"] + "_of_Events" + data_dict.update({ + "data": all_drought_mean[:, :, 3], + "var": (cfg["indexname"]).lower(), + "unit": "1", + "drought_char": namehlp, + "filename": namehlp2, + "drought_numbers_level": np.arange(-2.8, -1.8, 0.2), + }) + plot_map_spei_multi( + cfg, data_dict, input_filenames, colormap="gnuplot" + ) def _plot_single_maps(cfg, cube2, drought_show, tstype, input_filenames): """Plot map of drought characteristics for individual models and times.""" cube2.data = drought_show.data[:, :, 0] - name_dict = {'add_to_filename': tstype + '_No_of_Events_per_year', - 'name': tstype + ' Number of drought events per year', - 'var': 'frequency', - 'unit': 'year-1', - 'drought_char': 'Number of drought events per year', - 'input_filenames': input_filenames} - plot_map_spei(cfg, cube2, np.arange(0, 0.4, 0.05), - name_dict) + name_dict = { + "add_to_filename": tstype + "_No_of_Events_per_year", + "name": tstype + " Number of drought events per year", + "var": "frequency", + "unit": "year-1", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + } + plot_map_spei(cfg, cube2, np.arange(0, 0.4, 0.05), name_dict) # plot the average duration of drought events cube2.data = drought_show.data[:, :, 1] - name_dict.update({'add_to_filename': tstype + '_Dur_of_Events', - 'name': tstype + ' Duration of drought events(month)', - 'var': 'duration', - 'unit': 'month', - 'drought_char': 'Number of drought events per year', - 'input_filenames': input_filenames}) + name_dict.update({ + "add_to_filename": tstype + "_Dur_of_Events", + "name": tstype + " Duration of drought events(month)", + "var": "duration", + "unit": "month", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + }) plot_map_spei(cfg, cube2, np.arange(0, 6, 1), name_dict) # plot the average severity index of drought events cube2.data = drought_show.data[:, :, 2] - name_dict.update({'add_to_filename': tstype + '_Sev_index_of_Events', - 'name': tstype + ' Severity Index of drought events', - 'var': 'severity', - 'unit': '1', - 'drought_char': 'Number of drought events per year', - 'input_filenames': input_filenames}) + name_dict.update({ + "add_to_filename": tstype + "_Sev_index_of_Events", + "name": tstype + " Severity Index of drought events", + "var": "severity", + "unit": "1", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + }) plot_map_spei(cfg, cube2, np.arange(0, 9, 1), name_dict) # plot the average spei of drought events cube2.data = drought_show.data[:, :, 3] - namehlp = tstype + '_Avr_' + cfg['indexname'] + '_of_Events' - namehlp2 = tstype + '_Average_' + cfg['indexname'] + '_of_Events' - name_dict.update({'add_to_filename': namehlp, - 'name': namehlp2, - 'var': 'severity', - 'unit': '1', - 'drought_char': 'Number of drought events per year', - 'input_filenames': input_filenames}) + namehlp = tstype + "_Avr_" + cfg["indexname"] + "_of_Events" + namehlp2 = tstype + "_Average_" + cfg["indexname"] + "_of_Events" + name_dict.update({ + "add_to_filename": namehlp, + "name": namehlp2, + "var": "severity", + "unit": "1", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + }) plot_map_spei(cfg, cube2, np.arange(-2.8, -1.8, 0.2), name_dict) @@ -1628,11 +1765,11 @@ def runs_of_ones_array_spei(bits, spei): bounded = np.hstack(([0], bits, [0])) # get 1 at run starts and -1 at run ends difs = np.diff(bounded) - run_starts, = np.where(difs > 0) - run_ends, = np.where(difs < 0) + (run_starts,) = np.where(difs > 0) + (run_ends,) = np.where(difs < 0) spei_sum = np.full(len(run_starts), 0.5) for iii, indexs in enumerate(run_starts): - spei_sum[iii] = np.sum(spei[indexs:run_ends[iii]]) + spei_sum[iii] = np.sum(spei[indexs : run_ends[iii]]) return [run_ends - run_starts, spei_sum] @@ -1667,15 +1804,16 @@ def count_spells(data, threshold, axis): return_var[ilat, ilon, 3] = data_help[0] else: data_hits = data_help < threshold - [events, spei_sum] = runs_of_ones_array_spei(data_hits, - data_help) + [events, spei_sum] = runs_of_ones_array_spei( + data_hits, data_help + ) return_var[ilat, ilon, 0] = np.count_nonzero(events) return_var[ilat, ilon, 1] = np.mean(events) - return_var[ilat, ilon, 2] = np.mean((spei_sum * events) / - (np.mean(data_help - [data_hits]) - * np.mean(events))) + return_var[ilat, ilon, 2] = np.mean( + (spei_sum * events) + / (np.mean(data_help[data_hits]) * np.mean(events)) + ) return_var[ilat, ilon, 3] = np.mean(spei_sum / events) return return_var @@ -1683,73 +1821,102 @@ def count_spells(data, threshold, axis): def get_latlon_index(coords, lim1, lim2): """Get index for given values between two limits (1D), e.g. lats, lons.""" - index = (np.where(np.absolute(coords - (lim2 + lim1) - / 2.0) <= (lim2 - lim1) - / 2.0))[0] + index = ( + np.where( + np.absolute(coords - (lim2 + lim1) / 2.0) <= (lim2 - lim1) / 2.0 + ) + )[0] return index -def plot_map_spei_multi(cfg, data_dict, input_filenames, colormap='jet'): +def plot_map_spei_multi(cfg, data_dict, input_filenames, colormap="jet"): """Plot contour maps for multi model mean.""" - spei = np.ma.array(data_dict['data'], mask=np.isnan(data_dict['data'])) + spei = np.ma.array(data_dict["data"], mask=np.isnan(data_dict["data"])) # Get latitudes and longitudes from cube - lons = data_dict['longitude'] + lons = data_dict["longitude"] if max(lons) > 180.0: lons = np.where(lons > 180, lons - 360, lons) # sort the array index = np.argsort(lons) lons = lons[index] - spei = spei[np.ix_(range(data_dict['latitude'].size), index)] + spei = spei[np.ix_(range(data_dict["latitude"].size), index)] # Plot data # Create figure and axes instances - subplot_kw = {'projection': cart.PlateCarree(central_longitude=0.0)} + subplot_kw = {"projection": cart.PlateCarree(central_longitude=0.0)} fig, axx = plt.subplots(figsize=(6.5, 4), subplot_kw=subplot_kw) - axx.set_extent([-180.0, 180.0, -90.0, 90.0], - cart.PlateCarree(central_longitude=0.0)) + axx.set_extent( + [-180.0, 180.0, -90.0, 90.0], cart.PlateCarree(central_longitude=0.0) + ) # Draw filled contours - cnplot = plt.contourf(lons, data_dict['latitude'], spei, - data_dict['drought_numbers_level'], - transform=cart.PlateCarree(central_longitude=0.0), - cmap=colormap, extend='both', corner_mask=False) + cnplot = plt.contourf( + lons, + data_dict["latitude"], + spei, + data_dict["drought_numbers_level"], + transform=cart.PlateCarree(central_longitude=0.0), + cmap=colormap, + extend="both", + corner_mask=False, + ) # Draw coastlines axx.coastlines() # Add colorbar - cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation='horizontal') + cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation="horizontal") # Add colorbar title string - if data_dict['model_kind'] == 'Difference': - cbar.set_label(data_dict['model_kind'] + ' ' - + data_dict['drought_char'] + ' [%]') + if data_dict["model_kind"] == "Difference": + cbar.set_label( + data_dict["model_kind"] + " " + data_dict["drought_char"] + " [%]" + ) else: - cbar.set_label(data_dict['model_kind'] + ' ' - + data_dict['drought_char']) + cbar.set_label( + data_dict["model_kind"] + " " + data_dict["drought_char"] + ) # Set labels and title to each plot - axx.set_xlabel('Longitude') - axx.set_ylabel('Latitude') - axx.set_title(data_dict['datasetname'] + ' ' + data_dict['model_kind'] + - ' ' + data_dict['drought_char']) + axx.set_xlabel("Longitude") + axx.set_ylabel("Latitude") + axx.set_title( + data_dict["datasetname"] + + " " + + data_dict["model_kind"] + + " " + + data_dict["drought_char"] + ) # Sets number and distance of x ticks axx.set_xticks(np.linspace(-180, 180, 7)) # Sets strings for x ticks - axx.set_xticklabels(['180°W', '120°W', '60°W', - '0°', '60°E', '120°E', - '180°E']) + axx.set_xticklabels([ + "180°W", + "120°W", + "60°W", + "0°", + "60°E", + "120°E", + "180°E", + ]) # Sets number and distance of y ticks axx.set_yticks(np.linspace(-90, 90, 7)) # Sets strings for y ticks - axx.set_yticklabels(['90°S', '60°S', '30°S', '0°', - '30°N', '60°N', '90°N']) + axx.set_yticklabels(["90°S", "60°S", "30°S", "0°", "30°N", "60°N", "90°N"]) fig.tight_layout() - fig.savefig(get_plot_filename(cfg['indexname'] + '_map' + - data_dict['filename'] + '_' + - data_dict['datasetname'], cfg), dpi=300) + fig.savefig( + get_plot_filename( + cfg["indexname"] + + "_map" + + data_dict["filename"] + + "_" + + data_dict["datasetname"], + cfg, + ), + dpi=300, + ) plt.close() _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames) @@ -1762,113 +1929,150 @@ def plot_map_spei(cfg, cube, levels, name_dict): np.ma.masked_less_equal(spei, 0) # Get latitudes and longitudes from cube - name_dict.update({'latitude': cube.coord('latitude').points}) - lons = cube.coord('longitude').points + name_dict.update({"latitude": cube.coord("latitude").points}) + lons = cube.coord("longitude").points lons = np.where(lons > 180, lons - 360, lons) # sort the array index = np.argsort(lons) lons = lons[index] - name_dict.update({'longitude': lons}) - spei = spei[np.ix_(range(len(cube.coord('latitude').points)), index)] + name_dict.update({"longitude": lons}) + spei = spei[np.ix_(range(len(cube.coord("latitude").points)), index)] # Get data set name from cube try: - dataset_name = cube.metadata.attributes['model_id'] + dataset_name = cube.metadata.attributes["model_id"] except KeyError: try: - dataset_name = cube.metadata.attributes['source_id'] + dataset_name = cube.metadata.attributes["source_id"] except KeyError: - dataset_name = 'Observations' + dataset_name = "Observations" # Plot data # Create figure and axes instances - subplot_kw = {'projection': cart.PlateCarree(central_longitude=0.0)} + subplot_kw = {"projection": cart.PlateCarree(central_longitude=0.0)} fig, axx = plt.subplots(figsize=(8, 4), subplot_kw=subplot_kw) - axx.set_extent([-180.0, 180.0, -90.0, 90.0], - cart.PlateCarree(central_longitude=0.0)) + axx.set_extent( + [-180.0, 180.0, -90.0, 90.0], cart.PlateCarree(central_longitude=0.0) + ) # np.set_printoptions(threshold=np.nan) # Draw filled contours - cnplot = plt.contourf(lons, cube.coord('latitude').points, spei, - levels, - transform=cart.PlateCarree(central_longitude=0.0), - cmap='gnuplot', extend='both', corner_mask=False) + cnplot = plt.contourf( + lons, + cube.coord("latitude").points, + spei, + levels, + transform=cart.PlateCarree(central_longitude=0.0), + cmap="gnuplot", + extend="both", + corner_mask=False, + ) # Draw coastlines axx.coastlines() # Add colorbar - cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation='horizontal') + cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation="horizontal") # Add colorbar title string - cbar.set_label(name_dict['name']) + cbar.set_label(name_dict["name"]) # Set labels and title to each plot - axx.set_xlabel('Longitude') - axx.set_ylabel('Latitude') - axx.set_title(dataset_name + ' ' + name_dict['name']) + axx.set_xlabel("Longitude") + axx.set_ylabel("Latitude") + axx.set_title(dataset_name + " " + name_dict["name"]) # Sets number and distance of x ticks axx.set_xticks(np.linspace(-180, 180, 7)) # Sets strings for x ticks - axx.set_xticklabels(['180°W', '120°W', '60°W', - '0°', '60°E', '120°E', - '180°E']) + axx.set_xticklabels([ + "180°W", + "120°W", + "60°W", + "0°", + "60°E", + "120°E", + "180°E", + ]) # Sets number and distance of y ticks axx.set_yticks(np.linspace(-90, 90, 7)) # Sets strings for y ticks - axx.set_yticklabels(['90°S', '60°S', '30°S', '0°', - '30°N', '60°N', '90°N']) + axx.set_yticklabels(["90°S", "60°S", "30°S", "0°", "30°N", "60°N", "90°N"]) fig.tight_layout() - fig.savefig(get_plot_filename(cfg['indexname'] + '_map' + - name_dict['add_to_filename'] + '_' + - dataset_name, cfg), dpi=300) + fig.savefig( + get_plot_filename( + cfg["indexname"] + + "_map" + + name_dict["add_to_filename"] + + "_" + + dataset_name, + cfg, + ), + dpi=300, + ) plt.close() _provenance_map_spei(cfg, name_dict, spei, dataset_name) -def plot_time_series_spei(cfg, cube, filename, add_to_filename=''): +def plot_time_series_spei(cfg, cube, filename, add_to_filename=""): """Plot time series.""" # SPEI vector to plot spei = cube.data # Get time from cube - time = cube.coord('time').points + time = cube.coord("time").points # Adjust (ncdf) time to the format matplotlib expects - add_m_delta = mda.datestr2num('1850-01-01 00:00:00') + add_m_delta = mda.datestr2num("1850-01-01 00:00:00") time = time + add_m_delta # Get data set name from cube try: - dataset_name = cube.metadata.attributes['model_id'] + dataset_name = cube.metadata.attributes["model_id"] except KeyError: try: - dataset_name = cube.metadata.attributes['source_id'] + dataset_name = cube.metadata.attributes["source_id"] except KeyError: - dataset_name = 'Observations' - - data_dict = {'data': spei, - 'time': time, - 'var': cfg['indexname'], - 'dataset_name': dataset_name, - 'unit': '1', - 'filename': filename, - 'area': add_to_filename} + dataset_name = "Observations" + + data_dict = { + "data": spei, + "time": time, + "var": cfg["indexname"], + "dataset_name": dataset_name, + "unit": "1", + "filename": filename, + "area": add_to_filename, + } fig, axx = plt.subplots(figsize=(16, 4)) - axx.plot_date(time, spei, '-', tz=None, xdate=True, ydate=False, - color='r', linewidth=4., linestyle='-', alpha=1., - marker='x') - axx.axhline(y=-2, color='k') + axx.plot_date( + time, + spei, + "-", + tz=None, + xdate=True, + ydate=False, + color="r", + linewidth=4.0, + linestyle="-", + alpha=1.0, + marker="x", + ) + axx.axhline(y=-2, color="k") # Plot labels and title - axx.set_xlabel('Time') - axx.set_ylabel(cfg['indexname']) - axx.set_title('Mean ' + cfg['indexname'] + ' ' + - data_dict['dataset_name'] + ' ' - + data_dict['area']) + axx.set_xlabel("Time") + axx.set_ylabel(cfg["indexname"]) + axx.set_title( + "Mean " + + cfg["indexname"] + + " " + + data_dict["dataset_name"] + + " " + + data_dict["area"] + ) # Set limits for y-axis axx.set_ylim(-4.0, 4.0) @@ -1876,11 +2080,17 @@ def plot_time_series_spei(cfg, cube, filename, add_to_filename=''): # Often improves the layout fig.tight_layout() # Save plot to file - fig.savefig(get_plot_filename(cfg['indexname'] + - '_time_series_' + - data_dict['area'] + - '_' + - data_dict['dataset_name'], cfg), dpi=300) + fig.savefig( + get_plot_filename( + cfg["indexname"] + + "_time_series_" + + data_dict["area"] + + "_" + + data_dict["dataset_name"], + cfg, + ), + dpi=300, + ) plt.close() _provenance_time_series_spei(cfg, data_dict) From 9dc652927e90d8c08613491b3e6967a01a2971de Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 25 Feb 2025 16:05:09 +0100 Subject: [PATCH 29/66] ruff up utils.py --- esmvaltool/diag_scripts/droughts/utils.py | 1120 +++++++++------------ 1 file changed, 466 insertions(+), 654 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 1040b314f4..89a6978cdd 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -1,13 +1,12 @@ from __future__ import annotations + import datetime as dt import itertools as it import logging -import os -from contextlib import suppress -from os.path import dirname as par_dir +from calendar import monthrange from pathlib import Path from pprint import pformat -from tkinter import W +from typing import Any import cartopy as ct import cartopy.crs as cart @@ -18,28 +17,28 @@ import matplotlib.dates as mdates import matplotlib.pyplot as plt import numpy as np -import pandas as pd -import xarray as xr import yaml +from cartopy.mpl.geoaxes import GeoAxes from cf_units import Unit from esmvalcore import preprocessor as pp from iris.analysis import Aggregator from iris.coords import AuxCoord +from iris.cube import Cube, CubeList from iris.util import equalise_attributes +from matplotlib.figure import Figure import esmvaltool.diag_scripts.shared.names as n -from esmvaltool.diag_scripts import shared from esmvaltool.diag_scripts.shared import ( ProvenanceLogger, get_cfg, get_diagnostic_filename, get_plot_filename, - select_metadata, group_metadata, + select_metadata, ) from esmvaltool.diag_scripts.shared._base import _get_input_data_files -logger = logging.getLogger(os.path.basename(__file__)) +log = logging.getLogger(Path(__file__).name) # fmt: off DENSITY = AuxCoord( @@ -47,7 +46,6 @@ long_name="density", units="kg m-3") -# fmt: off FNAME_FORMAT = "{project}_{reference_dataset}_{mip}_{exp}_{ensemble}_{short_name}_{start_year}-{end_year}" CONTINENTAL_REGIONS = { @@ -60,8 +58,56 @@ "Asia": ["RAR", "WSB", "ESB", "RFE", "WCA", "ECA", "TIB", "EAS", "ARP", "SAS", "SEA"], "Australia": ["NAU", "CAU", "EAU", "SAU", "NZ", "WAN", "EAN"], } + +HEX_POSITIONS ={ + "NWN": [2, 0], "NEN": [4, 0], "GIC": [6.5, -0.5], "NEU": [14, 0], + "RAR": [20, 0], "WNA": [1, 1], "CNA": [3, 1], "ENA": [5, 1], + "WCE": [13, 1], "EEU": [15, 1], "WSB": [17, 1], "ESB": [19, 1], + "RFE": [21, 1], "NCA": [2, 2], "MED": [14, 2], "WCA": [16, 2], + "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], # "CAR": [5, 3], + "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], # "PAC": [27.5, 3.3], + "NWS": [6, 4], "NSA": [8, 4], "WAF": [12, 4], "CAF": [14, 4], + "NEAF": [16, 4], "NAU": [24.5, 4.3], "SAM": [7, 5], "NES": [9, 5], + "WSAF": [13, 5], "SEAF": [15, 5], "MDG": [17.5, 5.3], + "CAU": [23.5, 5.3], "EAU": [25.5, 5.3], "SWS": [6, 6], "SES": [8, 6], + "ESAF": [14, 6], "SAU": [24.5, 6.3], "NZ": [27, 6.5], "SSA": [7, 7], +} + +INDEX_META = { + "CDD": { + "long_name": "Conscutive Dry Days", + "short_name": "CDD", + "units": "days", + "standard_name": "consecutive_dry_days", + }, + "PDSI": { + "long_name": "Palmer Drought Severity Index", + "showrt_name": "PDSI", + "units": "1", + "standard_name": "palmer_drought_severity_index", + }, + "SCPDSI": { + "long_name": "Self-calibrated Palmer Drought Severity Index", + "short_name": "scPDSI", + "units": "1", + "standard_name": "self_calibrated_palmer_drought_severity_index", + }, + "SPI": { + "long_name": "Standardized Precipitation Index", + "short_name": "SPI", + "units": "1", + "standard_name": "standardized_precipitation_index", + }, + "SPEI": { + "long_name": "Standardized Precipitation Evapotranspiration Index", + "short_name": "SPEI", + "units": "1", + "standard_name": "standardized_precipitation_evapotranspiration_index", + }, +} # fmt: on + # REGION_NAMES = { # 'Arabian-Peninsula', # 'Arabian-Sea', @@ -124,7 +170,9 @@ # } -def merge_list_cube(cube_list, aux_name="dataset", points=None, equalize=True): +def merge_list_cube( + cube_list: list, aux_name: str = "dataset", *, equalize: bool = True, +) -> Cube: """Merge a list of cubes into a single one with an auxiliary variable. Useful for applying statistics along multiple cubes. The time coordinate is @@ -139,8 +187,6 @@ def merge_list_cube(cube_list, aux_name="dataset", points=None, equalize=True): equalize : bool, optional Drops differences in attributes, otherwise raises an error. Defaults to True. - points : list, optional - Set values or labels as new coordinate points. Returns ------- @@ -150,26 +196,25 @@ def merge_list_cube(cube_list, aux_name="dataset", points=None, equalize=True): for ds_index, ds_cube in enumerate(cube_list): coord = iris.coords.AuxCoord(ds_index, long_name=aux_name) ds_cube.add_aux_coord(coord) - cubes = iris.cube.CubeList(cube_list) - logger.info("formed %s: %s", type(cubes), cubes) + cubes = CubeList(cube_list) + log.info("formed %s: %s", type(cubes), cubes) if equalize: removed = equalise_attributes(cubes) - logger.info("removed different attributes: %s", removed) + log.info("removed different attributes: %s", removed) for cube in cubes: cube.remove_coord("time") - merged = cubes.merge_cube() - return merged + return cubes.merge_cube() def fold_meta( cfg: dict, meta: dict, - cfg_keys: list|None=None, - meta_keys: list|None=None, - variables: list|None=None, + cfg_keys: list | None = None, + meta_keys: list | None = None, + variables: list | None = None, ) -> tuple: """Create combinations of meta data and data constraints. - + cfg["variables"] overwrites meta["short_names"]. Parameters @@ -206,8 +251,9 @@ def fold_meta( groups = { gk: list( group_metadata( - select_metadata(meta, short_name=variables[0]), gk - ).keys() + select_metadata(meta, short_name=variables[0]), + gk, + ).keys(), ) for gk in meta_keys } @@ -218,7 +264,7 @@ def fold_meta( try: groups[g_map.get(ckey, ckey)] = cfg[ckey] except KeyError: - logger.warning("No '%s' found in plot config", ckey) + log.warning("No '%s' found in plot config", ckey) combinations = it.product(*groups.values()) return combinations, groups, meta_keys @@ -238,7 +284,8 @@ def select_meta_from_combi(meta: list, combi: dict, groups: dict) -> tuple: Returns ------- tuple - A tuple containing the selected metadata and the configuration dictionary. + A tuple containing the selected metadata and the configuration + dictionary. """ this_cfg = dict(zip(groups.keys(), combi)) filter_cfg = clean_meta(this_cfg) # remove non meta keys @@ -246,19 +293,19 @@ def select_meta_from_combi(meta: list, combi: dict, groups: dict) -> tuple: return this_meta, this_cfg -def list_meta_keys(meta:list, group:dict) -> list: +def list_meta_keys(meta: list, group: dict) -> list: """Return a list of all keys found for a group in the meta data.""" return list(group_metadata(meta, group).keys()) -def mkplotdir(cfg: dict, dname: str|Path)-> None: +def mkplotdir(cfg: dict, dname: str | Path) -> None: """Create a sub directory for plots if it does not exist.""" new_dir = Path(cfg["plot_dir"] / dname) if not new_dir.is_dir(): Path.mkdir(new_dir) -def sort_cube(cube:iris.cube, coord:str="longitude")-> iris.cube: +def sort_cube(cube: Cube, coord: str = "longitude") -> Cube: """Sort data along a one-dimensional numerical coordinate. Parameters @@ -287,7 +334,7 @@ def sort_cube(cube:iris.cube, coord:str="longitude")-> iris.cube: return cube[tuple(index)] -def fix_longitude(cube: iris.cube) -> iris.cube.Cube: +def fix_longitude(cube: Cube) -> Cube: """Return a cube with 0 centered longitude coords. updating the longitude coord and sorting the data accordingly @@ -298,12 +345,14 @@ def fix_longitude(cube: iris.cube) -> iris.cube.Cube: ] try: cube.add_aux_coord( - iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), 2, + iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), + 2, ) - except Exception as e: - logger.warning("TODO: hardcoded dimensions in ut.fix_longitude") + except Exception: + log.warning("TODO: hardcoded dimensions in ut.fix_longitude") cube.add_aux_coord( - iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), 1 + iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), + 1, ) # sort data and fixed coordinates cube = sort_cube(cube, coord="fixed_lon") @@ -321,31 +370,25 @@ def fix_longitude(cube: iris.cube) -> iris.cube.Cube: return cube -def get_datetime_coords(cube, coord="time"): - """returns the time coordinate of the cube converted - from cf_date to mpl compatible datetime objects. +def get_datetime_coords(cube, coord="time") -> np.ndarray: + """Return mpl compatible points of time coordinate. + + Converted from cf_date to mpl compatible datetime objects. TODO: this seems not to work with current Iris version? but cf_date.dateime contains the year aswell - TODO: the difference behaviour may be caused by different time coords in datasets - nc.num2date default behaviour changed to use only_cf_times, change back with + TODO: the difference behaviour may be caused by different time coords in + datasets + nc.num2date default behaviour changed to use only_cf_times, change back + swith only_use_python_datetimes=True """ tc = cube.coord(coord) - # logger.info(tc.units) - fixed = iplt._fixup_dates(tc, tc.points) - # logger.info(tc.points[0:12]) - # fixed = num2date(tc.points, str(tc.units), only_use_python_datetimes=True) - # logger.info(fixed) - # try: - # fixed = [f.datetime for f in fixed] - # except Exception as e: - # logger.info("probably already in datetime format?") - # logger.info(e) - return fixed - - -def get_start_year(cube, coord="time"): - """returns the year of the first time coord point. + return iplt._fixup_dates(tc, tc.points) + + +def get_start_year(cube: Cube, coord: str = "time") -> int: + """Get the year of the first time coord point. + Works for datetime and cf_calendar types """ tc = cube.coord(coord) @@ -356,49 +399,59 @@ def get_start_year(cube, coord="time"): return first.year -def get_meta_list(meta, group, select=None): - """list all entries found for the group key as a list. - with a given selection, the meta data will be filtered first. +def get_meta_list(meta: dict, group: str, select: dict | None = None) -> list: + """ + List all entries found for the group key as a list. - Args: - meta (dict): full meta data - group (str): key to search for. Defaults to "alias" - select (dict, optional): dict like {'short_name': 'pdsi'} that is passed to a selection. + With a given selection, the meta data will be filtered first. - Returns: - list: of collected values for group key. + Parameters + ---------- + meta : dict + Full meta data. + group : str + Key to search for. Defaults to "alias". + select : dict, optional + Dictionary like {'short_name': 'pdsi'} that is passed to a selection. + + Returns + ------- + list + Collected values for the group key. """ if select is not None: meta = select_metadata(meta, **select) return list(group_metadata(meta, group).keys()) -def get_datasets(cfg): +def get_datasets(cfg: dict) -> dict: + """Return a dictionary of datasets and their metadata.""" metadata = cfg["input_data"].values() return group_metadata(metadata, "dataset").keys() -def get_dataset_scenarios(cfg): - """ - returns iterable of meta data for all dataset/scenario combinations - :param cfg: - :return: - """ +def get_dataset_scenarios(cfg: dict) -> list: + """Combine datasets and scenarios to a list of pairs of strings.""" metadata = cfg["input_data"].values() input_datasets = group_metadata(metadata, "dataset").keys() input_scenarios = group_metadata(metadata, "alias").keys() - return it.product(input_datasets, input_scenarios) + return list(it.product(input_datasets, input_scenarios)) + +def date_tick_layout( + fig, + ax, + dates: list | None = None, + label: str = "Time", + years: int | None = 1, +) -> None: + """Update a time series figure to use date/year ticks and grid. -def date_tick_layout(fig, ax, dates=None, label="Time", auto=True, years=1): - """update a figure (timeline) to use - date/year ticks and grid. :param fig: figure that will be updated :param ax: ax to set ticks/labels/limits on :param dates: optional, to set limits :param label: ax label - :param auto: if true auto format instead of year - :param years: if not auto, tick every x years + :param years: tick every x years, if None auto formatter is used :return: nothing, updates figure in place """ ax.set_xlabel(label) @@ -406,11 +459,11 @@ def date_tick_layout(fig, ax, dates=None, label="Time", auto=True, years=1): datemin = np.datetime64(dates[0], "Y") datemax = np.datetime64(dates[-1], "Y") + np.timedelta64(1, "Y") ax.set_xlim(datemin, datemax) - if auto: + if years is None: locator = mdates.AutoDateLocator() min_locator = mdates.YearLocator(1) else: - locator = mdates.YearLocator(years) + locator = mdates.YearLocator(years) # type: ignore[assignement] min_locator = mdates.YearLocator(1) year_formatter = mdates.DateFormatter("%Y") ax.grid(True) @@ -436,16 +489,19 @@ def auto_tick_layout(fig, ax, dates=None): fig.autofmt_xdate() # align, rotate and space for tick labels -def map_land_layout(fig, ax, plot, bounds, var, cbar=True): - """plot style for rectangular drought maps - masks the ocean by overlay, - add gridlines, - set left/bottom tick labels - TODO: make this a method of MapPlot class +def map_land_layout( + fig: Figure, ax: GeoAxes, plot, bounds, *, cbar: bool = True, +) -> None: + """Plot style for rectangular drought maps with land overlay. + + Mask the ocean by overlay, add gridlines, set left/bottom tick labels. """ ax.coastlines() ax.add_feature( - ct.feature.OCEAN, edgecolor="black", facecolor="white", zorder=1 + ct.feature.OCEAN, + edgecolor="black", + facecolor="white", + zorder=1, ) gl = ax.gridlines( crs=ct.crs.PlateCarree(), @@ -459,18 +515,15 @@ def map_land_layout(fig, ax, plot, bounds, var, cbar=True): gl.xlabels_top = False gl.ylabels_right = False if bounds is not None and cbar: - cb = plt.colorbar( - plot, ax=ax, ticks=bounds, extend="both", fraction=0.022 + plt.colorbar( + plot, + ax=ax, + ticks=bounds, + extend="both", + fraction=0.022, ) - # cb = plt.colorbar(plot, ax=ax, ticks=bounds[0:-1:]+[bounds[-1]], extend="both", fraction=0.022) elif cbar: - cb = plt.colorbar(plot, ax=ax, extend="both", fraction=0.022) - # cb.set_label(f"{var.upper()}") - # fig.tight_layout() - - -def get_cubes_dataset_alias(cfg): - pass + plt.colorbar(plot, ax=ax, extend="both", fraction=0.022) def get_scenarios(meta, **kwargs): @@ -478,27 +531,13 @@ def get_scenarios(meta, **kwargs): return list(group_metadata(selected, "alias").keys()) -def add_preprocessor_input(cfg): - """ - NOTE: One can add preprocessor output/variables as ancestor too, no need - to do it in the diagnostic... - """ - logger.warning( - "Please add variables to the ancestor list instead. This function will be removed in the future." - ) - run_dir = os.path.dirname(cfg["run_dir"]) - pp_dir = "/preproc/".join(run_dir.rsplit("/run/", 1)) - fake_cfg = {"input_files": [pp_dir]} - cfg["input_data"].update(_get_input_data_files(fake_cfg)) - +def add_ancestor_input(cfg: dict) -> None: + """Read ancestors settings.yml and add it's input_data to this config. -def add_ancestor_input(cfg): - """Read ancestors settings.yml and - add it's input_data to this config. TODO: make sure it don't break for non ancestor scripts TODO: recursive? (optional) """ - logger.info(f"add ancestors for {cfg[n.INPUT_FILES]}") + log.info("add ancestors for %s", cfg[n.INPUT_FILES]) for input_file in cfg[n.INPUT_FILES]: cfg_anc_file = ( "/run/".join(input_file.rsplit("/work/", 1)) + "/settings.yml" @@ -507,12 +546,8 @@ def add_ancestor_input(cfg): cfg["input_data"].update(_get_input_data_files(cfg_anc)) -def add_meta_files(cfg): - pass - - def _fixup_dates(coord, values): - """copy from iris plot.py source code""" + """Copy from iris plot.py source code""" if coord.units.calendar is not None and values.ndim == 1: # Convert coordinate values into tuples of # (year, month, day, hour, min, sec) @@ -523,7 +558,7 @@ def _fixup_dates(coord, values): try: import cftime import nc_time_axis - except ImportError: + except ImportError as err: msg = ( "Cannot plot against time in a non-gregorian " 'calendar, because "nc_time_axis" is not available : ' @@ -531,11 +566,12 @@ def _fixup_dates(coord, values): "https://github.com/SciTools/nc-time-axis to enable " "this usage." ) - raise iris.IrisError(msg) + raise iris.IrisError(msg) from err r = [ nc_time_axis.CalendarDateTime( - cftime.datetime(*date), coord.units.calendar + cftime.datetime(*date), + coord.units.calendar, ) for date in dates ] @@ -544,32 +580,26 @@ def _fixup_dates(coord, values): return values -def quick_save(cube, name, cfg): - """Simply save cube to netcdf file without additional information - - Args: - cube: iris.cube object to save - name: basename for the created netcdf file - cfg: user configuration containing file output path - """ +def quick_save(cube: Cube, name: str, cfg: dict) -> None: + """Simply save cube to netcdf file without additional information.""" if cfg.get("write_netcdf", True): diag_file = get_diagnostic_filename(name, cfg) - logger.info(f"quick save {diag_file}") + log.info("quick save %s", diag_file) iris.save(cube, target=diag_file) -def quick_load(cfg, context, strict=True): - """ - select input files from config wich matches the selection. loads and returns the first match. +def quick_load(cfg: dict, context: dict, *, strict=True) -> Cube: + """Load input files from config wich matches the selection. + + Select, load and return the first match. raises an error (if strict) or a warning for multiple matches. """ meta = cfg["input_data"].values() var_meta = select_metadata(meta, **context) if len(var_meta) != 1: if strict: - raise Exception() - # logger.warning(f"Unexpected amount of matching meta data: {len(var_meta)}") - print("warning meta data missmatch") + raise ValueError("Metadata missmatch") + log.warning("warning meta data missmatch") return iris.load_cube(var_meta[0]["filename"]) @@ -598,7 +628,7 @@ def get_basename(cfg, meta, prefix=None, suffix=None): def get_custom_basename(meta, folder="plots", prefix=None, suffix=None): - """manually create basenames for output files + """Manually create basenames for output files NOTE: for basename in configured naming format use get_basename """ @@ -622,7 +652,12 @@ def clean_meta(meta, **kwargs): return {key: val for key, val in meta.items() if key in valid_keys} -def select_single_metadata(meta, strict=True, **kwargs): +def select_single_metadata( + meta: list, + *, + strict: bool = True, + **kwargs: dict[str, Any], +) -> dict | None: """Filter meta data by arbitrary keys and return one matching result. For more/less then one match the first/none is returned or an error is @@ -647,34 +682,32 @@ def select_single_metadata(meta, strict=True, **kwargs): No matching entry """ selected_meta = select_metadata(meta, **kwargs) - # logger.info("dataset from alias: " + dataset) + # log.info("dataset from alias: " + dataset) if len(selected_meta) > 1: - logger.warning(f"Multiple entries found for Metadata: {selected_meta}") + log.warning("Multiple entries found for Metadata: %s", selected_meta) if strict: raise ValueError("Too many matching entries") elif len(selected_meta) == 0: - logger.warning(f"No Metadata found! For: {kwargs}") - logger.debug(f"{meta}") + log.warning("No Metadata found! For: %s", kwargs) if strict: raise ValueError("No matching entry") return None return selected_meta[0] -def select_single_meta(*args, **kwargs): - return select_single_metadata(*args, **kwargs) +# alias for convenience +select_single_meta = select_single_metadata -def date_to_months(date, start_year): - """translates datestings YYYY-MM to - total months since begin of start_year - """ +def date_to_months(date: str, start_year: int) -> int: + """Translate date YYYY-MM to number of months since start_year.""" years, months = [int(x) for x in date.split("-")] - return 12 * (years - start_year) + months + return int(12 * (years - start_year) + months) -def fix_interval(interval): +def fix_interval(interval: dict) -> dict: """Ensure that an interval has a label and a range. + TODO: replace "/" with "_" in diagnostics who use this. """ if "range" not in interval: @@ -684,8 +717,14 @@ def fix_interval(interval): return interval -def get_plot_filename(cfg, basename, meta=dict(), replace=dict()): +def get_plot_fname( + cfg: dict, + basename, + meta: dict | None = None, + replace: dict | None = None, +) -> str: """Get a valid path for saving a diagnostic plot. + This is an alternative to shared.get_diagnostic_filename. It uses cfg as first argument and accept metadata to format the basename. @@ -695,29 +734,31 @@ def get_plot_filename(cfg, basename, meta=dict(), replace=dict()): Dictionary with diagnostic configuration. basename: str The basename of the file. - meta: dict - Metadata to format the basename. + meta: dict, optional + Metadata to format the basename. If None, empty dict is used. replace: dict Dictionary with strings to replace in the basename. + If None, empty dict is used. Returns ------- str: A valid path for saving a diagnostic plot. """ + meta = {} if meta is None else meta + replace = {} if replace is None else replace basename = basename.format(**meta) for key, value in replace.items(): basename = basename.replace(key, value) - return os.path.join( - cfg["plot_dir"], - f"{basename}.{cfg['output_file_type']}", - ) + fpath = Path(cfg["plot_dir"]) / basename + return str(fpath.with_suffix(cfg["output_file_type"])) -def slice_cube_interval(cube, interval): - """returns a cube slice for given interval +def slice_cube_interval(cube: Cube, interval: list) -> Cube: + """Return cube slice for given interval. + which is a list of strings (YYYY-mm) or int (index of cube) - NOTE: For 3D cubes (time first) + For 3D cubes time needs to be first dim. """ if isinstance(interval[0], int) and isinstance(interval[1], int): return cube[interval[0] : interval[1], :, :] @@ -729,80 +770,62 @@ def slice_cube_interval(cube, interval): return cube[t_start:t_end, :, :] -def find_first(nparr): - """finds first nonzero element of numpy array and returns index or -1. +def find_first(nparr: np.ndarray) -> int: + """Return index of first nonzero element of numpy array or -1. + Its faster than looping or using numpy.where. - https://stackoverflow.com/a/61117770/7268121 - nparr: (numpy.array) requires numerical data without negative zeroes. + nparr requires numerical data without negative zeroes. """ idx = nparr.view(bool).argmax() // nparr.itemsize - return idx if np.arr[idx] else -1 + return int(idx if np.arr[idx] else -1) -def add_spei_meta(cfg, name="spei", pos=0): - """NOTE: workaround to add missing meta for specific ancestor script""" - logger.info("adding meta file for save_spei output") +def add_spei_meta(cfg: dict, name: str = "spei", pos: int = 0) -> None: + """Add missing meta for specific ancestor script (workaround). + + NOTE: should be obsolete, since PET.R and SPEI.R write metadata. + """ + log.info("adding meta file for save_spei output") spei_fname = ( cfg["tmp_meta"]["filename"].split("/")[-1].replace("_pr_", f"_{name}_") ) - # FIXME: hardcoded index in input files.. needs to match recipe - spei_file = os.path.join(cfg["input_files"][pos], spei_fname) - logger.info(f"spei file path: {spei_file}") - logger.info("generating missing meta info") + spei_file = str(Path(cfg["input_files"][pos])/spei_fname) + log.info("spei file path: %s", spei_file) meta = cfg["tmp_meta"].copy() + meta.update(INDEX_META[name.upper()]) meta["filename"] = spei_file - meta["short_name"] = name - meta["long_name"] = "Standardised Precipitation-Evapotranspiration Index" - # meta['standard_name'] = 'standardised_precipitation_evapotranspiration_index' - if name.lower() == "spi": - meta["long_name"] = "Standardised Precipitation Index" - # meta['standard_name'] = 'standardised_precipitation_index' cfg["input_data"][spei_file] = meta -def fix_calendar(cube): - """Fix Calendar. - Fixes wrong calendars to 'gregorian' instead of 'proleptic_gregorian' or '365_day' or any other. - TODO: use pp.regrid_time when available in esmvalcore. - - Args: - cube (iris.cube.Cube): Input cubes which need to be fixed. - - Returns: - iris.cube.Cube: with fixed calendars +def fix_calendar(cube: Cube) -> Cube: + """Convert cubes calendar to gregorian. + TODO: use pp.regrid_time when available in esmvalcore. """ time = cube.coord("time") # if time.units.name == "days since 1850-1-1 00:00:00": - logger.info("renaming unit") + log.info("renaming unit") time.units = Unit("days since 1850-01-01", calendar=time.units.calendar) if time.units.calendar == "proleptic_gregorian": time.units = Unit(time.units.name, calendar="gregorian") - logger.info(f"renamed calendar: {time.units.calendar}") + log.info("renamed calendar: %s", time.units.calendar) if time.units.calendar != "gregorian": time.convert_units(Unit("days since 1850-01-01", calendar="gregorian")) - logger.info(f"converted time to: {time.units}") + log.info("converted time to: %s", time.units) return cube -def latlon_coords(cube): - """replace latitude and longitude - with lat and lon inplace - TODO: make this good! - """ - try: - cube.coord("longitude").rename("lon") - except Exception as e: - logging.info("no coord named longitude") - print(e) - try: +def latlon_coords(cube: Cube) -> None: + """Rename latitude, longitude coords to lat, lon inplace.""" + if "latitude" in cube.coords(): cube.coord("latitude").rename("lat") - except: - logging.info("no coord named latitude") + if "longitude" in cube.coords(): + cube.coord("longitude").rename("lon") def standard_time(cubes): - """Make sure all cubes' share the standard time coordinate. + """Make sure all cubes share the same standard time coordinate. + This function extracts the date information from the cube and reconstructs the time coordinate, resetting the actual dates to the 15th of the month or 1st of july for yearly data (consistent with @@ -813,45 +836,41 @@ def standard_time(cubes): different number of days in the year. NOTE: this might be replaced by preprocessor """ - from datetime import datetime - from esmvalcore.iris_helpers import date2num t_unit = Unit("days since 1850-01-01", calendar="standard") - for cube in cubes: # Extract date info from cube coord = cube.coord("time") years = [p.year for p in coord.units.num2date(coord.points)] months = [p.month for p in coord.units.num2date(coord.points)] days = [p.day for p in coord.units.num2date(coord.points)] - # Reconstruct default calendar if 0 not in np.diff(years): # yearly data - dates = [datetime(year, 7, 1, 0, 0, 0) for year in years] + dates = [dt.datetime(year, 7, 1, 0, 0, 0) for year in years] elif 0 not in np.diff(months): # monthly data dates = [ - datetime(year, month, 15, 0, 0, 0) + dt.datetime(year, month, 15, 0, 0, 0) for year, month in zip(years, months) ] elif 0 not in np.diff(days): # daily data dates = [ - datetime(year, month, day, 0, 0, 0) + dt.datetime(year, month, day, 0, 0, 0) for year, month, day in zip(years, months, days) ] if coord.units != t_unit: - logger.warning( + log.warning( "Multimodel encountered (sub)daily data and inconsistent " "time units or calendars. Attempting to continue, but " - "might produce unexpected results." + "might produce unexpected results.", ) else: raise ValueError( "Multimodel statistics preprocessor currently does not " - "support sub-daily data." + "support sub-daily data.", ) # Update the cubes' time coordinate (both point values and the units!) @@ -861,312 +880,131 @@ def standard_time(cubes): cube.coord("time").guess_bounds() -def guess_lat_lon_bounds(cube): - """guesses bounds for latitude and longitude if not existent.""" +def guess_lat_lon_bounds(cube: Cube) -> None: + """Guess bounds for latitude and longitude if missing.""" if not cube.coord("latitude").has_bounds(): cube.coord("latitude").guess_bounds() if not cube.coord("longitude").has_bounds(): cube.coord("longitude").guess_bounds() -def mmm(cube_list, mdtol=0, dropcoords=["time"], dropmethods=False): - """calculates mean and stdev along a cube list over all cubes returns two (mean and stdev) of same shape +def mmm( + cube_list: list | CubeList, + mdtol: float = 0, + dropcoords: list | None = None, + *, + dropmethods=False, +) -> tuple: + """Calculate mean and stdev along a cube list over all cubes. + + Return two (mean and stdev) of same shape TODO: merge alreadey exist, use that one, mean and std is trivial than. - Args: - cube_list ([type]): [description] + + Parameters + ---------- + cube_list : list|CubeList + List of iris cubes to be merged by mean. + mdtol : float, optional + Tolerance for mean calculation, by default 0 + dropcoords : list|None, optional + Coordinates to be dropped from the cubes. If None, only time will be + dropped. To keep all coords pass an empty list. """ + if dropcoords is None: + dropcoords = ["time"] for i, cube in enumerate(cube_list): - # code.interact(local=locals()) for coord_name in dropcoords: if cube.coords(coord_name): cube.remove_coord(coord_name) if dropmethods: cube.cell_methods = None cube.add_aux_coord(iris.coords.AuxCoord(i, long_name="dataset")) - # equalise_attributes(cube_list) - # cube.remove_coord("season_number") # add as drop_coord - cube_list = iris.cube.CubeList(cube_list) + cube_list = CubeList(cube_list) equalise_attributes(cube_list) - # common_depth = try: - # Note: just for testing: merged = cube_list.merge_cube() except iris.exceptions.MergeError as err: - # unique_seasons = set() - # for c in cube_list: - # unique_seasons.add(c.coord("season_number")) - # print(unique_seasons) - # for c in cube_list: - # print(c.coord("depth")) iris.util.describe_diff(cube_list[0], cube_list[1]) - raise err + raise iris.exceptions.MergeError from err if mdtol > 0: - logger.info(f"performing MMM with tolerance: {mdtol}") + log.info("performing MMM with tolerance: %s", mdtol) mean = merged.collapsed("dataset", iris.analysis.MEAN, mdtol=mdtol) sdev = merged.collapsed("dataset", iris.analysis.STD_DEV) return mean, sdev -def get_hex_positions(): - return { - "NWN": [2, 0], - "NEN": [4, 0], - "GIC": [6.5, -0.5], - "NEU": [14, 0], - "RAR": [20, 0], - "WNA": [1, 1], - "CNA": [3, 1], - "ENA": [5, 1], - "WCE": [13, 1], - "EEU": [15, 1], - "WSB": [17, 1], - "ESB": [19, 1], - "RFE": [21, 1], - "NCA": [2, 2], - "MED": [14, 2], - "WCA": [16, 2], - "ECA": [18, 2], - "TIB": [20, 2], - "EAS": [22, 2], - "SCA": [3, 3], - # "CAR": [5, 3], - "SAH": [13, 3], - "ARP": [15, 3], - "SAS": [19, 3], - "SEA": [23, 3], - # "PAC": [27.5, 3.3], - "NWS": [6, 4], - "NSA": [8, 4], - "WAF": [12, 4], - "CAF": [14, 4], - "NEAF": [16, 4], - "NAU": [24.5, 4.3], - "SAM": [7, 5], - "NES": [9, 5], - "WSAF": [13, 5], - "SEAF": [15, 5], - "MDG": [17.5, 5.3], - "CAU": [23.5, 5.3], - "EAU": [25.5, 5.3], - "SWS": [6, 6], - "SES": [8, 6], - "ESAF": [14, 6], - "SAU": [24.5, 6.3], - "NZ": [27, 6.5], - "SSA": [7, 7], - } - - -def get_region_data(): - """reads shapes.txt as csv file and returns a list of region names""" - fname = "/work/bd0854/b309169/ESMValTool-private/esmvaltool/diag_scripts/droughtindex/shapes.txt" - data = pd.read_csv(fname, sep=",", header=0, skiprows=0) - print(data) - return data - +def get_hex_positions() -> dict: + """Return a dictionary with hexagon positions for AR6 regions.""" + return HEX_POSITIONS -def get_region_abbrs(): - # abbr_positions = get_hex_positions() - data = get_region_data() - data.set_index("Name", inplace=True) - return {i: data.loc[i]["Abbr"] for i in data.index.tolist()} - -def add_aux_regions(cube, mask=None): - """Add an auxilary coordinate with region numbers. - Dimension names: 'lat' and 'lon'. - TODO: add option/fallback to use pp with shapefile instead - """ - import regionmask - - region = regionmask.defined_regions.ar6.land - xarr = xr.DataArray.from_iris(cube) - mask2d = mask if mask else region.mask(xarr).to_iris() - # newcube = cube.copy() - region_coord = iris.coords.AuxCoord( - mask2d.data, long_name="AR6 reference region", var_name="region" - ) - cube.add_aux_coord(region_coord, [1, 2]) - return cube - - -def regional_mean( - cube, - mask=None, - minmax=False, - stddev=False, -): - """Returns a cube with one dim_coord 'region' - which contains weighted means over 'lat', 'lon'. - TODO: allow minmax and stddev at the same time. Different returns or - return a dict? maybe add an metrics array: [mean, min, max, sum ...] - TODO: provide an alternative based on shape file and preprocessor in case - regionmask is not installed? - TODO: pass config and/or aux file path? - TODO: DEPRECATED use more general regiona_stats instead (includes maean) - """ - logger.warning("Please use regional_stats instead of regional_mean") - if minmax: - res = regional_stats_xarr( - {}, cube, operators=["min", "mean", "max"] - ).values() - return res["min", "mean", "max"] - if stddev: - res = regional_stats_xarr({}, cube, operators["mean", "std_dev"]) - return res["mean"], res["std_dev"] - res = regional_stats_xarr( - {}, cube, operators=["min", "mean", "max"] - ).values() - return res["mean"] - - -def regional_stats_xarr(cfg, cube, operators=["mean"], shapefile=None): - results = {} - if shapefile: - return regional_stats(cfg, cube, operators, shapefile) - try: - import regionmask - except: - err = "No Module regionmask. Install it or provide a shapefile." - raise ModuleError(err) - latlon_coords(cube) - region = regionmask.defined_regions.ar6.land - xarr = xr.DataArray.from_iris(cube) - mask3d = mask if mask else region.mask_3D(xarr) - weights = np.cos(np.deg2rad(xarr.lat)) # TODO: use cell area - weights3d = mask3d * weights - weighted = xarr.weighted(weights3d) - if "mean" in operators: - mean = weighted.mean(dim=("lat", "lon")) - result["mean"] = (mean.to_iris(),) - # stddev = weighted.sum_of_squares() / weights3d.sum() - if "std_dev" in operators: - stddev = weighted.std(dim=("lat", "lon")) - result["std_dev"] = stddev.to_iris() - if "min" in operators: - mini = xarr.where(mask3d).min(dim=("lat", "lon")) - result["min"] = mini.to_iris() - if "max" in operators: - maxi = xarr.where(mask3d).max(dim=("lat", "lon")) - result["max"] = maxi.to_iris() - return result - - -def regional_stats(cfg, cube, operator="mean"): - """Mean over IPCC Reference regions using shape file. - The shapefile (string) must be contained in cft (given by recipe). - It can be an absolute path or relative to the folder for auxilary data - configured in esmvaltool config. - """ - # if "shapefile" not in cfg: - # raise ValueError("A shapefile must be given or \ - # utils.regional_stats_xarr() be used with module regionmask.") - # shapefile = Path(cfg['auxiliary_data_dir']) / cfg['shapefile'] +def regional_stats(cfg, cube, operator="mean") -> dict: + """Calculate statistic over AR6 IPCC reference regions.""" guess_lat_lon_bounds(cube) extracted = pp.extract_shape(cube, "ar6", decomposed=True) - return pp.area_statistics(extracted, operator) -def transpose_by_names(cube, names): - """transposes an iris cube by dim-coords or their names""" +def transpose_by_names(cube: Cube, names: list) -> None: + """Transpose a cube by dim-coords or their names.""" new_dims = [cube.coord_dims(name)[0] for name in names] - print(new_dims) cube.transpose(new_dims) -def generate_metadata(work_folder, diag=None): - """create metadata from ancestor config and nc files. - - Can be used as workaround, to handle output of ancestors, that don't create - metadata.yml, similar to preprocessed variables. If metadata.yml exists in - the work folder (subfolders ignored) its content is returned instead. - """ - try: - return yaml.load(os.path.join(work_folder), "metadata.yml") - except: - logger.warning(f"no metadata.yml found in {work_folder}.") - raise NotImplementedError("TODO: generate metadata from cubes and cfg") - # TODO: provide fixes for some diagnostics or directly implement it. - # meta = {} - # cfg = yaml.read() - # nc_files = - - -def save_metadata(cfg, metadata): - """save dict as metadata.yml in workfolder.""" - with open(os.path.join(cfg["work_dir"], "metadata.yml"), "w") as wom: +def save_metadata(cfg: dict, metadata: dict) -> None: + """Save dict as metadata.yml in work folder.""" + with (Path(cfg["work_dir"]) / "metadata.yml").open("w") as wom: yaml.dump(metadata, wom) -def get_meta(index): - index = index.lower() - if index == "pdsi": - return dict( - variable_group="index", - standard_name="palmer_drought_severity_index", - short_name="pdsi", - long_name="Palmer Drought Severity Index", - units="1", - ) - elif index in ["scpdsi", "sc-pdsi"]: - return dict( - variable_group="index", - standard_name="self_calibrated_palmer_drought_severity_index", - short_name="scpdsi", - long_name="Self Calibrated Palmer Drought Severity Index", - units="1", - ) - else: - logger.error(f"No default meta data for Index: {index}") - raise NotImplementedError +def get_index_meta(index)->dict: + """Return default meta data for a given index. + + kept for compability. Use INDEX_META dict instead. + """ + return INDEX_META[index] -def set_defaults(target, defaults): - """Applies set_default on target for each entry of defaults +def set_defaults(target: dict, defaults: dict) -> None: + """Apply set_default on target for each entry of a dictionary. - This checks if a key exists in target, and only if not the keys are set with - values. It does not checks recursively for nested entries. + This checks if a key exists in target, and only if not the keys are set + with values. It does not checks recursively for nested entries. NOTE: this might be obsolete since python 3.9, as there are direct fallback assignments like: `target = defaults | target` - - Parameters - ---------- - target - dictionary to set the defaults on - defaults - dictionary containtaining defaults to be set on target """ - for key in defaults.keys(): + for key in defaults: target.setdefault(key, defaults[key]) -def sub_cfg(cfg, plot, key): +def sub_cfg(cfg: dict, plot: str, key: str) -> dict: """Get get merged general and plot type specific kwargs.""" if isinstance(cfg.get(key, {}), dict): general = cfg.get(key, {}).copy() specific = cfg.get(plot, {}).get(key, {}) general.update(specific) return general - else: - try: - return cfg[plot][key] - except KeyError: - return cfg[key] + try: + return cfg[plot][key] + except KeyError: + return cfg[key] -def aux_path(cfg, path): - """returns absolut path of an aux file.""" - if os.path.isabs(path): - return path - else: - return os.path.join(cfg["auxiliary_data_dir"], path) +def abs_auxilary_path(cfg: dict, path: str | Path) -> str: + """Return absolut path of an auxilary file.""" + if Path(path).is_absolute(): + return str(path) + return str(Path(cfg["auxiliary_data_dir"]) / path) -def remove_attributes(cube, ignore=[]): - """remove attributes of cubes or coords in place - used to clean up differences in cubes coordinates before merging +def remove_attributes( + cube: Cube | iris.Coord, + ignore: list | None = None, +) -> None: + """Remove most attributes of cube or coords in place. + + Used to clean up differences in cubes coordinates before merging Parameters ---------- @@ -1174,29 +1012,17 @@ def remove_attributes(cube, ignore=[]): iris.Cube or iris.Coord ignore Optional: List of Strings of attributes, that are not removed - Default: [] + By default: [] """ - remove = [] - for attr in cube.attributes: - if not attr in ignore: - remove.append(attr) + if ignore is None: + ignore = [] + remove = [attr for attr in cube.attributes if attr not in ignore] for attr in remove: del cube.attributes[attr] -def convert_to_mmday_xarray(pr): - """convert precipitation of xarray from kg/m2/s to mm/day - - Args: - pr: xarray.dataarrray precipitation in kgm-2s-1 - """ - pr.values = pr.values * 60 * 60 * 24 - pr.attrs["units"] = "mm day-1" - return pr - - -def font_color(background): - """black or white depending on greyscale of the background +def font_color(background: str) -> str: + """Black or white depending on greyscale of the background. Parameters ---------- @@ -1205,30 +1031,29 @@ def font_color(background): """ if sum(mpl.colors.to_rgb(background)) > 1.5: return "black" - else: - return "white" + return "white" -def log_provenance(cfg, fname, record): +def log_provenance(cfg: dict, fname: str | Path, record: dict) -> None: + """Add provenance information to the Provenancelog.""" with ProvenanceLogger(cfg) as provenance_logger: provenance_logger.log(fname, record) -def get_time_range(cube): - """guesses the period of a cube based on the time coordinate.""" - if not isinstance(cube, iris.cube.Cube): +def get_time_range(cube: Cube) -> dict: + """Guess the period of a cube based on the time coordinate.""" + if not isinstance(cube, Cube): cube = iris.load_cube(cube) time = cube.coord("time") - print(time) - print(time.points) start = time.units.num2date(time.points[0]) end = time.units.num2date(time.points[-1]) return {"start_year": start.year, "end_year": end.year} -def guess_experiment(meta): - """guess experiment from filename - TODO: this is a workaround for incomplete meta data.. +def guess_experiment(meta: dict) -> None: + """Guess missing 'exp' in metadata from filename. + + TODO: This is a workaround for incomplete meta data. fix this in ancestor diagnostics rather than use this function. """ exps = ["historical", "ssp126", "ssp245", "ssp370", "ssp585"] @@ -1237,8 +1062,17 @@ def guess_experiment(meta): meta["exp"] = exp -def monthly_to_daily(cube, units="mm day-1", leap_years=True): - """convert monthly data to daily data inplace ignoring leap years""" +def monthly_to_daily( + cube: Cube, + units: str = "mm day-1", + *, + leap_years: bool = True, +) -> None: + """Convert monthly data to daily data inplace ignoring leap years. + + With leap_years=False this is similar to the same named function in utils.R + and compatible with the pet.R diagnostic. + """ months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] months = months * int((cube.shape[0] / 12) + 1) for i, s in enumerate(cube.slices_over(["time"])): @@ -1246,23 +1080,27 @@ def monthly_to_daily(cube, units="mm day-1", leap_years=True): days = months[i] cube.data[i] = cube.data[i] / days try: - from calendar import monthrange - time = s.coord("time") date = time.units.num2date(time.points[0]) days = monthrange(date.year, date.month)[1] except Exception as e: - logger.warning("date failed, using fixed days without leap year") - logger.warning(e) + log.warning("date failed, using fixed days without leap year") + log.warning(e) days = months[i] cube.data[i] = cube.data[i] / days cube.units = units -def daily_to_monthly(cube, units="mm month-1", leap_years=True): - """convert daily data to monthly data inplace - with leap_years=False this is similar to the same named function in utils.R - and compatible with pet.R +def daily_to_monthly( + cube: Cube, + units: str = "mm month-1", + *, + leap_years: bool = True, +) -> None: + """Convert daily data to monthly data inplace. + + With leap_years=False this is similar to the same named function in utils.R + and compatible with the pet.R diagnostic. """ months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] months = months * int((cube.shape[0] / 12) + 1) @@ -1272,14 +1110,12 @@ def daily_to_monthly(cube, units="mm month-1", leap_years=True): cube.data[i] = cube.data[i] * days continue try: # consider leapday - from calendar import monthrange - time = s.coord("time") date = time.units.num2date(time.points[0]) days = monthrange(date.year, date.month)[1] except Exception as e: - logger.warning("date failed, using fixed days without leap year") - logger.warning(e) + log.warning("date failed, using fixed days without leap year") + log.warning(e) days = months[i] cube.data[i] = cube.data[i] * days cube.units = units @@ -1302,13 +1138,17 @@ def _get_drought_data(cfg, cube): # make a new cube to increase the size of the data array # Make an aggregator from the user function. spell_no = Aggregator( - "spell_count", count_spells, units_func=lambda units: 1 + "spell_count", + count_spells, + units_func=lambda units: 1, ) new_cube = _make_new_cube(cube) # calculate the number of drought events and their average duration drought_show = new_cube.collapsed( - "time", spell_no, threshold=cfg["threshold"] + "time", + spell_no, + threshold=cfg["threshold"], ) drought_show.rename("Drought characteristics") # length of time series @@ -1347,7 +1187,10 @@ def _provenance_map_spei(cfg, name_dict, spei, dataset_name): ] provenance_record = get_provenance_record( - [name_dict["input_filenames"]], caption, ["global"], set_refs + [name_dict["input_filenames"]], + caption, + ["global"], + set_refs, ) diagnostic_file = get_diagnostic_filename( @@ -1366,13 +1209,10 @@ def _provenance_map_spei(cfg, name_dict, spei, dataset_name): + dataset_name, cfg, ) - - logger.info("Saving analysis results to %s", diagnostic_file) - + log.info("Saving analysis results to %s", diagnostic_file) cubesave = cube_to_save_ploted(spei, name_dict) iris.save(cubesave, target=diagnostic_file) - - logger.info( + log.info( "Recording provenance of %s:\n%s", diagnostic_file, pformat(provenance_record), @@ -1385,33 +1225,22 @@ def _provenance_map_spei(cfg, name_dict, spei, dataset_name): def _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames): """Set provenance for plot_map_spei_multi.""" caption = ( - "Global map of the multi-model mean of " - + data_dict["drought_char"] - + " [" - + data_dict["unit"] - + "] " - + "based on " - + cfg["indexname"] - + "." + f"Global map of the multi-model mean of " + f"{data_dict['drought_char']} [{data_dict['unit']}] based on " + f"{cfg['indexname']}." ) - if cfg["indexname"].lower == "spei": - set_refs = [ - "martin18grl", - "vicente10jclim", - ] + set_refs = [ "martin18grl", "vicente10jclim"] elif cfg["indexname"].lower == "spi": - set_refs = [ - "martin18grl", - "mckee93proc", - ] + set_refs = [ "martin18grl", "mckee93proc"] else: - set_refs = [ - "martin18grl", - ] + set_refs = [ "martin18grl"] provenance_record = get_provenance_record( - input_filenames, caption, ["global"], set_refs + input_filenames, + caption, + ["global"], + set_refs, ) diagnostic_file = get_diagnostic_filename( @@ -1431,11 +1260,11 @@ def _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames): cfg, ) - logger.info("Saving analysis results to %s", diagnostic_file) + log.info("Saving analysis results to %s", diagnostic_file) iris.save(cube_to_save_ploted(spei, data_dict), target=diagnostic_file) - logger.info( + log.info( "Recording provenance of %s:\n%s", diagnostic_file, pformat(provenance_record), @@ -1465,7 +1294,11 @@ def _provenance_time_series_spei(cfg, data_dict): ] provenance_record = get_provenance_record( - [data_dict["filename"]], caption, ["reg"], set_refs, plot_type="times" + [data_dict["filename"]], + caption, + ["reg"], + set_refs, + plot_type="times", ) diagnostic_file = get_diagnostic_filename( @@ -1484,12 +1317,12 @@ def _provenance_time_series_spei(cfg, data_dict): + data_dict["dataset_name"], cfg, ) - logger.info("Saving analysis results to %s", diagnostic_file) + log.info("Saving analysis results to %s", diagnostic_file) cubesave = cube_to_save_ploted_ts(data_dict) iris.save(cubesave, target=diagnostic_file) - logger.info( + log.info( "Recording provenance of %s:\n%s", diagnostic_file, pformat(provenance_record), @@ -1499,9 +1332,9 @@ def _provenance_time_series_spei(cfg, data_dict): provenance_logger.log(diagnostic_file, provenance_record) -def cube_to_save_ploted(var, data_dict): +def cube_to_save_ploted(var, data_dict) -> Cube: """Create cube to prepare plotted data for saving to netCDF.""" - plot_cube = iris.cube.Cube( + plot_cube = Cube( var, var_name=data_dict["var"], long_name=data_dict["drought_char"], @@ -1525,33 +1358,36 @@ def cube_to_save_ploted(var, data_dict): ), 1, ) - return plot_cube -def cube_to_save_ploted_ts(data_dict): +def cube_to_save_ploted_ts(data_dict:dict)->Cube: """Create cube to prepare plotted time series for saving to netCDF.""" - plot_cube = iris.cube.Cube( + cube = Cube( data_dict["data"], var_name=data_dict["var"], long_name=data_dict["var"], units=data_dict["unit"], ) - plot_cube.add_dim_coord( - iris.coords.DimCoord( - data_dict["time"], var_name="time", long_name="Time", units="month" - ), - 0, + coord = iris.coords.DimCoord( + data_dict["time"], + var_name="time", + long_name="Time", + units="month", ) - - return plot_cube + cube.add_dim_coord(coord, 0) + return cube def get_provenance_record( - ancestor_files, caption, domains, refs, plot_type="geo" -): + ancestor_files, + caption, + domains, + refs, + plot_type="geo", +) -> dict: """Get Provenance record.""" - record = { + return { "caption": caption, "statistics": ["mean"], "domains": domains, @@ -1564,37 +1400,44 @@ def get_provenance_record( "references": refs, "ancestors": ancestor_files, } - return record def _make_new_cube(cube): """Make a new cube with an extra dimension for result of spell count.""" - new_shape = cube.shape + (4,) + new_shape = (*cube.shape, 4) new_data = iris.util.broadcast_to_shape(cube.data, new_shape, [0, 1, 2]) - new_cube = iris.cube.Cube(new_data) + new_cube = Cube(new_data) new_cube.add_dim_coord( - iris.coords.DimCoord(cube.coord("time").points, long_name="time"), 0 + iris.coords.DimCoord(cube.coord("time").points, long_name="time"), + 0, ) new_cube.add_dim_coord( iris.coords.DimCoord( - cube.coord("latitude").points, long_name="latitude" + cube.coord("latitude").points, + long_name="latitude", ), 1, ) new_cube.add_dim_coord( iris.coords.DimCoord( - cube.coord("longitude").points, long_name="longitude" + cube.coord("longitude").points, + long_name="longitude", ), 2, ) new_cube.add_dim_coord( - iris.coords.DimCoord([0, 1, 2, 3], long_name="z"), 3 + iris.coords.DimCoord([0, 1, 2, 3], long_name="z"), + 3, ) return new_cube def _plot_multi_model_maps( - cfg, all_drought_mean, lats_lons, input_filenames, tstype + cfg, + all_drought_mean, + lats_lons, + input_filenames, + tstype, ): """Prepare plots for multi-model mean.""" data_dict = { @@ -1614,7 +1457,10 @@ def _plot_multi_model_maps( "drought_numbers_level": np.arange(-100, 110, 10), }) plot_map_spei_multi( - cfg, data_dict, input_filenames, colormap="rainbow" + cfg, + data_dict, + input_filenames, + colormap="rainbow", ) data_dict.update({ @@ -1625,7 +1471,10 @@ def _plot_multi_model_maps( "drought_numbers_level": np.arange(-100, 110, 10), }) plot_map_spei_multi( - cfg, data_dict, input_filenames, colormap="rainbow" + cfg, + data_dict, + input_filenames, + colormap="rainbow", ) data_dict.update({ @@ -1636,7 +1485,10 @@ def _plot_multi_model_maps( "drought_numbers_level": np.arange(-50, 60, 10), }) plot_map_spei_multi( - cfg, data_dict, input_filenames, colormap="rainbow" + cfg, + data_dict, + input_filenames, + colormap="rainbow", ) data_dict.update({ @@ -1649,7 +1501,10 @@ def _plot_multi_model_maps( "drought_numbers_level": np.arange(-50, 60, 10), }) plot_map_spei_multi( - cfg, data_dict, input_filenames, colormap="rainbow" + cfg, + data_dict, + input_filenames, + colormap="rainbow", ) else: data_dict.update({ @@ -1665,7 +1520,10 @@ def _plot_multi_model_maps( else: data_dict["datasetname"] = "MultiModelMean" plot_map_spei_multi( - cfg, data_dict, input_filenames, colormap="gnuplot" + cfg, + data_dict, + input_filenames, + colormap="gnuplot", ) data_dict.update({ @@ -1677,7 +1535,10 @@ def _plot_multi_model_maps( "drought_numbers_level": np.arange(0, 6, 1), }) plot_map_spei_multi( - cfg, data_dict, input_filenames, colormap="gnuplot" + cfg, + data_dict, + input_filenames, + colormap="gnuplot", ) data_dict.update({ @@ -1689,7 +1550,10 @@ def _plot_multi_model_maps( "drought_numbers_level": np.arange(0, 9, 1), }) plot_map_spei_multi( - cfg, data_dict, input_filenames, colormap="gnuplot" + cfg, + data_dict, + input_filenames, + colormap="gnuplot", ) namehlp = "Average " + cfg["indexname"] + " of drought events" namehlp2 = tstype + "_Average_" + cfg["indexname"] + "_of_Events" @@ -1702,7 +1566,10 @@ def _plot_multi_model_maps( "drought_numbers_level": np.arange(-2.8, -1.8, 0.2), }) plot_map_spei_multi( - cfg, data_dict, input_filenames, colormap="gnuplot" + cfg, + data_dict, + input_filenames, + colormap="gnuplot", ) @@ -1759,7 +1626,7 @@ def _plot_single_maps(cfg, cube2, drought_show, tstype, input_filenames): plot_map_spei(cfg, cube2, np.arange(-2.8, -1.8, 0.2), name_dict) -def runs_of_ones_array_spei(bits, spei): +def runs_of_ones_array_spei(bits, spei) -> list: """Set 1 at beginning ond -1 at the end of events.""" # make sure all runs of ones are well-bounded bounded = np.hstack(([0], bits, [0])) @@ -1774,7 +1641,7 @@ def runs_of_ones_array_spei(bits, spei): return [run_ends - run_starts, spei_sum] -def count_spells(data, threshold, axis): +def count_spells(data, threshold, axis) -> np.ndarray: """Functions for Iris Aggregator to count spells.""" if axis < 0: # just cope with negative axis numbers @@ -1805,31 +1672,33 @@ def count_spells(data, threshold, axis): else: data_hits = data_help < threshold [events, spei_sum] = runs_of_ones_array_spei( - data_hits, data_help + data_hits, + data_help, ) return_var[ilat, ilon, 0] = np.count_nonzero(events) return_var[ilat, ilon, 1] = np.mean(events) return_var[ilat, ilon, 2] = np.mean( (spei_sum * events) - / (np.mean(data_help[data_hits]) * np.mean(events)) + / (np.mean(data_help[data_hits]) * np.mean(events)), ) return_var[ilat, ilon, 3] = np.mean(spei_sum / events) return return_var -def get_latlon_index(coords, lim1, lim2): +def get_latlon_index(coords, lim1, lim2) -> np.ndarray: """Get index for given values between two limits (1D), e.g. lats, lons.""" - index = ( + return ( np.where( - np.absolute(coords - (lim2 + lim1) / 2.0) <= (lim2 - lim1) / 2.0 + np.absolute(coords - (lim2 + lim1) / 2.0) <= (lim2 - lim1) / 2.0, ) )[0] - return index -def plot_map_spei_multi(cfg, data_dict, input_filenames, colormap="jet"): +def plot_map_spei_multi( + cfg, data_dict, input_filenames, colormap="jet", +) -> None: """Plot contour maps for multi model mean.""" spei = np.ma.array(data_dict["data"], mask=np.isnan(data_dict["data"])) @@ -1847,7 +1716,8 @@ def plot_map_spei_multi(cfg, data_dict, input_filenames, colormap="jet"): subplot_kw = {"projection": cart.PlateCarree(central_longitude=0.0)} fig, axx = plt.subplots(figsize=(6.5, 4), subplot_kw=subplot_kw) axx.set_extent( - [-180.0, 180.0, -90.0, 90.0], cart.PlateCarree(central_longitude=0.0) + [-180.0, 180.0, -90.0, 90.0], + cart.PlateCarree(central_longitude=0.0), ) # Draw filled contours @@ -1870,22 +1740,18 @@ def plot_map_spei_multi(cfg, data_dict, input_filenames, colormap="jet"): # Add colorbar title string if data_dict["model_kind"] == "Difference": cbar.set_label( - data_dict["model_kind"] + " " + data_dict["drought_char"] + " [%]" + data_dict["model_kind"] + " " + data_dict["drought_char"] + " [%]", ) else: cbar.set_label( - data_dict["model_kind"] + " " + data_dict["drought_char"] + data_dict["model_kind"] + " " + data_dict["drought_char"], ) # Set labels and title to each plot axx.set_xlabel("Longitude") axx.set_ylabel("Latitude") axx.set_title( - data_dict["datasetname"] - + " " - + data_dict["model_kind"] - + " " - + data_dict["drought_char"] + "{datasetname} {model_kind} {drought_char}".format(**data_dict), ) # Sets number and distance of x ticks @@ -1922,12 +1788,11 @@ def plot_map_spei_multi(cfg, data_dict, input_filenames, colormap="jet"): _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames) -def plot_map_spei(cfg, cube, levels, name_dict): +def plot_map_spei(cfg, cube, levels, name_dict) -> None: """Plot contour map.""" mask = np.isnan(cube.data) spei = np.ma.array(cube.data, mask=mask) np.ma.masked_less_equal(spei, 0) - # Get latitudes and longitudes from cube name_dict.update({"latitude": cube.coord("latitude").points}) lons = cube.coord("longitude").points @@ -1937,7 +1802,6 @@ def plot_map_spei(cfg, cube, levels, name_dict): lons = lons[index] name_dict.update({"longitude": lons}) spei = spei[np.ix_(range(len(cube.coord("latitude").points)), index)] - # Get data set name from cube try: dataset_name = cube.metadata.attributes["model_id"] @@ -1946,17 +1810,14 @@ def plot_map_spei(cfg, cube, levels, name_dict): dataset_name = cube.metadata.attributes["source_id"] except KeyError: dataset_name = "Observations" - # Plot data # Create figure and axes instances subplot_kw = {"projection": cart.PlateCarree(central_longitude=0.0)} fig, axx = plt.subplots(figsize=(8, 4), subplot_kw=subplot_kw) axx.set_extent( - [-180.0, 180.0, -90.0, 90.0], cart.PlateCarree(central_longitude=0.0) + [-180.0, 180.0, -90.0, 90.0], + cart.PlateCarree(central_longitude=0.0), ) - - # np.set_printoptions(threshold=np.nan) - # Draw filled contours cnplot = plt.contourf( lons, @@ -1968,56 +1829,30 @@ def plot_map_spei(cfg, cube, levels, name_dict): extend="both", corner_mask=False, ) - # Draw coastlines axx.coastlines() - - # Add colorbar cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation="horizontal") - - # Add colorbar title string cbar.set_label(name_dict["name"]) - - # Set labels and title to each plot axx.set_xlabel("Longitude") axx.set_ylabel("Latitude") axx.set_title(dataset_name + " " + name_dict["name"]) - # Sets number and distance of x ticks + # Set up x and y ticks axx.set_xticks(np.linspace(-180, 180, 7)) - # Sets strings for x ticks - axx.set_xticklabels([ - "180°W", - "120°W", - "60°W", - "0°", - "60°E", - "120°E", - "180°E", - ]) - # Sets number and distance of y ticks + axx.set_xticklabels( + ["180°W", "120°W", "60°W", "0°", "60°E", "120°E", "180°E"], + ) axx.set_yticks(np.linspace(-90, 90, 7)) - # Sets strings for y ticks axx.set_yticklabels(["90°S", "60°S", "30°S", "0°", "30°N", "60°N", "90°N"]) - fig.tight_layout() - - fig.savefig( - get_plot_filename( - cfg["indexname"] - + "_map" - + name_dict["add_to_filename"] - + "_" - + dataset_name, - cfg, - ), - dpi=300, + basename = ( + f"{cfg['indexname']}_map_{name_dict['add_to_filename']}_{dataset_name}" ) + fig.savefig(get_plot_filename(basename, cfg), dpi=300) plt.close() - _provenance_map_spei(cfg, name_dict, spei, dataset_name) -def plot_time_series_spei(cfg, cube, filename, add_to_filename=""): +def plot_time_series_spei(cfg, cube, filename, add_to_filename="") -> None: """Plot time series.""" # SPEI vector to plot spei = cube.data @@ -2035,7 +1870,6 @@ def plot_time_series_spei(cfg, cube, filename, add_to_filename=""): dataset_name = cube.metadata.attributes["source_id"] except KeyError: dataset_name = "Observations" - data_dict = { "data": spei, "time": time, @@ -2045,7 +1879,6 @@ def plot_time_series_spei(cfg, cube, filename, add_to_filename=""): "filename": filename, "area": add_to_filename, } - fig, axx = plt.subplots(figsize=(16, 4)) axx.plot_date( time, @@ -2061,36 +1894,15 @@ def plot_time_series_spei(cfg, cube, filename, add_to_filename=""): marker="x", ) axx.axhline(y=-2, color="k") - - # Plot labels and title axx.set_xlabel("Time") axx.set_ylabel(cfg["indexname"]) axx.set_title( - "Mean " - + cfg["indexname"] - + " " - + data_dict["dataset_name"] - + " " - + data_dict["area"] + f"Mean {cfg['indexname']} {data_dict['dataset_name']} {data_dict['area']}", ) - - # Set limits for y-axis axx.set_ylim(-4.0, 4.0) - - # Often improves the layout fig.tight_layout() - # Save plot to file - fig.savefig( - get_plot_filename( - cfg["indexname"] - + "_time_series_" - + data_dict["area"] - + "_" - + data_dict["dataset_name"], - cfg, - ), - dpi=300, - ) + basename = f"{cfg['indexname']}_time_series_{data_dict['area']}_" + basename += data_dict["dataset_name"] + fig.savefig(get_plot_filename(basename, cfg), dpi=300) plt.close() - _provenance_time_series_spei(cfg, data_dict) From 9db51c01162a86fc7aa211599bf803e71fb30c74 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 25 Feb 2025 17:22:16 +0100 Subject: [PATCH 30/66] some manual codacy fixes --- esmvaltool/diag_scripts/droughts/utils.py | 203 ++++++++-------------- 1 file changed, 71 insertions(+), 132 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 89a6978cdd..f1ec4df7dc 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -1,3 +1,9 @@ +"""Utility functions for drought diagnostics. + +Functions and variables shared by several diagnostics in the drought folder. +Added functions should have a meaningfull name and docstring. +""" + from __future__ import annotations import datetime as dt @@ -41,6 +47,7 @@ log = logging.getLogger(Path(__file__).name) # fmt: off +# pylint: disable=line-too-long DENSITY = AuxCoord( 1000, long_name="density", @@ -64,8 +71,8 @@ "RAR": [20, 0], "WNA": [1, 1], "CNA": [3, 1], "ENA": [5, 1], "WCE": [13, 1], "EEU": [15, 1], "WSB": [17, 1], "ESB": [19, 1], "RFE": [21, 1], "NCA": [2, 2], "MED": [14, 2], "WCA": [16, 2], - "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], # "CAR": [5, 3], - "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], # "PAC": [27.5, 3.3], + "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], # "CAR": [5, 3], + "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], # "PAC": [27.5, 3.3], "NWS": [6, 4], "NSA": [8, 4], "WAF": [12, 4], "CAF": [14, 4], "NEAF": [16, 4], "NAU": [24.5, 4.3], "SAM": [7, 5], "NES": [9, 5], "WSAF": [13, 5], "SEAF": [15, 5], "MDG": [17.5, 5.3], @@ -106,6 +113,7 @@ }, } # fmt: on +# pylint: enable=line-too-long # REGION_NAMES = { @@ -171,7 +179,10 @@ def merge_list_cube( - cube_list: list, aux_name: str = "dataset", *, equalize: bool = True, + cube_list: list, + aux_name: str = "dataset", + *, + equalize: bool = True, ) -> Cube: """Merge a list of cubes into a single one with an auxiliary variable. @@ -370,35 +381,6 @@ def fix_longitude(cube: Cube) -> Cube: return cube -def get_datetime_coords(cube, coord="time") -> np.ndarray: - """Return mpl compatible points of time coordinate. - - Converted from cf_date to mpl compatible datetime objects. - TODO: this seems not to work with current Iris version? - but cf_date.dateime contains the year aswell - TODO: the difference behaviour may be caused by different time coords in - datasets - nc.num2date default behaviour changed to use only_cf_times, change back - swith - only_use_python_datetimes=True - """ - tc = cube.coord(coord) - return iplt._fixup_dates(tc, tc.points) - - -def get_start_year(cube: Cube, coord: str = "time") -> int: - """Get the year of the first time coord point. - - Works for datetime and cf_calendar types - """ - tc = cube.coord(coord) - first = iplt._fixup_dates(tc, tc.points)[0] - try: - return first.datetime.year - except: - return first.year - - def get_meta_list(meta: dict, group: str, select: dict | None = None) -> list: """ List all entries found for the group key as a list. @@ -463,7 +445,7 @@ def date_tick_layout( locator = mdates.AutoDateLocator() min_locator = mdates.YearLocator(1) else: - locator = mdates.YearLocator(years) # type: ignore[assignement] + locator = mdates.YearLocator(years) # type: ignore[assignment] min_locator = mdates.YearLocator(1) year_formatter = mdates.DateFormatter("%Y") ax.grid(True) @@ -474,6 +456,10 @@ def date_tick_layout( def auto_tick_layout(fig, ax, dates=None): + """Update a time series figure to use auto date ticks and grid. + + NOTE: can this be merged with date_tick_layout? + """ ax.set_xlabel("Time") if dates is not None: datemin = np.datetime64(dates[0], "Y") @@ -489,21 +475,19 @@ def auto_tick_layout(fig, ax, dates=None): fig.autofmt_xdate() # align, rotate and space for tick labels -def map_land_layout( - fig: Figure, ax: GeoAxes, plot, bounds, *, cbar: bool = True, -) -> None: +def map_land_layout(axes: GeoAxes, plot, bounds, *, cbar: bool = True) -> None: """Plot style for rectangular drought maps with land overlay. Mask the ocean by overlay, add gridlines, set left/bottom tick labels. """ - ax.coastlines() - ax.add_feature( + axes.coastlines() + axes.add_feature( ct.feature.OCEAN, edgecolor="black", facecolor="white", zorder=1, ) - gl = ax.gridlines( + gl = axes.gridlines( crs=ct.crs.PlateCarree(), linewidth=1, color="black", @@ -517,16 +501,17 @@ def map_land_layout( if bounds is not None and cbar: plt.colorbar( plot, - ax=ax, + ax=axes, ticks=bounds, extend="both", fraction=0.022, ) elif cbar: - plt.colorbar(plot, ax=ax, extend="both", fraction=0.022) + plt.colorbar(plot, ax=axes, extend="both", fraction=0.022) -def get_scenarios(meta, **kwargs): +def get_scenarios(meta, **kwargs) -> list: + """Return a list of alias values for scenario names.""" selected = select_metadata(meta, **kwargs) return list(group_metadata(selected, "alias").keys()) @@ -546,40 +531,6 @@ def add_ancestor_input(cfg: dict) -> None: cfg["input_data"].update(_get_input_data_files(cfg_anc)) -def _fixup_dates(coord, values): - """Copy from iris plot.py source code""" - if coord.units.calendar is not None and values.ndim == 1: - # Convert coordinate values into tuples of - # (year, month, day, hour, min, sec) - dates = [coord.units.num2date(val).timetuple()[0:6] for val in values] - if coord.units.calendar == "gregorian": - r = [dt.datetime(*date) for date in dates] - else: - try: - import cftime - import nc_time_axis - except ImportError as err: - msg = ( - "Cannot plot against time in a non-gregorian " - 'calendar, because "nc_time_axis" is not available : ' - "Install the package from " - "https://github.com/SciTools/nc-time-axis to enable " - "this usage." - ) - raise iris.IrisError(msg) from err - - r = [ - nc_time_axis.CalendarDateTime( - cftime.datetime(*date), - coord.units.calendar, - ) - for date in dates - ] - values = np.empty(len(r), dtype=object) - values[:] = r - return values - - def quick_save(cube: Cube, name: str, cfg: dict) -> None: """Simply save cube to netcdf file without additional information.""" if cfg.get("write_netcdf", True): @@ -598,23 +549,29 @@ def quick_load(cfg: dict, context: dict, *, strict=True) -> Cube: var_meta = select_metadata(meta, **context) if len(var_meta) != 1: if strict: - raise ValueError("Metadata missmatch") + raise ValueError("Wrong number of meta data found.") log.warning("warning meta data missmatch") return iris.load_cube(var_meta[0]["filename"]) -def smooth(dat, window=32, mode="same"): - # smooth - # from scipy.ndimage.filters import uniform_filter1d as unifilter - # TODO: iris can also directly filter on cubes: - # https://scitools-iris.readthedocs.io/en/latest/generated/gallery/general/plot_SOI_filtering.html#sphx-glr-generated-gallery-general-plot-soi-filtering-py - # smoothed = unifilter(dat, 32) # smooth over 4 year window +def smooth(dat, window=32, mode="same") -> np.ndarray: + """Smooth a 1D array with a window size. + + from scipy.ndimage.filters import uniform_filter1d as unifilter + TODO: iris can also directly filter on cubes: + https://scitools-iris.readthedocs.io/en/latest/generated/gallery/general/ + plot_SOI_filtering.html#sphx-glr-generated-gallery-general-plot-soi- + filtering-py + smoothed = unifilter(dat, 32) # smooth over 4 year window + """ # using numpy convol - filter = np.ones(window) - return np.convolve(dat, filter, mode) / window + np_filter = np.ones(window) + return np.convolve(dat, np_filter, mode) / window -def get_basename(cfg, meta, prefix=None, suffix=None): +def get_basename(cfg, meta, prefix=None, suffix=None) -> str: + """Return a formatted basename for a diagnostic file.""" + _ = cfg # we might need this in the future. dont tell codacy! formats = { # TODO: load this from config-developer.yml? "CMIP6": "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_{grid}_{start_year}-{end_year}", "OBS": "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_{start_year}-{end_year}", @@ -627,27 +584,11 @@ def get_basename(cfg, meta, prefix=None, suffix=None): return basename -def get_custom_basename(meta, folder="plots", prefix=None, suffix=None): - """Manually create basenames for output files +def clean_meta(meta) -> dict: + """Return a copy of meta data with only selected keys. - NOTE: for basename in configured naming format use get_basename + Keys are: short_name, dataset, alias, exp """ - defaults = { - "variable": "variable", - "alias": "alias", - } - defaults.update(meta) - base = f"{folder}/" - if prefix: - base += f"{prefix}_" - base += "{alias}_{variable}".format(**defaults) - if suffix: - base += f"_{suffix}" - return base - - -def clean_meta(meta, **kwargs): - # TODO: incomplete keylist valid_keys = ["short_name", "dataset", "alias", "exp"] return {key: val for key, val in meta.items() if key in valid_keys} @@ -682,7 +623,6 @@ def select_single_metadata( No matching entry """ selected_meta = select_metadata(meta, **kwargs) - # log.info("dataset from alias: " + dataset) if len(selected_meta) > 1: log.warning("Multiple entries found for Metadata: %s", selected_meta) if strict: @@ -789,7 +729,7 @@ def add_spei_meta(cfg: dict, name: str = "spei", pos: int = 0) -> None: spei_fname = ( cfg["tmp_meta"]["filename"].split("/")[-1].replace("_pr_", f"_{name}_") ) - spei_file = str(Path(cfg["input_files"][pos])/spei_fname) + spei_file = str(Path(cfg["input_files"][pos]) / spei_fname) log.info("spei file path: %s", spei_file) meta = cfg["tmp_meta"].copy() meta.update(INDEX_META[name.upper()]) @@ -823,7 +763,7 @@ def latlon_coords(cube: Cube) -> None: cube.coord("longitude").rename("lon") -def standard_time(cubes): +def standard_time(cubes: Cube) -> None: """Make sure all cubes share the same standard time coordinate. This function extracts the date information from the cube and @@ -909,6 +849,8 @@ def mmm( dropcoords : list|None, optional Coordinates to be dropped from the cubes. If None, only time will be dropped. To keep all coords pass an empty list. + dropmethods: bool, optional + Drop cell_methods from the cubes, by default False """ if dropcoords is None: dropcoords = ["time"] @@ -940,6 +882,7 @@ def get_hex_positions() -> dict: def regional_stats(cfg, cube, operator="mean") -> dict: """Calculate statistic over AR6 IPCC reference regions.""" + _ = cfg # we might need this in the future. dont tell codacy! guess_lat_lon_bounds(cube) extracted = pp.extract_shape(cube, "ar6", decomposed=True) return pp.area_statistics(extracted, operator) @@ -957,7 +900,7 @@ def save_metadata(cfg: dict, metadata: dict) -> None: yaml.dump(metadata, wom) -def get_index_meta(index)->dict: +def get_index_meta(index) -> dict: """Return default meta data for a given index. kept for compability. Use INDEX_META dict instead. @@ -1075,19 +1018,19 @@ def monthly_to_daily( """ months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] months = months * int((cube.shape[0] / 12) + 1) - for i, s in enumerate(cube.slices_over(["time"])): + for idx, sli in enumerate(cube.slices_over(["time"])): if not leap_years: - days = months[i] - cube.data[i] = cube.data[i] / days + days = months[idx] + cube.data[idx] = cube.data[idx] / days try: - time = s.coord("time") + time = sli.coord("time") date = time.units.num2date(time.points[0]) days = monthrange(date.year, date.month)[1] except Exception as e: log.warning("date failed, using fixed days without leap year") log.warning(e) - days = months[i] - cube.data[i] = cube.data[i] / days + days = months[idx] + cube.data[idx] = cube.data[idx] / days cube.units = units @@ -1129,7 +1072,8 @@ def _get_data_hlp(axis, data, ilat, ilon): data_help = (data[ilat, :, ilon])[:, 0] elif axis == 2: data_help = data[ilat, ilon, :] - + else: + data_help = None return data_help @@ -1172,19 +1116,11 @@ def _provenance_map_spei(cfg, name_dict, spei, dataset_name): ) if cfg["indexname"].lower == "spei": - set_refs = [ - "martin18grl", - "vicente10jclim", - ] + set_refs = ["martin18grl", "vicente10jclim"] elif cfg["indexname"].lower == "spi": - set_refs = [ - "martin18grl", - "mckee93proc", - ] + set_refs = ["martin18grl", "mckee93proc"] else: - set_refs = [ - "martin18grl", - ] + set_refs = ["martin18grl"] provenance_record = get_provenance_record( [name_dict["input_filenames"]], @@ -1230,11 +1166,11 @@ def _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames): f"{cfg['indexname']}." ) if cfg["indexname"].lower == "spei": - set_refs = [ "martin18grl", "vicente10jclim"] + set_refs = ["martin18grl", "vicente10jclim"] elif cfg["indexname"].lower == "spi": - set_refs = [ "martin18grl", "mckee93proc"] + set_refs = ["martin18grl", "mckee93proc"] else: - set_refs = [ "martin18grl"] + set_refs = ["martin18grl"] provenance_record = get_provenance_record( input_filenames, @@ -1361,7 +1297,7 @@ def cube_to_save_ploted(var, data_dict) -> Cube: return plot_cube -def cube_to_save_ploted_ts(data_dict:dict)->Cube: +def cube_to_save_ploted_ts(data_dict: dict) -> Cube: """Create cube to prepare plotted time series for saving to netCDF.""" cube = Cube( data_dict["data"], @@ -1697,7 +1633,10 @@ def get_latlon_index(coords, lim1, lim2) -> np.ndarray: def plot_map_spei_multi( - cfg, data_dict, input_filenames, colormap="jet", + cfg, + data_dict, + input_filenames, + colormap="jet", ) -> None: """Plot contour maps for multi model mean.""" spei = np.ma.array(data_dict["data"], mask=np.isnan(data_dict["data"])) From e45589edeb7220e87ea6ab049c0f3b9ea355d386 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 26 Feb 2025 03:37:20 +0100 Subject: [PATCH 31/66] more codacy issues --- esmvaltool/diag_scripts/droughts/utils.py | 159 +++++++--------------- 1 file changed, 47 insertions(+), 112 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index f1ec4df7dc..0f47e3d2ca 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -7,6 +7,7 @@ from __future__ import annotations import datetime as dt +import errno import itertools as it import logging from calendar import monthrange @@ -17,9 +18,7 @@ import cartopy as ct import cartopy.crs as cart import iris -import iris.plot as iplt import matplotlib as mpl -import matplotlib.dates as mda import matplotlib.dates as mdates import matplotlib.pyplot as plt import numpy as np @@ -27,11 +26,11 @@ from cartopy.mpl.geoaxes import GeoAxes from cf_units import Unit from esmvalcore import preprocessor as pp +from esmvalcore.iris_helpers import date2num from iris.analysis import Aggregator from iris.coords import AuxCoord from iris.cube import Cube, CubeList from iris.util import equalise_attributes -from matplotlib.figure import Figure import esmvaltool.diag_scripts.shared.names as n from esmvaltool.diag_scripts.shared import ( @@ -54,6 +53,8 @@ units="kg m-3") FNAME_FORMAT = "{project}_{reference_dataset}_{mip}_{exp}_{ensemble}_{short_name}_{start_year}-{end_year}" +CMIP6 = "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_{grid}_{start_year}-{end_year}" +OBS = "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_{start_year}-{end_year}" CONTINENTAL_REGIONS = { "Global": ["GLO"], # global @@ -116,68 +117,6 @@ # pylint: enable=line-too-long -# REGION_NAMES = { -# 'Arabian-Peninsula', -# 'Arabian-Sea', -# 'Arctic-Ocean', -# 'Bay-of-Bengal' -# 'C.Australia', -# 'C.North-America', -# 'Caribbean', -# 'Central-Africa' -# 'E.Antarctica', -# 'E.Asia', -# 'E.Australia', -# 'E.C.Asia', -# 'E.Europe' -# 'E.North-America', -# 'E.Siberia', -# 'E.Southern-Africa' -# 'Equatorial.Atlantic-Ocean', -# 'Equatorial.Indic-Ocean' -# 'Equatorial.Pacific-Ocean', -# 'Greenland/Iceland', -# 'Madagascar', -# 'Mediterranean', -# 'N.Atlantic-Ocean', -# 'N.Australia', -# 'N.Central-America' -# 'N.E.North-America', -# 'N.E.South-America', -# 'N.Eastern-Africa', -# 'N.Europe', -# 'N.Pacific-Ocean', -# 'N.South-America', -# 'N.W.North-America' -# 'N.W.South-America', -# 'New-Zealand', -# 'Russian-Arctic', -# 'Russian-Far-East', -# 'S.Asia', -# 'S.Atlantic-Ocean', -# 'S.Australia', -# 'S.Central-America', -# 'S.E.Asia', -# 'S.E.South-America', -# 'S.Eastern-Africa', -# 'S.Indic-Ocean', -# 'S.Pacific-Ocean', -# 'S.South-America', -# 'S.W.South-America', -# 'Sahara', -# 'South-American-Monsoon', -# 'Southern-Ocean', -# 'Tibetan-Plateau', -# 'W.Antarctica', -# 'W.C.Asia', -# 'W.North-America', -# 'W.Siberia', -# 'W.Southern-Africa', -# 'West&Central-Europe', -# 'Western-Africa' -# } - - def merge_list_cube( cube_list: list, aux_name: str = "dataset", @@ -352,14 +291,15 @@ def fix_longitude(cube: Cube) -> Cube: """ # make sure coords are -180 to 180 fixed_lons = [ - l if l < 180 else l - 360 for l in cube.coord("longitude").points + lon if lon < 180 else lon - 360 + for lon in cube.coord("longitude").points ] try: cube.add_aux_coord( iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), 2, ) - except Exception: + except ValueError: log.warning("TODO: hardcoded dimensions in ut.fix_longitude") cube.add_aux_coord( iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), @@ -371,7 +311,7 @@ def fix_longitude(cube: Cube) -> Cube: new_lon = cube.coord("fixed_lon") new_lon_dims = cube.coord_dims(new_lon) # Create a new coordinate which is a DimCoord. - # NOTE: The var name becomes the dim name + # The var name becomes the dim name longitude = iris.coords.DimCoord.from_coord(new_lon) longitude.rename("longitude") # Remove the AuxCoord, old longitude and add the new DimCoord. @@ -422,7 +362,7 @@ def get_dataset_scenarios(cfg: dict) -> list: def date_tick_layout( fig, - ax, + axes, dates: list | None = None, label: str = "Time", years: int | None = 1, @@ -436,11 +376,11 @@ def date_tick_layout( :param years: tick every x years, if None auto formatter is used :return: nothing, updates figure in place """ - ax.set_xlabel(label) + axes.set_xlabel(label) if dates is not None: datemin = np.datetime64(dates[0], "Y") datemax = np.datetime64(dates[-1], "Y") + np.timedelta64(1, "Y") - ax.set_xlim(datemin, datemax) + axes.set_xlim(datemin, datemax) if years is None: locator = mdates.AutoDateLocator() min_locator = mdates.YearLocator(1) @@ -448,30 +388,30 @@ def date_tick_layout( locator = mdates.YearLocator(years) # type: ignore[assignment] min_locator = mdates.YearLocator(1) year_formatter = mdates.DateFormatter("%Y") - ax.grid(True) - ax.xaxis.set_major_locator(locator) - ax.xaxis.set_major_formatter(year_formatter) - ax.xaxis.set_minor_locator(min_locator) + axes.grid(True) + axes.xaxis.set_major_locator(locator) + axes.xaxis.set_major_formatter(year_formatter) + axes.xaxis.set_minor_locator(min_locator) fig.autofmt_xdate() # align, rotate and space for tick labels -def auto_tick_layout(fig, ax, dates=None): +def auto_tick_layout(fig, axes, dates=None) -> None: """Update a time series figure to use auto date ticks and grid. NOTE: can this be merged with date_tick_layout? """ - ax.set_xlabel("Time") + axes.set_xlabel("Time") if dates is not None: datemin = np.datetime64(dates[0], "Y") datemax = np.datetime64(dates[-1], "Y") + np.timedelta64(1, "Y") - ax.set_xlim(datemin, datemax) + axes.set_xlim(datemin, datemax) year_locator = mdates.YearLocator(1) months_locator = mdates.MonthLocator() year_formatter = mdates.DateFormatter("%Y") - ax.grid(True) - ax.xaxis.set_major_locator(year_locator) - ax.xaxis.set_major_formatter(year_formatter) - ax.xaxis.set_minor_locator(months_locator) + axes.grid(True) + axes.xaxis.set_major_locator(year_locator) + axes.xaxis.set_major_formatter(year_formatter) + axes.xaxis.set_minor_locator(months_locator) fig.autofmt_xdate() # align, rotate and space for tick labels @@ -487,7 +427,7 @@ def map_land_layout(axes: GeoAxes, plot, bounds, *, cbar: bool = True) -> None: facecolor="white", zorder=1, ) - gl = axes.gridlines( + glines = axes.gridlines( crs=ct.crs.PlateCarree(), linewidth=1, color="black", @@ -496,8 +436,8 @@ def map_land_layout(axes: GeoAxes, plot, bounds, *, cbar: bool = True) -> None: draw_labels=True, zorder=2, ) - gl.xlabels_top = False - gl.ylabels_right = False + glines.xlabels_top = False + glines.ylabels_right = False if bounds is not None and cbar: plt.colorbar( plot, @@ -573,8 +513,8 @@ def get_basename(cfg, meta, prefix=None, suffix=None) -> str: """Return a formatted basename for a diagnostic file.""" _ = cfg # we might need this in the future. dont tell codacy! formats = { # TODO: load this from config-developer.yml? - "CMIP6": "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_{grid}_{start_year}-{end_year}", - "OBS": "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_{start_year}-{end_year}", + "CMIP6": CMIP6_FNAME, + "OBS": OBS_FNAME, } basename = formats[meta["project"]].format(**meta) if suffix: @@ -776,8 +716,6 @@ def standard_time(cubes: Cube) -> None: different number of days in the year. NOTE: this might be replaced by preprocessor """ - from esmvalcore.iris_helpers import date2num - t_unit = Unit("days since 1850-01-01", calendar="standard") for cube in cubes: # Extract date info from cube @@ -854,13 +792,13 @@ def mmm( """ if dropcoords is None: dropcoords = ["time"] - for i, cube in enumerate(cube_list): + for idx, cube in enumerate(cube_list): for coord_name in dropcoords: if cube.coords(coord_name): cube.remove_coord(coord_name) if dropmethods: cube.cell_methods = None - cube.add_aux_coord(iris.coords.AuxCoord(i, long_name="dataset")) + cube.add_aux_coord(iris.coords.AuxCoord(idx, long_name="dataset")) cube_list = CubeList(cube_list) equalise_attributes(cube_list) try: @@ -1022,14 +960,10 @@ def monthly_to_daily( if not leap_years: days = months[idx] cube.data[idx] = cube.data[idx] / days - try: - time = sli.coord("time") - date = time.units.num2date(time.points[0]) - days = monthrange(date.year, date.month)[1] - except Exception as e: - log.warning("date failed, using fixed days without leap year") - log.warning(e) - days = months[idx] + continue + time = sli.coord("time") + date = time.units.num2date(time.points[0]) + days = monthrange(date.year, date.month)[1] cube.data[idx] = cube.data[idx] / days cube.units = units @@ -1047,20 +981,20 @@ def daily_to_monthly( """ months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] months = months * int((cube.shape[0] / 12) + 1) - for i, s in enumerate(cube.slices_over(["time"])): + for idx, sli in enumerate(cube.slices_over(["time"])): if not leap_years: - days = months[i] - cube.data[i] = cube.data[i] * days + days = months[idx] + cube.data[idx] = cube.data[idx] * days continue try: # consider leapday - time = s.coord("time") + time = sli.coord("time") date = time.units.num2date(time.points[0]) days = monthrange(date.year, date.month)[1] - except Exception as e: + except Exception as err: log.warning("date failed, using fixed days without leap year") - log.warning(e) - days = months[i] - cube.data[i] = cube.data[i] * days + log.warning(err) + days = months[idx] + cube.data[idx] = cube.data[idx] * days cube.units = units @@ -1572,7 +1506,7 @@ def runs_of_ones_array_spei(bits, spei) -> list: (run_ends,) = np.where(difs < 0) spei_sum = np.full(len(run_starts), 0.5) for iii, indexs in enumerate(run_starts): - spei_sum[iii] = np.sum(spei[indexs : run_ends[iii]]) + spei_sum[iii] = np.sum(spei[indexs: run_ends[iii]]) return [run_ends - run_starts, spei_sum] @@ -1690,7 +1624,8 @@ def plot_map_spei_multi( axx.set_xlabel("Longitude") axx.set_ylabel("Latitude") axx.set_title( - "{datasetname} {model_kind} {drought_char}".format(**data_dict), + f"{data_dict['datasetname']} {data_dict['model_kind']} " + f"{data_dict['drought_char']}", ) # Sets number and distance of x ticks @@ -1723,7 +1658,6 @@ def plot_map_spei_multi( dpi=300, ) plt.close() - _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames) @@ -1798,7 +1732,7 @@ def plot_time_series_spei(cfg, cube, filename, add_to_filename="") -> None: # Get time from cube time = cube.coord("time").points # Adjust (ncdf) time to the format matplotlib expects - add_m_delta = mda.datestr2num("1850-01-01 00:00:00") + add_m_delta = mdates.datestr2num("1850-01-01 00:00:00") time = time + add_m_delta # Get data set name from cube @@ -1836,7 +1770,8 @@ def plot_time_series_spei(cfg, cube, filename, add_to_filename="") -> None: axx.set_xlabel("Time") axx.set_ylabel(cfg["indexname"]) axx.set_title( - f"Mean {cfg['indexname']} {data_dict['dataset_name']} {data_dict['area']}", + f"Mean {cfg['indexname']} {data_dict['dataset_name']} " + f"{data_dict['area']}", ) axx.set_ylim(-4.0, 4.0) fig.tight_layout() From 90326649ee5cdcaef6ebcda66d55ad4bcc8862da Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 26 Feb 2025 03:57:40 +0100 Subject: [PATCH 32/66] flake8 issues solved --- .../diag_scripts/droughts/collect_drought.py | 58 ++++++++++--------- esmvaltool/diag_scripts/droughts/diffmap.py | 25 +++----- esmvaltool/diag_scripts/droughts/utils.py | 24 +++----- 3 files changed, 47 insertions(+), 60 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index dd877dd8d8..57e21b2c14 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -1,8 +1,4 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# Author: Katja Weigel (IUP, Uni Bremen, Germany) -# EVal4CMIP project -"""Compares SPI/SPEI data from models with observations/reanalysis. +"""Compares SPI/SPEI data from models with observations/reanalysis. Description ----------- @@ -21,7 +17,7 @@ The indexname is used to generate filenames, plot titles and captions. Should be ``SPI`` or ``SPEI`` reference_dataset: str - Dataset name to use for comparison (excluded from MMM). With + Dataset name to use for comparison (excluded from MMM). With ``compare_intervals=True`` this option has no effect. threshold: float, optional (default: -2.0) Threshold for an event to be considered as drought. @@ -48,35 +44,41 @@ import datetime as dt import esmvaltool.diag_scripts.shared as e from esmvaltool.diag_scripts.droughts.utils import ( - _get_drought_data, _plot_multi_model_maps, _plot_single_maps) + _get_drought_data, + _plot_multi_model_maps, + _plot_single_maps, +) def _plot_models_vs_obs(cfg, cube, mmm, obs, fnames): """Compare drought metrics of multi-model mean to observations.""" latslons = [cube.coord(i).points for i in ["latitude", "longitude"]] - perc_diff = ((obs-mmm) / (obs + mmm) * 200) - _plot_multi_model_maps(cfg, mmm, latslons, fnames, 'Historic') - _plot_multi_model_maps(cfg, obs, latslons, fnames, 'Observations') - _plot_multi_model_maps(cfg, perc_diff, latslons, fnames, 'Difference') + perc_diff = (obs - mmm) / (obs + mmm) * 200 + _plot_multi_model_maps(cfg, mmm, latslons, fnames, "Historic") + _plot_multi_model_maps(cfg, obs, latslons, fnames, "Observations") + _plot_multi_model_maps(cfg, perc_diff, latslons, fnames, "Difference") def _plot_future_vs_past(cfg, cube, slices, fnames): """Compare drought metrics of future and historic time slices.""" latslons = [cube.coord(i).points for i in ["latitude", "longitude"]] - slices["Difference"] = ((slices["Future"] - slices["Historic"]) / - (slices["Future"] + slices["Historic"]) * 200) - for tstype in ['Historic', 'Future', 'Difference']: + slices["Difference"] = ( + (slices["Future"] - slices["Historic"]) + / (slices["Future"] + slices["Historic"]) + * 200 + ) + for tstype in ["Historic", "Future", "Difference"]: _plot_multi_model_maps(cfg, slices[tstype], latslons, fnames, tstype) def _set_tscube(cfg, cube, time, tstype): """Time slice from a cube with start/end given by cfg.""" - if tstype == 'Future': + if tstype == "Future": start_year = cfg["end_year"] - cfg["comparison_period"] + 1 - start = dt.datetime(start_year , 1, 15, 0, 0, 0) - end = dt.datetime(cfg['end_year'], 12, 16, 0, 0, 0) - elif tstype == 'Historic': - start = dt.datetime(cfg['start_year'], 1, 15, 0, 0, 0) + start = dt.datetime(start_year, 1, 15, 0, 0, 0) + end = dt.datetime(cfg["end_year"], 12, 16, 0, 0, 0) + elif tstype == "Historic": + start = dt.datetime(cfg["start_year"], 1, 15, 0, 0, 0) end_year = cfg["start_year"] + cfg["comparison_period"] - 1 end = dt.datetime(end_year, 12, 16, 0, 0, 0) stime = time.nearest_neighbour_index(time.units.date2num(start)) @@ -97,18 +99,19 @@ def main(cfg): fname = meta["filename"] cube = iris.load_cube(fname) fnames.append(fname) - cube.coord('latitude').guess_bounds() - cube.coord('longitude').guess_bounds() - cube_mean = cube.collapsed('time', iris.analysis.MEAN) + cube.coord("latitude").guess_bounds() + cube.coord("longitude").guess_bounds() + cube_mean = cube.collapsed("time", iris.analysis.MEAN) if cfg.get("compare_intervals", False): # calculate and plot metrics per time slice - for tstype in ['Historic', 'Future']: - ts_cube = _set_tscube(cfg, cube, cube.coord('time'), tstype) + for tstype in ["Historic", "Future"]: + ts_cube = _set_tscube(cfg, cube, cube.coord("time"), tstype) drought_show = _get_drought_data(cfg, ts_cube) drought_slices[tstype].append(drought_show.data) if cfg.get("plot_models", False): _plot_single_maps( - cfg, cube_mean, drought_show, tstype, fname) + cfg, cube_mean, drought_show, tstype, fname + ) else: # calculate and plot metrics per dataset drought_show = _get_drought_data(cfg, cube) @@ -118,7 +121,8 @@ def main(cfg): drought_data.append(drought_show.data) if cfg.get("plot_models", False): _plot_single_maps( - cfg, cube_mean, drought_show, 'Historic', fname) + cfg, cube_mean, drought_show, "Historic", fname + ) if cfg.get("compare_intervals", False): # calculate multi model mean for time slices @@ -132,6 +136,6 @@ def main(cfg): _plot_models_vs_obs(cfg, cube, mmm, ref_data, fnames) -if __name__ == '__main__': +if __name__ == "__main__": with e.run_diagnostic() as config: main(config) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index d0120a9e27..6d403d6095 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- """Creates a difference map for any given drought index. A global map is plotted for each dataset with an index (must be unique). @@ -14,14 +12,6 @@ The produced maps can be clipped to non polar landmasses (220, 170, -55, 90) with `clip_land: True`. -TODO: rename metric and group to their real keys in plotkwargs (allow -multi match?) and make group_by accept a list. make sure diffmap_metrics is -always added so that it can be consiedered as extra facet for this diagnostic. -see yml for more notes.. - -TODO: plot_kwargs overwrites from cfg seems not to overwrite those from -diffmap.yml rename them to 'extra_plot_kwargs' or 'meta_plot_kwargs' and add a -meta key to match any selection (order matters). This allows more flexibility. Configuration options in recipe ------------------------------- @@ -86,7 +76,7 @@ import esmvaltool.diag_scripts.droughts.utils as ut import esmvaltool.diag_scripts.shared as e -# from esmvaltool.diag_scripts.droughts import colors # noqa: F401 +# from esmvaltool.diag_scripts.droughts import colors log = logging.getLogger(__file__) @@ -113,7 +103,7 @@ def plot_colorbar( """Plot colorbar in its own figure for strip_plots.""" fig = plt.figure(figsize=(1.5, 3)) # fixed size axes in fixed size figure - cbar_ax = fig.add_axes([0.01, 0.04, 0.2, 0.92]) # type: ignore[call-overload] + cbar_ax = fig.add_axes([0.01, 0.04, 0.2, 0.92]) if mappable is None: cmap = plot_kwargs.get("cmap", "RdYlBu") norm = mpl.colors.Normalize( @@ -166,12 +156,12 @@ def plot( log.info("NO BOUNDS GUESSING: %s", coord.name()) cube.coord(coord.name()).guess_bounds() cyclic_data, cyclic_lon = add_cyclic_point( - cube.data, cube.coord("longitude").points + cube.data, cube.coord("longitude").points, ) if ( meta["dataset"] == "ERA5" and meta["short_name"] == "evspsblpot" - and len(cube.data[0]) == 360 # noqa: PLR2004 + and len(cube.data[0]) == 360 ): # NOTE: fill missing gap at 360 for era5 pet calculation cube.data[:, 359] = cube.data[:, 0] @@ -228,7 +218,7 @@ def calculate_diff(cfg, meta, mm, output_meta, group, norm): if "start_year" in cfg or "end_year" in cfg: log.info("selecting time period") cube = pp.extract_time( - cube, cfg["start_year"], 1, 1, cfg["end_year"], 12, 31 + cube, cfg["start_year"], 1, 1, cfg["end_year"], 12, 31, ) dtime = cfg.get("comparison_period", 10) * 12 cubes = {} @@ -316,14 +306,13 @@ def main(cfg) -> None: mm = defaultdict(list) skipped = 0 for meta in metas: - # TODO@lukruh: fix diag_spei output to contain all relevant meta data - ut.guess_experiment(meta) + ut.guess_experiment(meta) # TODO: add in SPEI.R instead if "end_year" not in meta: try: meta.update(ut.get_time_range(meta["filename"])) except Exception: log.error( - "failed to get time range for %s", meta["filename"] + "failed to get time range for %s", meta["filename"], ) skipped += 1 log.error("skipped datasets: %s", skipped) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 0f47e3d2ca..811b694556 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -7,7 +7,6 @@ from __future__ import annotations import datetime as dt -import errno import itertools as it import logging from calendar import monthrange @@ -46,15 +45,11 @@ log = logging.getLogger(Path(__file__).name) # fmt: off -# pylint: disable=line-too-long -DENSITY = AuxCoord( - 1000, - long_name="density", - units="kg m-3") +DENSITY = AuxCoord(1000, long_name="density", units="kg m-3") -FNAME_FORMAT = "{project}_{reference_dataset}_{mip}_{exp}_{ensemble}_{short_name}_{start_year}-{end_year}" -CMIP6 = "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_{grid}_{start_year}-{end_year}" -OBS = "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_{start_year}-{end_year}" +FNAME_FORMAT = "{project}_{reference_dataset}_{mip}_{exp}_{ensemble}_{short_name}_{start_year}-{end_year}" # noqa: E501 +CMIP6_FNAME = "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_{grid}_{start_year}-{end_year}" # noqa: E501 +OBS_FNAME = "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_{start_year}-{end_year}" # noqa: E501 CONTINENTAL_REGIONS = { "Global": ["GLO"], # global @@ -63,17 +58,17 @@ "Southern America": ["NWS", "NSA", "NES", "SAM", "SWS", "SES", "SSA"], "Europe": ["NEU", "WCE", "EEU", "MED"], "Africa": ["SAH", "WAF", "CAF", "NEAF", "SEAF", "WSAF", "ESAF", "MDG"], - "Asia": ["RAR", "WSB", "ESB", "RFE", "WCA", "ECA", "TIB", "EAS", "ARP", "SAS", "SEA"], + "Asia": ["RAR", "WSB", "ESB", "RFE", "WCA", "ECA", "TIB", "EAS", "ARP", "SAS", "SEA"], # noqa: E501 "Australia": ["NAU", "CAU", "EAU", "SAU", "NZ", "WAN", "EAN"], } -HEX_POSITIONS ={ +HEX_POSITIONS = { "NWN": [2, 0], "NEN": [4, 0], "GIC": [6.5, -0.5], "NEU": [14, 0], "RAR": [20, 0], "WNA": [1, 1], "CNA": [3, 1], "ENA": [5, 1], "WCE": [13, 1], "EEU": [15, 1], "WSB": [17, 1], "ESB": [19, 1], "RFE": [21, 1], "NCA": [2, 2], "MED": [14, 2], "WCA": [16, 2], - "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], # "CAR": [5, 3], - "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], # "PAC": [27.5, 3.3], + "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], # "CAR": [5, 3], # noqa: E501 + "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], # "PAC": [27.5, 3.3], # noqa: E501 "NWS": [6, 4], "NSA": [8, 4], "WAF": [12, 4], "CAF": [14, 4], "NEAF": [16, 4], "NAU": [24.5, 4.3], "SAM": [7, 5], "NES": [9, 5], "WSAF": [13, 5], "SEAF": [15, 5], "MDG": [17.5, 5.3], @@ -114,7 +109,6 @@ }, } # fmt: on -# pylint: enable=line-too-long def merge_list_cube( @@ -641,7 +635,7 @@ def slice_cube_interval(cube: Cube, interval: list) -> Cube: For 3D cubes time needs to be first dim. """ if isinstance(interval[0], int) and isinstance(interval[1], int): - return cube[interval[0] : interval[1], :, :] + return cube[interval[0]: interval[1], :, :] dt_start = dt.datetime.strptime(interval[0], "%Y-%m") dt_end = dt.datetime.strptime(interval[1], "%Y-%m") time = cube.coord("time") From 0512091b3260f421fc7ac82c5d0d294c6349284b Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Thu, 27 Feb 2025 15:26:22 +0100 Subject: [PATCH 33/66] fix codacy issues --- .../diag_scripts/droughts/collect_drought.py | 14 +-- esmvaltool/diag_scripts/droughts/diffmap.py | 85 +++++++++---------- esmvaltool/diag_scripts/droughts/pet.R | 4 +- esmvaltool/diag_scripts/droughts/utils.py | 39 +++++---- 4 files changed, 72 insertions(+), 70 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index 57e21b2c14..28153652df 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -39,9 +39,11 @@ ``compare_intervals=True``. """ +import datetime as dt + import iris import numpy as np -import datetime as dt + import esmvaltool.diag_scripts.shared as e from esmvaltool.diag_scripts.droughts.utils import ( _get_drought_data, @@ -81,21 +83,21 @@ def _set_tscube(cfg, cube, time, tstype): start = dt.datetime(cfg["start_year"], 1, 15, 0, 0, 0) end_year = cfg["start_year"] + cfg["comparison_period"] - 1 end = dt.datetime(end_year, 12, 16, 0, 0, 0) + else: + raise ValueError("Unknown time slice type: " + tstype) stime = time.nearest_neighbour_index(time.units.date2num(start)) etime = time.nearest_neighbour_index(time.units.date2num(end)) - tscube = cube[stime:etime, :, :] - return tscube + return cube[stime:etime, :, :] -def main(cfg): +def main(cfg) -> None: """Run the diagnostic.""" # Read input data - input_data = cfg["input_data"].values() drought_data = [] drought_slices = {"Historic": [], "Future": []} fnames = [] # why do we need them? ref_data = None - for iii, meta in enumerate(input_data): + for meta in cfg["input_data"].values(): fname = meta["filename"] cube = iris.load_cube(fname) fnames.append(fname) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index 6d403d6095..4a78875fd3 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -94,7 +94,7 @@ def plot_colorbar( - cfg: dict, # noqa: ARG001 + cfg: dict, plotfile: str, plot_kwargs: dict, orientation: str = "vertical", @@ -111,7 +111,7 @@ def plot_colorbar( vmax=plot_kwargs.get("vmax"), ) mappable = mpl.cm.ScalarMappable(norm=norm, cmap=cmap) - cb = fig.colorbar( + cbar = fig.colorbar( mappable, cax=cbar_ax, orientation=orientation, @@ -119,10 +119,10 @@ def plot_colorbar( pad=0.0, ) if "cbar_ticks" in plot_kwargs: - cb.set_ticks(plot_kwargs["cbar_ticks"], minor=False) + cbar.set_ticks(plot_kwargs["cbar_ticks"], minor=False) fontsize = plot_kwargs.get("cbar_fontsize", 14) - cb.ax.tick_params(labelsize=fontsize) - cb.set_label( + cbar.ax.tick_params(labelsize=fontsize) + cbar.set_label( plot_kwargs["cbar_label"], fontsize=fontsize, labelpad=fontsize, @@ -155,9 +155,7 @@ def plot( if not coord.has_bounds(): log.info("NO BOUNDS GUESSING: %s", coord.name()) cube.coord(coord.name()).guess_bounds() - cyclic_data, cyclic_lon = add_cyclic_point( - cube.data, cube.coord("longitude").points, - ) + add_cyclic_point(cube.data, cube.coord("longitude").points) if ( meta["dataset"] == "ERA5" and meta["short_name"] == "evspsblpot" @@ -183,7 +181,10 @@ def plot( def apply_plot_kwargs_overwrite( - kwargs: dict, overwrites: dict, metric: str, group: str, + kwargs: dict, + overwrites: dict, + metric: str, + group: str, ) -> dict: """Apply plot_kwargs_overwrite to kwargs dict for selected plots.""" for overwrite in overwrites: @@ -202,14 +203,13 @@ def apply_plot_kwargs_overwrite( return kwargs -def calculate_diff(cfg, meta, mm, output_meta, group, norm): +def calculate_diff(cfg, meta, mm_data, output_meta, group): """Absolute difference between first and last years of a cube. Calculates the absolut difference between the first and last period of a cube. Writing data to mm and plotting each dataset depends on cfg. """ - fname = meta["filename"] - cube = iris.load_cube(fname) + cube = iris.load_cube(meta["filename"]) if meta["short_name"] in cfg.get("convert_units", {}): pp.convert_units(cube, cfg["convert_units"][meta["short_name"]]) with contextlib.suppress(Exception): @@ -218,14 +218,25 @@ def calculate_diff(cfg, meta, mm, output_meta, group, norm): if "start_year" in cfg or "end_year" in cfg: log.info("selecting time period") cube = pp.extract_time( - cube, cfg["start_year"], 1, 1, cfg["end_year"], 12, 31, + cube, + cfg["start_year"], + 1, + 1, + cfg["end_year"], + 12, + 31, ) dtime = cfg.get("comparison_period", 10) * 12 cubes = {} cubes["total"] = cube.collapsed("time", MEAN) do_metrics = cfg.get("metrics", METRICS) - calc_metrics = ["first", "last", "diff", "percent"] - if any(m in do_metrics for m in calc_metrics): + norm = ( + int(meta["end_year"]) + - int(meta["start_year"]) + + 1 # count full end year + - cfg.get("comparison_period", 10) # decades center to center + ) / 10 + if any(m in do_metrics for m in ["first", "last", "diff", "percent"]): cubes["first"] = cube[0:dtime].collapsed("time", MEAN) cubes["last"] = cube[-dtime:].collapsed("time", MEAN) if any(m in do_metrics for m in ["diff", "percent"]): @@ -236,19 +247,19 @@ def calculate_diff(cfg, meta, mm, output_meta, group, norm): cubes["percent"].units = "% / 10 years" if cfg.get("plot_mmm", True): for key in do_metrics: - mm[key].append(cubes[key]) + mm_data[key].append(cubes[key]) for key, cube in cubes.items(): if key not in do_metrics: continue # i.e. first/last if only diff is needed meta["diffmap_metric"] = key meta["exp"] = meta.get("exp", "exp") basename = cfg["basename"].format(**meta) - titles = cfg.get("titles", TITLES) - meta["title"] = titles[key].format(**meta) + meta["title"] = cfg.get("titles", TITLES)[key].format(**meta) if cfg.get("plot_models", True): plot_kwargs = cfg.get("plot_kwargs", {}).copy() - overwrites = cfg.get("plot_kwargs_overwrite", []) - apply_plot_kwargs_overwrite(plot_kwargs, overwrites, key, group) + apply_plot_kwargs_overwrite( + plot_kwargs, cfg.get("plot_kwargs_overwrite", []), key, group, + ) plot(cfg, meta, cube, basename, kwargs=plot_kwargs) plt.close() if cfg.get("save_models", True): @@ -258,7 +269,7 @@ def calculate_diff(cfg, meta, mm, output_meta, group, norm): output_meta[work_file] = meta.copy() -def calculate_mmm(cfg, meta, mm, output_meta, group) -> None: +def calculate_mmm(cfg, meta, mm_data, output_meta, group) -> None: """Calculate multi-model mean for a given metric.""" for metric in cfg.get("metrics", METRICS): drop = cfg.get("dropcoords", ["time", "height"]) @@ -267,7 +278,7 @@ def calculate_mmm(cfg, meta, mm, output_meta, group) -> None: meta["diffmap_metric"] = metric basename = cfg["basename"].format(**meta) mmm, _ = ut.mmm( - mm[metric], + mm_data[metric], dropcoords=drop, dropmethods=metric != "diff", mdtol=cfg.get("mdtol", 0.3), @@ -288,9 +299,9 @@ def calculate_mmm(cfg, meta, mm, output_meta, group) -> None: def set_defaults(cfg: dict) -> None: """Update cfg with default values from diffmap.yml in place.""" - config_file = os.path.realpath(__file__)[:-3] + ".yml" - with open(config_file, encoding="utf-8") as f: - defaults = yaml.safe_load(f) + config_fpath = os.path.realpath(__file__)[:-3] + ".yml" + with open(config_fpath, encoding="utf-8") as config_file: + defaults = yaml.safe_load(config_file) for key, val in defaults.items(): cfg.setdefault(key, val) if cfg["plot_kwargs_overwrite"] is not defaults["plot_kwargs_overwrite"]: @@ -303,35 +314,19 @@ def main(cfg) -> None: groups = e.group_metadata(cfg["input_data"].values(), cfg["group_by"]) output = {} for group, metas in groups.items(): - mm = defaultdict(list) - skipped = 0 + mm_data = defaultdict(list) for meta in metas: ut.guess_experiment(meta) # TODO: add in SPEI.R instead if "end_year" not in meta: - try: - meta.update(ut.get_time_range(meta["filename"])) - except Exception: - log.error( - "failed to get time range for %s", meta["filename"], - ) - skipped += 1 - log.error("skipped datasets: %s", skipped) - continue + meta.update(ut.get_time_range(meta["filename"])) # adjust norm for selected time period meta["end_year"] = cfg.get("end_year", meta["end_year"]) meta["start_year"] = cfg.get("start_year", meta["start_year"]) - norm = ( - int(meta["end_year"]) - - int(meta["start_year"]) - + 1 # count full end year - - cfg.get("comparison_period", 10) # decades center to center - ) / 10 - calculate_diff(cfg, meta, mm, output, group, norm) + calculate_diff(cfg, meta, mm_data, output, group, norm) do_mmm = cfg.get("plot_mmm", True) or cfg.get("save_mmm", True) if do_mmm and len(metas) > 1: - calculate_mmm(cfg, metas[0], mm, output, group) + calculate_mmm(cfg, metas[0], mm_data, output, group) ut.save_metadata(cfg, output) - # TODO@lukruh: close all and everything to free up memory if __name__ == "__main__": diff --git a/esmvaltool/diag_scripts/droughts/pet.R b/esmvaltool/diag_scripts/droughts/pet.R index b204f6d354..8e858e2064 100644 --- a/esmvaltool/diag_scripts/droughts/pet.R +++ b/esmvaltool/diag_scripts/droughts/pet.R @@ -203,7 +203,7 @@ for (dataset in names(grouped_meta)){ pet[, , t] <- tmp } pet <- monthly_to_daily(pet, dim=3) - + # write PET to file first_meta = metas[[names(metas)[1]]] filename <- write_nc_file_like( @@ -213,7 +213,7 @@ for (dataset in names(grouped_meta)){ units="mm day-1") input_meta <- select_var(metas, "tasmin") # TODO: create duplicate()? input_meta$filename <- filename - input_meta$short_name <- "evspsblpot" + input_meta$short_name <- "evspsblpot" input_meta$long_name <- "Potential Evapotranspiration" input_meta$units <- "mm day-1" meta[[filename]] <- input_meta diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 811b694556..d861af2557 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -47,9 +47,12 @@ # fmt: off DENSITY = AuxCoord(1000, long_name="density", units="kg m-3") -FNAME_FORMAT = "{project}_{reference_dataset}_{mip}_{exp}_{ensemble}_{short_name}_{start_year}-{end_year}" # noqa: E501 -CMIP6_FNAME = "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_{grid}_{start_year}-{end_year}" # noqa: E501 -OBS_FNAME = "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_{start_year}-{end_year}" # noqa: E501 +FNAME_FORMAT = "{project}_{reference_dataset}_{mip}_{exp}_{ensemble}_" +"{short_name}_{start_year}-{end_year}" +CMIP6_FNAME = "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_" +"{grid}_{start_year}-{end_year}" +OBS_FNAME = "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_" +"{start_year}-{end_year}" CONTINENTAL_REGIONS = { "Global": ["GLO"], # global @@ -58,7 +61,8 @@ "Southern America": ["NWS", "NSA", "NES", "SAM", "SWS", "SES", "SSA"], "Europe": ["NEU", "WCE", "EEU", "MED"], "Africa": ["SAH", "WAF", "CAF", "NEAF", "SEAF", "WSAF", "ESAF", "MDG"], - "Asia": ["RAR", "WSB", "ESB", "RFE", "WCA", "ECA", "TIB", "EAS", "ARP", "SAS", "SEA"], # noqa: E501 + "Asia": ["RAR", "WSB", "ESB", "RFE", "WCA", "ECA", "TIB", "EAS", "ARP", + "SAS", "SEA"], "Australia": ["NAU", "CAU", "EAU", "SAU", "NZ", "WAN", "EAN"], } @@ -67,8 +71,10 @@ "RAR": [20, 0], "WNA": [1, 1], "CNA": [3, 1], "ENA": [5, 1], "WCE": [13, 1], "EEU": [15, 1], "WSB": [17, 1], "ESB": [19, 1], "RFE": [21, 1], "NCA": [2, 2], "MED": [14, 2], "WCA": [16, 2], - "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], # "CAR": [5, 3], # noqa: E501 - "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], # "PAC": [27.5, 3.3], # noqa: E501 + "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], + # "CAR": [5, 3], + "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], + # "PAC": [27.5, 3.3], "NWS": [6, 4], "NSA": [8, 4], "WAF": [12, 4], "CAF": [14, 4], "NEAF": [16, 4], "NAU": [24.5, 4.3], "SAM": [7, 5], "NES": [9, 5], "WSAF": [13, 5], "SEAF": [15, 5], "MDG": [17.5, 5.3], @@ -896,13 +902,15 @@ def remove_attributes( del cube.attributes[attr] -def font_color(background: str) -> str: +def font_color(background: str|tuple|float) -> str: """Black or white depending on greyscale of the background. + Can be used to make text more readable on a colored background. + Parameters ---------- - bacgkround - matplotlib color + bacgkround: str + color as string (grayscale value, name, hex) or tuple (rgb, rgba) """ if sum(mpl.colors.to_rgb(background)) > 1.5: return "black" @@ -955,6 +963,7 @@ def monthly_to_daily( days = months[idx] cube.data[idx] = cube.data[idx] / days continue + # consider leap days time = sli.coord("time") date = time.units.num2date(time.points[0]) days = monthrange(date.year, date.month)[1] @@ -980,14 +989,10 @@ def daily_to_monthly( days = months[idx] cube.data[idx] = cube.data[idx] * days continue - try: # consider leapday - time = sli.coord("time") - date = time.units.num2date(time.points[0]) - days = monthrange(date.year, date.month)[1] - except Exception as err: - log.warning("date failed, using fixed days without leap year") - log.warning(err) - days = months[idx] + # consider leap days + time = sli.coord("time") + date = time.units.num2date(time.points[0]) + days = monthrange(date.year, date.month)[1] cube.data[idx] = cube.data[idx] * days cube.units = units From cecdb061dc167b0dd10f20981a3a0dce3a85f504 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Thu, 27 Feb 2025 17:44:40 +0100 Subject: [PATCH 34/66] move martin specific functions from utils to collect --- .../diag_scripts/droughts/collect_drought.py | 579 ++++++++++++++- esmvaltool/diag_scripts/droughts/utils.py | 660 +----------------- 2 files changed, 592 insertions(+), 647 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index 28153652df..3cff0a9adf 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -11,6 +11,12 @@ dataset can be specified with ``reference_dataset`` and is not part of the multi-model mean. +.. note:: Previouis Version: + With ESMValTool v2.12 and previous, multiple collect_drought_*.py + diagnostics and a collect_drought_func.py diagnostic existed in the + `droughtindex` folders. Those have been archived and replaced by this + diagnostic in v2.13. + Configuration options --------------------- indexname: str @@ -40,17 +46,572 @@ """ import datetime as dt +import logging +from pathlib import Path +from pprint import pformat +import cartopy.crs as cart import iris import numpy as np +from iris.analysis import Aggregator import esmvaltool.diag_scripts.shared as e from esmvaltool.diag_scripts.droughts.utils import ( - _get_drought_data, - _plot_multi_model_maps, - _plot_single_maps, + _make_new_cube, + count_spells, + create_cube_from_data, +) +from esmvaltool.diag_scripts.shared import ( + ProvenanceLogger, + get_diagnostic_filename, + get_plot_filename, ) +log = logging.getLogger(Path(__file__).name) + + +def get_provenance_record( + ancestor_files, + caption, + domains, + refs, + plot_type="geo", +) -> dict: + """Get Provenance record.""" + return { + "caption": caption, + "statistics": ["mean"], + "domains": domains, + "plot_type": plot_type, + "themes": ["phys"], + "authors": [ + "weigel_katja", + "adeniyi_kemisola", + ], + "references": refs, + "ancestors": ancestor_files, + } + + +def _get_drought_data(cfg, cube): + """Prepare data and calculate characteristics.""" + # make a new cube to increase the size of the data array + # Make an aggregator from the user function. + spell_no = Aggregator( + "spell_count", + count_spells, + units_func=lambda units: 1, + ) + new_cube = _make_new_cube(cube) + + # calculate the number of drought events and their average duration + drought_show = new_cube.collapsed( + "time", + spell_no, + threshold=cfg["threshold"], + ) + drought_show.rename("Drought characteristics") + # length of time series + time_length = len(new_cube.coord("time").points) / 12.0 + # Convert number of droughtevents to frequency (per year) + drought_show.data[:, :, 0] = drought_show.data[:, :, 0] / time_length + return drought_show + + +def _provenance_map_spei(cfg, name_dict, spei, dataset_name): + """Set provenance for plot_map_spei.""" + caption = ( + "Global map of " + + name_dict["drought_char"] + + " [" + + name_dict["unit"] + + "] " + + "based on " + + cfg["indexname"] + + "." + ) + + if cfg["indexname"].lower == "spei": + set_refs = ["martin18grl", "vicente10jclim"] + elif cfg["indexname"].lower == "spi": + set_refs = ["martin18grl", "mckee93proc"] + else: + set_refs = ["martin18grl"] + + provenance_record = get_provenance_record( + [name_dict["input_filenames"]], + caption, + ["global"], + set_refs, + ) + + diagnostic_file = get_diagnostic_filename( + cfg["indexname"] + + "_map" + + name_dict["add_to_filename"] + + "_" + + dataset_name, + cfg, + ) + plot_file = get_plot_filename( + cfg["indexname"] + + "_map" + + name_dict["add_to_filename"] + + "_" + + dataset_name, + cfg, + ) + log.info("Saving analysis results to %s", diagnostic_file) + cubesave = create_cube_from_data(spei, name_dict) + iris.save(cubesave, target=diagnostic_file) + log.info( + "Recording provenance of %s:\n%s", + diagnostic_file, + pformat(provenance_record), + ) + with ProvenanceLogger(cfg) as provenance_logger: + provenance_logger.log(plot_file, provenance_record) + provenance_logger.log(diagnostic_file, provenance_record) + + +def _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames): + """Set provenance for plot_map_spei_multi.""" + caption = ( + f"Global map of the multi-model mean of " + f"{data_dict['drought_char']} [{data_dict['unit']}] based on " + f"{cfg['indexname']}." + ) + if cfg["indexname"].lower == "spei": + set_refs = ["martin18grl", "vicente10jclim"] + elif cfg["indexname"].lower == "spi": + set_refs = ["martin18grl", "mckee93proc"] + else: + set_refs = ["martin18grl"] + + provenance_record = get_provenance_record( + input_filenames, + caption, + ["global"], + set_refs, + ) + + diagnostic_file = get_diagnostic_filename( + cfg["indexname"] + + "_map" + + data_dict["filename"] + + "_" + + data_dict["datasetname"], + cfg, + ) + plot_file = get_plot_filename( + cfg["indexname"] + + "_map" + + data_dict["filename"] + + "_" + + data_dict["datasetname"], + cfg, + ) + log.info("Saving analysis results to %s", diagnostic_file) + iris.save(create_cube_from_data(spei, data_dict), target=diagnostic_file) + log.info( + "Recording provenance of %s:\n%s", + diagnostic_file, + pformat(provenance_record), + ) + with ProvenanceLogger(cfg) as provenance_logger: + provenance_logger.log(plot_file, provenance_record) + provenance_logger.log(diagnostic_file, provenance_record) + + +def _plot_multi_model_maps( + cfg, + all_drought_mean, + lats_lons, + input_filenames, + tstype, +): + """Prepare plots for multi-model mean.""" + data_dict = { + "latitude": lats_lons[0], + "longitude": lats_lons[1], + "model_kind": tstype, + } + if tstype == "Difference": + # RCP85 Percentage difference + data_dict.update({ + "data": all_drought_mean[:, :, 0], + "var": "diffnumber", + "datasetname": "Percentage", + "drought_char": "Number of drought events", + "unit": "%", + "filename": "Percentage_difference_of_No_of_Events", + "drought_numbers_level": np.arange(-100, 110, 10), + }) + plot_map_spei_multi( + cfg, + data_dict, + input_filenames, + colormap="rainbow", + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 1], + "var": "diffduration", + "drought_char": "Duration of drought events", + "filename": "Percentage_difference_of_Dur_of_Events", + "drought_numbers_level": np.arange(-100, 110, 10), + }) + plot_map_spei_multi( + cfg, + data_dict, + input_filenames, + colormap="rainbow", + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 2], + "var": "diffseverity", + "drought_char": "Severity Index of drought events", + "filename": "Percentage_difference_of_Sev_of_Events", + "drought_numbers_level": np.arange(-50, 60, 10), + }) + plot_map_spei_multi( + cfg, + data_dict, + input_filenames, + colormap="rainbow", + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 3], + "var": "diff" + (cfg["indexname"]).lower(), + "drought_char": "Average " + + cfg["indexname"] + + " of drought events", + "filename": "Percentage_difference_of_Avr_of_Events", + "drought_numbers_level": np.arange(-50, 60, 10), + }) + plot_map_spei_multi( + cfg, + data_dict, + input_filenames, + colormap="rainbow", + ) + else: + data_dict.update({ + "data": all_drought_mean[:, :, 0], + "var": "frequency", + "unit": "year-1", + "drought_char": "Number of drought events per year", + "filename": tstype + "_No_of_Events_per_year", + "drought_numbers_level": np.arange(0, 0.4, 0.05), + }) + if tstype == "Observations": + data_dict["datasetname"] = "Mean" + else: + data_dict["datasetname"] = "MultiModelMean" + plot_map_spei_multi( + cfg, + data_dict, + input_filenames, + colormap="gnuplot", + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 1], + "var": "duration", + "unit": "month", + "drought_char": "Duration of drought events [month]", + "filename": tstype + "_Dur_of_Events", + "drought_numbers_level": np.arange(0, 6, 1), + }) + plot_map_spei_multi( + cfg, + data_dict, + input_filenames, + colormap="gnuplot", + ) + + data_dict.update({ + "data": all_drought_mean[:, :, 2], + "var": "severity", + "unit": "1", + "drought_char": "Severity Index of drought events", + "filename": tstype + "_Sev_index_of_Events", + "drought_numbers_level": np.arange(0, 9, 1), + }) + plot_map_spei_multi( + cfg, + data_dict, + input_filenames, + colormap="gnuplot", + ) + namehlp = "Average " + cfg["indexname"] + " of drought events" + namehlp2 = tstype + "_Average_" + cfg["indexname"] + "_of_Events" + data_dict.update({ + "data": all_drought_mean[:, :, 3], + "var": (cfg["indexname"]).lower(), + "unit": "1", + "drought_char": namehlp, + "filename": namehlp2, + "drought_numbers_level": np.arange(-2.8, -1.8, 0.2), + }) + plot_map_spei_multi( + cfg, + data_dict, + input_filenames, + colormap="gnuplot", + ) + + +def _plot_single_maps(cfg, cube2, drought_show, tstype, input_filenames): + """Plot map of drought characteristics for individual models and times.""" + cube2.data = drought_show.data[:, :, 0] + name_dict = { + "add_to_filename": tstype + "_No_of_Events_per_year", + "name": tstype + " Number of drought events per year", + "var": "frequency", + "unit": "year-1", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + } + plot_map_spei(cfg, cube2, np.arange(0, 0.4, 0.05), name_dict) + # plot the average duration of drought events + cube2.data = drought_show.data[:, :, 1] + name_dict.update({ + "add_to_filename": tstype + "_Dur_of_Events", + "name": tstype + " Duration of drought events(month)", + "var": "duration", + "unit": "month", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + }) + plot_map_spei(cfg, cube2, np.arange(0, 6, 1), name_dict) + # plot the average severity index of drought events + cube2.data = drought_show.data[:, :, 2] + name_dict.update({ + "add_to_filename": tstype + "_Sev_index_of_Events", + "name": tstype + " Severity Index of drought events", + "var": "severity", + "unit": "1", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + }) + plot_map_spei(cfg, cube2, np.arange(0, 9, 1), name_dict) + # plot the average spei of drought events + cube2.data = drought_show.data[:, :, 3] + namehlp = tstype + "_Avr_" + cfg["indexname"] + "_of_Events" + namehlp2 = tstype + "_Average_" + cfg["indexname"] + "_of_Events" + name_dict.update({ + "add_to_filename": namehlp, + "name": namehlp2, + "var": "severity", + "unit": "1", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + }) + plot_map_spei(cfg, cube2, np.arange(-2.8, -1.8, 0.2), name_dict) + + +def plot_map_spei_multi( + cfg, + data_dict, + input_filenames, + colormap="jet", +) -> None: + """Plot contour maps for multi model mean.""" + spei = np.ma.array(data_dict["data"], mask=np.isnan(data_dict["data"])) + # Get latitudes and longitudes from cube + lons = data_dict["longitude"] + if max(lons) > 180.0: + lons = np.where(lons > 180, lons - 360, lons) + # sort the array + index = np.argsort(lons) + lons = lons[index] + spei = spei[np.ix_(range(data_dict["latitude"].size), index)] + # Plot data + # Create figure and axes instances + subplot_kw = {"projection": cart.PlateCarree(central_longitude=0.0)} + fig, axx = plt.subplots(figsize=(6.5, 4), subplot_kw=subplot_kw) + axx.set_extent( + [-180.0, 180.0, -90.0, 90.0], + cart.PlateCarree(central_longitude=0.0), + ) + # Draw filled contours + cnplot = plt.contourf( + lons, + data_dict["latitude"], + spei, + data_dict["drought_numbers_level"], + transform=cart.PlateCarree(central_longitude=0.0), + cmap=colormap, + extend="both", + corner_mask=False, + ) + # Style plot + axx.coastlines() + cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation="horizontal") + if data_dict["model_kind"] == "Difference": + cbar.set_label( + data_dict["model_kind"] + " " + data_dict["drought_char"] + " [%]", + ) + else: + cbar.set_label( + data_dict["model_kind"] + " " + data_dict["drought_char"], + ) + axx.set_xlabel("Longitude") + axx.set_ylabel("Latitude") + axx.set_title( + f"{data_dict['datasetname']} {data_dict['model_kind']} " + f"{data_dict['drought_char']}", + ) + # set ticks + axx.set_xticks(np.linspace(-180, 180, 7)) + axx.set_xticklabels([ + "180°W", + "120°W", + "60°W", + "0°", + "60°E", + "120°E", + "180°E", + ]) + axx.set_yticks(np.linspace(-90, 90, 7)) + axx.set_yticklabels(["90°S", "60°S", "30°S", "0°", "30°N", "60°N", "90°N"]) + + fig.tight_layout() + fig.savefig( + get_plot_filename( + cfg["indexname"] + + "_map" + + data_dict["filename"] + + "_" + + data_dict["datasetname"], + cfg, + ), + dpi=300, + ) + plt.close() + _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames) + + +def plot_map_spei(cfg, cube, levels, name_dict) -> None: + """Plot contour map.""" + mask = np.isnan(cube.data) + spei = np.ma.array(cube.data, mask=mask) + np.ma.masked_less_equal(spei, 0) + # Get latitudes and longitudes from cube + name_dict.update({"latitude": cube.coord("latitude").points}) + lons = cube.coord("longitude").points + lons = np.where(lons > 180, lons - 360, lons) + # sort the array + index = np.argsort(lons) + lons = lons[index] + name_dict.update({"longitude": lons}) + spei = spei[np.ix_(range(len(cube.coord("latitude").points)), index)] + # Get data set name from cube + try: + dataset_name = cube.metadata.attributes["model_id"] + except KeyError: + try: + dataset_name = cube.metadata.attributes["source_id"] + except KeyError: + dataset_name = "Observations" + # Plot data + # Create figure and axes instances + subplot_kw = {"projection": cart.PlateCarree(central_longitude=0.0)} + fig, axx = plt.subplots(figsize=(8, 4), subplot_kw=subplot_kw) + axx.set_extent( + [-180.0, 180.0, -90.0, 90.0], + cart.PlateCarree(central_longitude=0.0), + ) + # Draw filled contours + cnplot = plt.contourf( + lons, + cube.coord("latitude").points, + spei, + levels, + transform=cart.PlateCarree(central_longitude=0.0), + cmap="gnuplot", + extend="both", + corner_mask=False, + ) + axx.coastlines() + cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation="horizontal") + cbar.set_label(name_dict["name"]) + axx.set_xlabel("Longitude") + axx.set_ylabel("Latitude") + axx.set_title(dataset_name + " " + name_dict["name"]) + + # Set up x and y ticks + axx.set_xticks(np.linspace(-180, 180, 7)) + axx.set_xticklabels( + ["180°W", "120°W", "60°W", "0°", "60°E", "120°E", "180°E"], + ) + axx.set_yticks(np.linspace(-90, 90, 7)) + axx.set_yticklabels(["90°S", "60°S", "30°S", "0°", "30°N", "60°N", "90°N"]) + fig.tight_layout() + basename = ( + f"{cfg['indexname']}_map_{name_dict['add_to_filename']}_{dataset_name}" + ) + fig.savefig(get_plot_filename(basename, cfg), dpi=300) + plt.close() + _provenance_map_spei(cfg, name_dict, spei, dataset_name) + + +def plot_time_series_spei(cfg, cube, filename, add_to_filename="") -> None: + """Plot time series.""" + spei = cube.data + time = cube.coord("time").points + # Adjust (ncdf) time to the format matplotlib expects + add_m_delta = mdates.datestr2num("1850-01-01 00:00:00") + time = time + add_m_delta + # Get data set name from cube + try: + dataset_name = cube.metadata.attributes["model_id"] + except KeyError: + try: + dataset_name = cube.metadata.attributes["source_id"] + except KeyError: + dataset_name = "Observations" + data_dict = { + "data": spei, + "time": time, + "var": cfg["indexname"], + "dataset_name": dataset_name, + "unit": "1", + "filename": filename, + "area": add_to_filename, + } + fig, axx = plt.subplots(figsize=(16, 4)) + axx.plot_date( + time, + spei, + "-", + tz=None, + xdate=True, + ydate=False, + color="r", + linewidth=4.0, + linestyle="-", + alpha=1.0, + marker="x", + ) + axx.axhline(y=-2, color="k") + axx.set_xlabel("Time") + axx.set_ylabel(cfg["indexname"]) + axx.set_title( + f"Mean {cfg['indexname']} {data_dict['dataset_name']} " + f"{data_dict['area']}", + ) + axx.set_ylim(-4.0, 4.0) + fig.tight_layout() + basename = f"{cfg['indexname']}_time_series_{data_dict['area']}_" + basename += data_dict["dataset_name"] + fig.savefig(get_plot_filename(basename, cfg), dpi=300) + plt.close() + _provenance_time_series_spei(cfg, data_dict) + def _plot_models_vs_obs(cfg, cube, mmm, obs, fnames): """Compare drought metrics of multi-model mean to observations.""" @@ -112,7 +673,11 @@ def main(cfg) -> None: drought_slices[tstype].append(drought_show.data) if cfg.get("plot_models", False): _plot_single_maps( - cfg, cube_mean, drought_show, tstype, fname + cfg, + cube_mean, + drought_show, + tstype, + fname, ) else: # calculate and plot metrics per dataset @@ -123,7 +688,11 @@ def main(cfg) -> None: drought_data.append(drought_show.data) if cfg.get("plot_models", False): _plot_single_maps( - cfg, cube_mean, drought_show, "Historic", fname + cfg, + cube_mean, + drought_show, + "Historic", + fname, ) if cfg.get("compare_intervals", False): diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index d861af2557..6d1479c7cc 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -11,11 +11,9 @@ import logging from calendar import monthrange from pathlib import Path -from pprint import pformat from typing import Any import cartopy as ct -import cartopy.crs as cart import iris import matplotlib as mpl import matplotlib.dates as mdates @@ -26,7 +24,6 @@ from cf_units import Unit from esmvalcore import preprocessor as pp from esmvalcore.iris_helpers import date2num -from iris.analysis import Aggregator from iris.coords import AuxCoord from iris.cube import Cube, CubeList from iris.util import equalise_attributes @@ -36,7 +33,6 @@ ProvenanceLogger, get_cfg, get_diagnostic_filename, - get_plot_filename, group_metadata, select_metadata, ) @@ -641,7 +637,7 @@ def slice_cube_interval(cube: Cube, interval: list) -> Cube: For 3D cubes time needs to be first dim. """ if isinstance(interval[0], int) and isinstance(interval[1], int): - return cube[interval[0]: interval[1], :, :] + return cube[interval[0] : interval[1], :, :] dt_start = dt.datetime.strptime(interval[0], "%Y-%m") dt_end = dt.datetime.strptime(interval[1], "%Y-%m") time = cube.coord("time") @@ -902,7 +898,7 @@ def remove_attributes( del cube.attributes[attr] -def font_color(background: str|tuple|float) -> str: +def font_color(background: str | tuple | float) -> str: """Black or white depending on greyscale of the background. Can be used to make text more readable on a colored background. @@ -1010,206 +1006,26 @@ def _get_data_hlp(axis, data, ilat, ilon): return data_help -def _get_drought_data(cfg, cube): - """Prepare data and calculate characteristics.""" - # make a new cube to increase the size of the data array - # Make an aggregator from the user function. - spell_no = Aggregator( - "spell_count", - count_spells, - units_func=lambda units: 1, - ) - new_cube = _make_new_cube(cube) - - # calculate the number of drought events and their average duration - drought_show = new_cube.collapsed( - "time", - spell_no, - threshold=cfg["threshold"], - ) - drought_show.rename("Drought characteristics") - # length of time series - time_length = len(new_cube.coord("time").points) / 12.0 - # Convert number of droughtevents to frequency (per year) - drought_show.data[:, :, 0] = drought_show.data[:, :, 0] / time_length - return drought_show - - -def _provenance_map_spei(cfg, name_dict, spei, dataset_name): - """Set provenance for plot_map_spei.""" - caption = ( - "Global map of " - + name_dict["drought_char"] - + " [" - + name_dict["unit"] - + "] " - + "based on " - + cfg["indexname"] - + "." - ) - - if cfg["indexname"].lower == "spei": - set_refs = ["martin18grl", "vicente10jclim"] - elif cfg["indexname"].lower == "spi": - set_refs = ["martin18grl", "mckee93proc"] - else: - set_refs = ["martin18grl"] - - provenance_record = get_provenance_record( - [name_dict["input_filenames"]], - caption, - ["global"], - set_refs, - ) - - diagnostic_file = get_diagnostic_filename( - cfg["indexname"] - + "_map" - + name_dict["add_to_filename"] - + "_" - + dataset_name, - cfg, - ) - plot_file = get_plot_filename( - cfg["indexname"] - + "_map" - + name_dict["add_to_filename"] - + "_" - + dataset_name, - cfg, - ) - log.info("Saving analysis results to %s", diagnostic_file) - cubesave = cube_to_save_ploted(spei, name_dict) - iris.save(cubesave, target=diagnostic_file) - log.info( - "Recording provenance of %s:\n%s", - diagnostic_file, - pformat(provenance_record), - ) - with ProvenanceLogger(cfg) as provenance_logger: - provenance_logger.log(plot_file, provenance_record) - provenance_logger.log(diagnostic_file, provenance_record) - - -def _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames): - """Set provenance for plot_map_spei_multi.""" - caption = ( - f"Global map of the multi-model mean of " - f"{data_dict['drought_char']} [{data_dict['unit']}] based on " - f"{cfg['indexname']}." - ) - if cfg["indexname"].lower == "spei": - set_refs = ["martin18grl", "vicente10jclim"] - elif cfg["indexname"].lower == "spi": - set_refs = ["martin18grl", "mckee93proc"] - else: - set_refs = ["martin18grl"] - - provenance_record = get_provenance_record( - input_filenames, - caption, - ["global"], - set_refs, - ) - - diagnostic_file = get_diagnostic_filename( - cfg["indexname"] - + "_map" - + data_dict["filename"] - + "_" - + data_dict["datasetname"], - cfg, - ) - plot_file = get_plot_filename( - cfg["indexname"] - + "_map" - + data_dict["filename"] - + "_" - + data_dict["datasetname"], - cfg, - ) - - log.info("Saving analysis results to %s", diagnostic_file) - - iris.save(cube_to_save_ploted(spei, data_dict), target=diagnostic_file) - - log.info( - "Recording provenance of %s:\n%s", - diagnostic_file, - pformat(provenance_record), - ) - with ProvenanceLogger(cfg) as provenance_logger: - provenance_logger.log(plot_file, provenance_record) - provenance_logger.log(diagnostic_file, provenance_record) +def create_cube_from_data(var, data_dict) -> Cube: + """Create cube to prepare plotted data for saving to netCDF. + Renamed function from cube_to_save_ploted_data -def _provenance_time_series_spei(cfg, data_dict): - """Provenance for time series plots.""" - caption = ( - "Time series of " + data_dict["var"] + " at" + data_dict["area"] + "." - ) - - if cfg["indexname"].lower == "spei": - set_refs = [ - "vicente10jclim", - ] - elif cfg["indexname"].lower == "spi": - set_refs = [ - "mckee93proc", - ] - else: - set_refs = [ - "martin18grl", - ] - - provenance_record = get_provenance_record( - [data_dict["filename"]], - caption, - ["reg"], - set_refs, - plot_type="times", - ) - - diagnostic_file = get_diagnostic_filename( - cfg["indexname"] - + "_time_series_" - + data_dict["area"] - + "_" - + data_dict["dataset_name"], - cfg, - ) - plot_file = get_plot_filename( - cfg["indexname"] - + "_time_series_" - + data_dict["area"] - + "_" - + data_dict["dataset_name"], - cfg, - ) - log.info("Saving analysis results to %s", diagnostic_file) - - cubesave = cube_to_save_ploted_ts(data_dict) - iris.save(cubesave, target=diagnostic_file) - - log.info( - "Recording provenance of %s:\n%s", - diagnostic_file, - pformat(provenance_record), - ) - with ProvenanceLogger(cfg) as provenance_logger: - provenance_logger.log(plot_file, provenance_record) - provenance_logger.log(diagnostic_file, provenance_record) - - -def cube_to_save_ploted(var, data_dict) -> Cube: - """Create cube to prepare plotted data for saving to netCDF.""" - plot_cube = Cube( + Parameters + ---------- + var : np.ndarray + Data to be plotted. + data_dict : dict + Dictionary containing metadata for the data. It must contain: "var", + "drought_char", "unit", "latitude", and "longitude". + """ + cube = Cube( var, var_name=data_dict["var"], long_name=data_dict["drought_char"], units=data_dict["unit"], ) - plot_cube.add_dim_coord( + cube.add_dim_coord( iris.coords.DimCoord( data_dict["latitude"], var_name="lat", @@ -1218,7 +1034,7 @@ def cube_to_save_ploted(var, data_dict) -> Cube: ), 0, ) - plot_cube.add_dim_coord( + cube.add_dim_coord( iris.coords.DimCoord( data_dict["longitude"], var_name="lon", @@ -1227,7 +1043,7 @@ def cube_to_save_ploted(var, data_dict) -> Cube: ), 1, ) - return plot_cube + return cube def cube_to_save_ploted_ts(data_dict: dict) -> Cube: @@ -1248,29 +1064,6 @@ def cube_to_save_ploted_ts(data_dict: dict) -> Cube: return cube -def get_provenance_record( - ancestor_files, - caption, - domains, - refs, - plot_type="geo", -) -> dict: - """Get Provenance record.""" - return { - "caption": caption, - "statistics": ["mean"], - "domains": domains, - "plot_type": plot_type, - "themes": ["phys"], - "authors": [ - "weigel_katja", - "adeniyi_kemisola", - ], - "references": refs, - "ancestors": ancestor_files, - } - - def _make_new_cube(cube): """Make a new cube with an extra dimension for result of spell count.""" new_shape = (*cube.shape, 4) @@ -1301,200 +1094,6 @@ def _make_new_cube(cube): return new_cube -def _plot_multi_model_maps( - cfg, - all_drought_mean, - lats_lons, - input_filenames, - tstype, -): - """Prepare plots for multi-model mean.""" - data_dict = { - "latitude": lats_lons[0], - "longitude": lats_lons[1], - "model_kind": tstype, - } - if tstype == "Difference": - # RCP85 Percentage difference - data_dict.update({ - "data": all_drought_mean[:, :, 0], - "var": "diffnumber", - "datasetname": "Percentage", - "drought_char": "Number of drought events", - "unit": "%", - "filename": "Percentage_difference_of_No_of_Events", - "drought_numbers_level": np.arange(-100, 110, 10), - }) - plot_map_spei_multi( - cfg, - data_dict, - input_filenames, - colormap="rainbow", - ) - - data_dict.update({ - "data": all_drought_mean[:, :, 1], - "var": "diffduration", - "drought_char": "Duration of drought events", - "filename": "Percentage_difference_of_Dur_of_Events", - "drought_numbers_level": np.arange(-100, 110, 10), - }) - plot_map_spei_multi( - cfg, - data_dict, - input_filenames, - colormap="rainbow", - ) - - data_dict.update({ - "data": all_drought_mean[:, :, 2], - "var": "diffseverity", - "drought_char": "Severity Index of drought events", - "filename": "Percentage_difference_of_Sev_of_Events", - "drought_numbers_level": np.arange(-50, 60, 10), - }) - plot_map_spei_multi( - cfg, - data_dict, - input_filenames, - colormap="rainbow", - ) - - data_dict.update({ - "data": all_drought_mean[:, :, 3], - "var": "diff" + (cfg["indexname"]).lower(), - "drought_char": "Average " - + cfg["indexname"] - + " of drought events", - "filename": "Percentage_difference_of_Avr_of_Events", - "drought_numbers_level": np.arange(-50, 60, 10), - }) - plot_map_spei_multi( - cfg, - data_dict, - input_filenames, - colormap="rainbow", - ) - else: - data_dict.update({ - "data": all_drought_mean[:, :, 0], - "var": "frequency", - "unit": "year-1", - "drought_char": "Number of drought events per year", - "filename": tstype + "_No_of_Events_per_year", - "drought_numbers_level": np.arange(0, 0.4, 0.05), - }) - if tstype == "Observations": - data_dict["datasetname"] = "Mean" - else: - data_dict["datasetname"] = "MultiModelMean" - plot_map_spei_multi( - cfg, - data_dict, - input_filenames, - colormap="gnuplot", - ) - - data_dict.update({ - "data": all_drought_mean[:, :, 1], - "var": "duration", - "unit": "month", - "drought_char": "Duration of drought events [month]", - "filename": tstype + "_Dur_of_Events", - "drought_numbers_level": np.arange(0, 6, 1), - }) - plot_map_spei_multi( - cfg, - data_dict, - input_filenames, - colormap="gnuplot", - ) - - data_dict.update({ - "data": all_drought_mean[:, :, 2], - "var": "severity", - "unit": "1", - "drought_char": "Severity Index of drought events", - "filename": tstype + "_Sev_index_of_Events", - "drought_numbers_level": np.arange(0, 9, 1), - }) - plot_map_spei_multi( - cfg, - data_dict, - input_filenames, - colormap="gnuplot", - ) - namehlp = "Average " + cfg["indexname"] + " of drought events" - namehlp2 = tstype + "_Average_" + cfg["indexname"] + "_of_Events" - data_dict.update({ - "data": all_drought_mean[:, :, 3], - "var": (cfg["indexname"]).lower(), - "unit": "1", - "drought_char": namehlp, - "filename": namehlp2, - "drought_numbers_level": np.arange(-2.8, -1.8, 0.2), - }) - plot_map_spei_multi( - cfg, - data_dict, - input_filenames, - colormap="gnuplot", - ) - - -def _plot_single_maps(cfg, cube2, drought_show, tstype, input_filenames): - """Plot map of drought characteristics for individual models and times.""" - cube2.data = drought_show.data[:, :, 0] - name_dict = { - "add_to_filename": tstype + "_No_of_Events_per_year", - "name": tstype + " Number of drought events per year", - "var": "frequency", - "unit": "year-1", - "drought_char": "Number of drought events per year", - "input_filenames": input_filenames, - } - plot_map_spei(cfg, cube2, np.arange(0, 0.4, 0.05), name_dict) - - # plot the average duration of drought events - cube2.data = drought_show.data[:, :, 1] - name_dict.update({ - "add_to_filename": tstype + "_Dur_of_Events", - "name": tstype + " Duration of drought events(month)", - "var": "duration", - "unit": "month", - "drought_char": "Number of drought events per year", - "input_filenames": input_filenames, - }) - plot_map_spei(cfg, cube2, np.arange(0, 6, 1), name_dict) - - # plot the average severity index of drought events - cube2.data = drought_show.data[:, :, 2] - name_dict.update({ - "add_to_filename": tstype + "_Sev_index_of_Events", - "name": tstype + " Severity Index of drought events", - "var": "severity", - "unit": "1", - "drought_char": "Number of drought events per year", - "input_filenames": input_filenames, - }) - plot_map_spei(cfg, cube2, np.arange(0, 9, 1), name_dict) - - # plot the average spei of drought events - cube2.data = drought_show.data[:, :, 3] - - namehlp = tstype + "_Avr_" + cfg["indexname"] + "_of_Events" - namehlp2 = tstype + "_Average_" + cfg["indexname"] + "_of_Events" - name_dict.update({ - "add_to_filename": namehlp, - "name": namehlp2, - "var": "severity", - "unit": "1", - "drought_char": "Number of drought events per year", - "input_filenames": input_filenames, - }) - plot_map_spei(cfg, cube2, np.arange(-2.8, -1.8, 0.2), name_dict) - - def runs_of_ones_array_spei(bits, spei) -> list: """Set 1 at beginning ond -1 at the end of events.""" # make sure all runs of ones are well-bounded @@ -1505,8 +1104,7 @@ def runs_of_ones_array_spei(bits, spei) -> list: (run_ends,) = np.where(difs < 0) spei_sum = np.full(len(run_starts), 0.5) for iii, indexs in enumerate(run_starts): - spei_sum[iii] = np.sum(spei[indexs: run_ends[iii]]) - + spei_sum[iii] = np.sum(spei[indexs : run_ends[iii]]) return [run_ends - run_starts, spei_sum] @@ -1518,21 +1116,17 @@ def count_spells(data, threshold, axis) -> np.ndarray: data = data[:, :, 0, :] if axis > 2: axis = axis - 1 - listshape = [] inoax = [] for iii, ishape in enumerate(data.shape): if iii != axis: listshape.append(ishape) inoax.append(iii) - listshape.append(4) return_var = np.zeros(tuple(listshape)) - for ilat in range(listshape[0]): for ilon in range(listshape[1]): data_help = _get_data_hlp(axis, data, ilat, ilon) - if data_help.count() == 0: return_var[ilat, ilon, 0] = data_help[0] return_var[ilat, ilon, 1] = data_help[0] @@ -1544,7 +1138,6 @@ def count_spells(data, threshold, axis) -> np.ndarray: data_hits, data_help, ) - return_var[ilat, ilon, 0] = np.count_nonzero(events) return_var[ilat, ilon, 1] = np.mean(events) return_var[ilat, ilon, 2] = np.mean( @@ -1552,7 +1145,6 @@ def count_spells(data, threshold, axis) -> np.ndarray: / (np.mean(data_help[data_hits]) * np.mean(events)), ) return_var[ilat, ilon, 3] = np.mean(spei_sum / events) - return return_var @@ -1563,219 +1155,3 @@ def get_latlon_index(coords, lim1, lim2) -> np.ndarray: np.absolute(coords - (lim2 + lim1) / 2.0) <= (lim2 - lim1) / 2.0, ) )[0] - - -def plot_map_spei_multi( - cfg, - data_dict, - input_filenames, - colormap="jet", -) -> None: - """Plot contour maps for multi model mean.""" - spei = np.ma.array(data_dict["data"], mask=np.isnan(data_dict["data"])) - - # Get latitudes and longitudes from cube - lons = data_dict["longitude"] - if max(lons) > 180.0: - lons = np.where(lons > 180, lons - 360, lons) - # sort the array - index = np.argsort(lons) - lons = lons[index] - spei = spei[np.ix_(range(data_dict["latitude"].size), index)] - - # Plot data - # Create figure and axes instances - subplot_kw = {"projection": cart.PlateCarree(central_longitude=0.0)} - fig, axx = plt.subplots(figsize=(6.5, 4), subplot_kw=subplot_kw) - axx.set_extent( - [-180.0, 180.0, -90.0, 90.0], - cart.PlateCarree(central_longitude=0.0), - ) - - # Draw filled contours - cnplot = plt.contourf( - lons, - data_dict["latitude"], - spei, - data_dict["drought_numbers_level"], - transform=cart.PlateCarree(central_longitude=0.0), - cmap=colormap, - extend="both", - corner_mask=False, - ) - # Draw coastlines - axx.coastlines() - - # Add colorbar - cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation="horizontal") - - # Add colorbar title string - if data_dict["model_kind"] == "Difference": - cbar.set_label( - data_dict["model_kind"] + " " + data_dict["drought_char"] + " [%]", - ) - else: - cbar.set_label( - data_dict["model_kind"] + " " + data_dict["drought_char"], - ) - - # Set labels and title to each plot - axx.set_xlabel("Longitude") - axx.set_ylabel("Latitude") - axx.set_title( - f"{data_dict['datasetname']} {data_dict['model_kind']} " - f"{data_dict['drought_char']}", - ) - - # Sets number and distance of x ticks - axx.set_xticks(np.linspace(-180, 180, 7)) - # Sets strings for x ticks - axx.set_xticklabels([ - "180°W", - "120°W", - "60°W", - "0°", - "60°E", - "120°E", - "180°E", - ]) - # Sets number and distance of y ticks - axx.set_yticks(np.linspace(-90, 90, 7)) - # Sets strings for y ticks - axx.set_yticklabels(["90°S", "60°S", "30°S", "0°", "30°N", "60°N", "90°N"]) - - fig.tight_layout() - fig.savefig( - get_plot_filename( - cfg["indexname"] - + "_map" - + data_dict["filename"] - + "_" - + data_dict["datasetname"], - cfg, - ), - dpi=300, - ) - plt.close() - _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames) - - -def plot_map_spei(cfg, cube, levels, name_dict) -> None: - """Plot contour map.""" - mask = np.isnan(cube.data) - spei = np.ma.array(cube.data, mask=mask) - np.ma.masked_less_equal(spei, 0) - # Get latitudes and longitudes from cube - name_dict.update({"latitude": cube.coord("latitude").points}) - lons = cube.coord("longitude").points - lons = np.where(lons > 180, lons - 360, lons) - # sort the array - index = np.argsort(lons) - lons = lons[index] - name_dict.update({"longitude": lons}) - spei = spei[np.ix_(range(len(cube.coord("latitude").points)), index)] - # Get data set name from cube - try: - dataset_name = cube.metadata.attributes["model_id"] - except KeyError: - try: - dataset_name = cube.metadata.attributes["source_id"] - except KeyError: - dataset_name = "Observations" - # Plot data - # Create figure and axes instances - subplot_kw = {"projection": cart.PlateCarree(central_longitude=0.0)} - fig, axx = plt.subplots(figsize=(8, 4), subplot_kw=subplot_kw) - axx.set_extent( - [-180.0, 180.0, -90.0, 90.0], - cart.PlateCarree(central_longitude=0.0), - ) - # Draw filled contours - cnplot = plt.contourf( - lons, - cube.coord("latitude").points, - spei, - levels, - transform=cart.PlateCarree(central_longitude=0.0), - cmap="gnuplot", - extend="both", - corner_mask=False, - ) - axx.coastlines() - cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation="horizontal") - cbar.set_label(name_dict["name"]) - axx.set_xlabel("Longitude") - axx.set_ylabel("Latitude") - axx.set_title(dataset_name + " " + name_dict["name"]) - - # Set up x and y ticks - axx.set_xticks(np.linspace(-180, 180, 7)) - axx.set_xticklabels( - ["180°W", "120°W", "60°W", "0°", "60°E", "120°E", "180°E"], - ) - axx.set_yticks(np.linspace(-90, 90, 7)) - axx.set_yticklabels(["90°S", "60°S", "30°S", "0°", "30°N", "60°N", "90°N"]) - fig.tight_layout() - basename = ( - f"{cfg['indexname']}_map_{name_dict['add_to_filename']}_{dataset_name}" - ) - fig.savefig(get_plot_filename(basename, cfg), dpi=300) - plt.close() - _provenance_map_spei(cfg, name_dict, spei, dataset_name) - - -def plot_time_series_spei(cfg, cube, filename, add_to_filename="") -> None: - """Plot time series.""" - # SPEI vector to plot - spei = cube.data - # Get time from cube - time = cube.coord("time").points - # Adjust (ncdf) time to the format matplotlib expects - add_m_delta = mdates.datestr2num("1850-01-01 00:00:00") - time = time + add_m_delta - - # Get data set name from cube - try: - dataset_name = cube.metadata.attributes["model_id"] - except KeyError: - try: - dataset_name = cube.metadata.attributes["source_id"] - except KeyError: - dataset_name = "Observations" - data_dict = { - "data": spei, - "time": time, - "var": cfg["indexname"], - "dataset_name": dataset_name, - "unit": "1", - "filename": filename, - "area": add_to_filename, - } - fig, axx = plt.subplots(figsize=(16, 4)) - axx.plot_date( - time, - spei, - "-", - tz=None, - xdate=True, - ydate=False, - color="r", - linewidth=4.0, - linestyle="-", - alpha=1.0, - marker="x", - ) - axx.axhline(y=-2, color="k") - axx.set_xlabel("Time") - axx.set_ylabel(cfg["indexname"]) - axx.set_title( - f"Mean {cfg['indexname']} {data_dict['dataset_name']} " - f"{data_dict['area']}", - ) - axx.set_ylim(-4.0, 4.0) - fig.tight_layout() - basename = f"{cfg['indexname']}_time_series_{data_dict['area']}_" - basename += data_dict["dataset_name"] - fig.savefig(get_plot_filename(basename, cfg), dpi=300) - plt.close() - _provenance_time_series_spei(cfg, data_dict) From 95987d152eb642dccd92ee231eeaae13402c8635 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 28 Feb 2025 11:55:12 +0100 Subject: [PATCH 35/66] some codacy complains fixed --- .../diag_scripts/droughts/collect_drought.py | 56 +-------- esmvaltool/diag_scripts/droughts/diffmap.py | 21 ++-- esmvaltool/diag_scripts/droughts/utils.py | 119 +++++++----------- 3 files changed, 55 insertions(+), 141 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index 3cff0a9adf..3da484863a 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -49,6 +49,8 @@ import logging from pathlib import Path from pprint import pformat +import matplotlib.pyplot as plt +import matplotlib.dates as mdates import cartopy.crs as cart import iris @@ -559,60 +561,6 @@ def plot_map_spei(cfg, cube, levels, name_dict) -> None: _provenance_map_spei(cfg, name_dict, spei, dataset_name) -def plot_time_series_spei(cfg, cube, filename, add_to_filename="") -> None: - """Plot time series.""" - spei = cube.data - time = cube.coord("time").points - # Adjust (ncdf) time to the format matplotlib expects - add_m_delta = mdates.datestr2num("1850-01-01 00:00:00") - time = time + add_m_delta - # Get data set name from cube - try: - dataset_name = cube.metadata.attributes["model_id"] - except KeyError: - try: - dataset_name = cube.metadata.attributes["source_id"] - except KeyError: - dataset_name = "Observations" - data_dict = { - "data": spei, - "time": time, - "var": cfg["indexname"], - "dataset_name": dataset_name, - "unit": "1", - "filename": filename, - "area": add_to_filename, - } - fig, axx = plt.subplots(figsize=(16, 4)) - axx.plot_date( - time, - spei, - "-", - tz=None, - xdate=True, - ydate=False, - color="r", - linewidth=4.0, - linestyle="-", - alpha=1.0, - marker="x", - ) - axx.axhline(y=-2, color="k") - axx.set_xlabel("Time") - axx.set_ylabel(cfg["indexname"]) - axx.set_title( - f"Mean {cfg['indexname']} {data_dict['dataset_name']} " - f"{data_dict['area']}", - ) - axx.set_ylim(-4.0, 4.0) - fig.tight_layout() - basename = f"{cfg['indexname']}_time_series_{data_dict['area']}_" - basename += data_dict["dataset_name"] - fig.savefig(get_plot_filename(basename, cfg), dpi=300) - plt.close() - _provenance_time_series_spei(cfg, data_dict) - - def _plot_models_vs_obs(cfg, cube, mmm, obs, fnames): """Compare drought metrics of multi-model mean to observations.""" latslons = [cube.coord(i).points for i in ["latitude", "longitude"]] diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index 4a78875fd3..cd36cb1e7d 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -101,6 +101,7 @@ def plot_colorbar( mappable: mpl.cm.ScalarMappable | None = None, ) -> None: """Plot colorbar in its own figure for strip_plots.""" + _ = cfg # we might need this in the future fig = plt.figure(figsize=(1.5, 3)) # fixed size axes in fixed size figure cbar_ax = fig.add_axes([0.01, 0.04, 0.2, 0.92]) @@ -218,13 +219,7 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group): if "start_year" in cfg or "end_year" in cfg: log.info("selecting time period") cube = pp.extract_time( - cube, - cfg["start_year"], - 1, - 1, - cfg["end_year"], - 12, - 31, + cube, cfg["start_year"], 1, 1, cfg["end_year"], 12, 31 ) dtime = cfg.get("comparison_period", 10) * 12 cubes = {} @@ -236,13 +231,14 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group): + 1 # count full end year - cfg.get("comparison_period", 10) # decades center to center ) / 10 - if any(m in do_metrics for m in ["first", "last", "diff", "percent"]): + if do_metrics != ["total"]: # anything else needs start/end periods cubes["first"] = cube[0:dtime].collapsed("time", MEAN) cubes["last"] = cube[-dtime:].collapsed("time", MEAN) - if any(m in do_metrics for m in ["diff", "percent"]): + if "diff" in do_metrics or "percent" in do_metrics: cubes["diff"] = cubes["last"] - cubes["first"] cubes["diff"].data /= norm cubes["diff"].units = str(cubes["diff"].units) + " / 10 years" + if "percent" in do_metrics: cubes["percent"] = cubes["diff"] / cubes["first"] * 100 cubes["percent"].units = "% / 10 years" if cfg.get("plot_mmm", True): @@ -258,7 +254,10 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group): if cfg.get("plot_models", True): plot_kwargs = cfg.get("plot_kwargs", {}).copy() apply_plot_kwargs_overwrite( - plot_kwargs, cfg.get("plot_kwargs_overwrite", []), key, group, + plot_kwargs, + cfg.get("plot_kwargs_overwrite", []), + key, + group, ) plot(cfg, meta, cube, basename, kwargs=plot_kwargs) plt.close() @@ -322,7 +321,7 @@ def main(cfg) -> None: # adjust norm for selected time period meta["end_year"] = cfg.get("end_year", meta["end_year"]) meta["start_year"] = cfg.get("start_year", meta["start_year"]) - calculate_diff(cfg, meta, mm_data, output, group, norm) + calculate_diff(cfg, meta, mm_data, output, group) do_mmm = cfg.get("plot_mmm", True) or cfg.get("save_mmm", True) if do_mmm and len(metas) > 1: calculate_mmm(cfg, metas[0], mm_data, output, group) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 6d1479c7cc..a4e5ece006 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -63,19 +63,19 @@ } HEX_POSITIONS = { - "NWN": [2, 0], "NEN": [4, 0], "GIC": [6.5, -0.5], "NEU": [14, 0], - "RAR": [20, 0], "WNA": [1, 1], "CNA": [3, 1], "ENA": [5, 1], - "WCE": [13, 1], "EEU": [15, 1], "WSB": [17, 1], "ESB": [19, 1], - "RFE": [21, 1], "NCA": [2, 2], "MED": [14, 2], "WCA": [16, 2], - "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], - # "CAR": [5, 3], - "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], - # "PAC": [27.5, 3.3], - "NWS": [6, 4], "NSA": [8, 4], "WAF": [12, 4], "CAF": [14, 4], - "NEAF": [16, 4], "NAU": [24.5, 4.3], "SAM": [7, 5], "NES": [9, 5], - "WSAF": [13, 5], "SEAF": [15, 5], "MDG": [17.5, 5.3], - "CAU": [23.5, 5.3], "EAU": [25.5, 5.3], "SWS": [6, 6], "SES": [8, 6], - "ESAF": [14, 6], "SAU": [24.5, 6.3], "NZ": [27, 6.5], "SSA": [7, 7], + "NWN": [2, 0], "NEN": [4, 0], "GIC": [6.5, -0.5], "NEU": [14, 0], + "RAR": [20, 0], "WNA": [1, 1], "CNA": [3, 1], "ENA": [5, 1], + "WCE": [13, 1], "EEU": [15, 1], "WSB": [17, 1], "ESB": [19, 1], + "RFE": [21, 1], "NCA": [2, 2], "MED": [14, 2], "WCA": [16, 2], + "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], + # "CAR": [5, 3], + "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], + # "PAC": [27.5, 3.3], + "NWS": [6, 4], "NSA": [8, 4], "WAF": [12, 4], "CAF": [14, 4], + "NEAF": [16, 4], "NAU": [24.5, 4.3], "SAM": [7, 5], "NES": [9, 5], + "WSAF": [13, 5], "SEAF": [15, 5], "MDG": [17.5, 5.3], + "CAU": [23.5, 5.3], "EAU": [25.5, 5.3], "SWS": [6, 6], "SES": [8, 6], + "ESAF": [14, 6], "SAU": [24.5, 6.3], "NZ": [27, 6.5], "SSA": [7, 7], } INDEX_META = { @@ -126,11 +126,11 @@ def merge_list_cube( Parameters ---------- - cube_list : list + cube_list: list List or iterable of cubes with the same coordinates. - aux_name : str, optional + aux_name: str, optional Name of the new auxiliary coordinate. Defaults to "dataset". - equalize : bool, optional + equalize: bool, optional Drops differences in attributes, otherwise raises an error. Defaults to True. @@ -165,17 +165,17 @@ def fold_meta( Parameters ---------- - cfg : dict + cfg: dict Plot specific configuration with cfg_keys on root level. - meta : list + meta: list Full meta data including ancestor files. - cfg_keys : list, optional + cfg_keys: list, optional Data constraints as config entries used for product. Defaults to ["locations", "intervals"]. - meta_keys : list, optional + meta_keys: list, optional Keys for each meta used for product, short_name added automatically. Defaults to ["dataset", "exp"]. - variables : list, optional + variables: list, optional Variables to be used. Defaults to None. Returns @@ -280,7 +280,7 @@ def sort_cube(cube: Cube, coord: str = "longitude") -> Cube: return cube[tuple(index)] -def fix_longitude(cube: Cube) -> Cube: +def fix_longitude(cube: Cube, coord="longitude") -> Cube: """Return a cube with 0 centered longitude coords. updating the longitude coord and sorting the data accordingly @@ -288,31 +288,20 @@ def fix_longitude(cube: Cube) -> Cube: # make sure coords are -180 to 180 fixed_lons = [ lon if lon < 180 else lon - 360 - for lon in cube.coord("longitude").points + for lon in cube.coord(coord).points ] - try: - cube.add_aux_coord( - iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), - 2, - ) - except ValueError: - log.warning("TODO: hardcoded dimensions in ut.fix_longitude") - cube.add_aux_coord( - iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), - 1, - ) - # sort data and fixed coordinates + lon_dim = cube.coord_dims(cube.coord(coord))[0] + cube.add_aux_coord( + iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), lon_dim, + ) cube = sort_cube(cube, coord="fixed_lon") - # set new coordinates as dimcoords + # set new coordinates as dimcoords, add new dim and remove old and aux new_lon = cube.coord("fixed_lon") new_lon_dims = cube.coord_dims(new_lon) - # Create a new coordinate which is a DimCoord. - # The var name becomes the dim name longitude = iris.coords.DimCoord.from_coord(new_lon) - longitude.rename("longitude") - # Remove the AuxCoord, old longitude and add the new DimCoord. + longitude.rename(coord) cube.remove_coord(new_lon) - cube.remove_coord(cube.coord("longitude")) + cube.remove_coord(cube.coord(coord)) cube.add_dim_coord(longitude, new_lon_dims) return cube @@ -363,15 +352,7 @@ def date_tick_layout( label: str = "Time", years: int | None = 1, ) -> None: - """Update a time series figure to use date/year ticks and grid. - - :param fig: figure that will be updated - :param ax: ax to set ticks/labels/limits on - :param dates: optional, to set limits - :param label: ax label - :param years: tick every x years, if None auto formatter is used - :return: nothing, updates figure in place - """ + """Update a time series figure to use date/year ticks and grid.""" axes.set_xlabel(label) if dates is not None: datemin = np.datetime64(dates[0], "Y") @@ -453,11 +434,7 @@ def get_scenarios(meta, **kwargs) -> list: def add_ancestor_input(cfg: dict) -> None: - """Read ancestors settings.yml and add it's input_data to this config. - - TODO: make sure it don't break for non ancestor scripts - TODO: recursive? (optional) - """ + """Read ancestors settings.yml and add it's input_data to this config.""" log.info("add ancestors for %s", cfg[n.INPUT_FILES]) for input_file in cfg[n.INPUT_FILES]: cfg_anc_file = ( @@ -482,12 +459,8 @@ def quick_load(cfg: dict, context: dict, *, strict=True) -> Cube: raises an error (if strict) or a warning for multiple matches. """ meta = cfg["input_data"].values() - var_meta = select_metadata(meta, **context) - if len(var_meta) != 1: - if strict: - raise ValueError("Wrong number of meta data found.") - log.warning("warning meta data missmatch") - return iris.load_cube(var_meta[0]["filename"]) + var_meta = select_single_meta(meta, strict=strict, **context) + return iris.load_cube(var_meta["filename"]) def smooth(dat, window=32, mode="same") -> np.ndarray: @@ -508,7 +481,7 @@ def smooth(dat, window=32, mode="same") -> np.ndarray: def get_basename(cfg, meta, prefix=None, suffix=None) -> str: """Return a formatted basename for a diagnostic file.""" _ = cfg # we might need this in the future. dont tell codacy! - formats = { # TODO: load this from config-developer.yml? + formats = { "CMIP6": CMIP6_FNAME, "OBS": OBS_FNAME, } @@ -606,13 +579,13 @@ def get_plot_fname( Parameters ---------- - cfg: dict + cfg : dict Dictionary with diagnostic configuration. - basename: str + basename : str The basename of the file. - meta: dict, optional + meta : dict, optional Metadata to format the basename. If None, empty dict is used. - replace: dict + replace : dict Dictionary with strings to replace in the basename. If None, empty dict is used. @@ -637,7 +610,7 @@ def slice_cube_interval(cube: Cube, interval: list) -> Cube: For 3D cubes time needs to be first dim. """ if isinstance(interval[0], int) and isinstance(interval[1], int): - return cube[interval[0] : interval[1], :, :] + return cube[interval[0]: interval[1], :, :] dt_start = dt.datetime.strptime(interval[0], "%Y-%m") dt_end = dt.datetime.strptime(interval[1], "%Y-%m") time = cube.coord("time") @@ -901,11 +874,9 @@ def remove_attributes( def font_color(background: str | tuple | float) -> str: """Black or white depending on greyscale of the background. - Can be used to make text more readable on a colored background. - Parameters ---------- - bacgkround: str + bacgkround : str, tuple, or float color as string (grayscale value, name, hex) or tuple (rgb, rgba) """ if sum(mpl.colors.to_rgb(background)) > 1.5: @@ -930,11 +901,7 @@ def get_time_range(cube: Cube) -> dict: def guess_experiment(meta: dict) -> None: - """Guess missing 'exp' in metadata from filename. - - TODO: This is a workaround for incomplete meta data. - fix this in ancestor diagnostics rather than use this function. - """ + """Guess missing 'exp' in metadata from filename.""" exps = ["historical", "ssp126", "ssp245", "ssp370", "ssp585"] for exp in exps: if exp in meta["filename"]: @@ -1104,7 +1071,7 @@ def runs_of_ones_array_spei(bits, spei) -> list: (run_ends,) = np.where(difs < 0) spei_sum = np.full(len(run_starts), 0.5) for iii, indexs in enumerate(run_starts): - spei_sum[iii] = np.sum(spei[indexs : run_ends[iii]]) + spei_sum[iii] = np.sum(spei[indexs: run_ends[iii]]) return [run_ends - run_starts, spei_sum] From 7d7552893a58887e59d67250035022d48ea67b9e Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 28 Feb 2025 12:26:11 +0100 Subject: [PATCH 36/66] do extra calculation to reduce code complexity --- .../diag_scripts/droughts/collect_drought.py | 1 - esmvaltool/diag_scripts/droughts/diffmap.py | 21 ++++++++----------- esmvaltool/diag_scripts/droughts/utils.py | 4 ++-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index 3da484863a..36bfcacebd 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -50,7 +50,6 @@ from pathlib import Path from pprint import pformat import matplotlib.pyplot as plt -import matplotlib.dates as mdates import cartopy.crs as cart import iris diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index cd36cb1e7d..15588d43c4 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -207,8 +207,8 @@ def apply_plot_kwargs_overwrite( def calculate_diff(cfg, meta, mm_data, output_meta, group): """Absolute difference between first and last years of a cube. - Calculates the absolut difference between the first and last period of - a cube. Writing data to mm and plotting each dataset depends on cfg. + Calculates the absolut and relative difference between the first and last + period of a cube. Write data to mm and optionally plot each dataset. """ cube = iris.load_cube(meta["filename"]) if meta["short_name"] in cfg.get("convert_units", {}): @@ -231,16 +231,13 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group): + 1 # count full end year - cfg.get("comparison_period", 10) # decades center to center ) / 10 - if do_metrics != ["total"]: # anything else needs start/end periods - cubes["first"] = cube[0:dtime].collapsed("time", MEAN) - cubes["last"] = cube[-dtime:].collapsed("time", MEAN) - if "diff" in do_metrics or "percent" in do_metrics: - cubes["diff"] = cubes["last"] - cubes["first"] - cubes["diff"].data /= norm - cubes["diff"].units = str(cubes["diff"].units) + " / 10 years" - if "percent" in do_metrics: - cubes["percent"] = cubes["diff"] / cubes["first"] * 100 - cubes["percent"].units = "% / 10 years" + cubes["first"] = cube[0:dtime].collapsed("time", MEAN) + cubes["last"] = cube[-dtime:].collapsed("time", MEAN) + cubes["diff"] = cubes["last"] - cubes["first"] + cubes["diff"].data /= norm + cubes["diff"].units = str(cubes["diff"].units) + " / 10 years" + cubes["percent"] = cubes["diff"] / cubes["first"] * 100 + cubes["percent"].units = "% / 10 years" if cfg.get("plot_mmm", True): for key in do_metrics: mm_data[key].append(cubes[key]) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index a4e5ece006..97af70b761 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -872,11 +872,11 @@ def remove_attributes( def font_color(background: str | tuple | float) -> str: - """Black or white depending on greyscale of the background. + """Return black or white depending on backgrounds greyscale. Parameters ---------- - bacgkround : str, tuple, or float + background : str, tuple, or float color as string (grayscale value, name, hex) or tuple (rgb, rgba) """ if sum(mpl.colors.to_rgb(background)) > 1.5: From a5ff68094daf2f05e85607a0bc5a2238b25cd197 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 28 Feb 2025 14:24:12 +0100 Subject: [PATCH 37/66] add filter argument to select input data from recipe --- esmvaltool/diag_scripts/droughts/diffmap.py | 33 +++++++++++++++++---- esmvaltool/recipes/droughts/recipe_spei.yml | 1 - 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index 15588d43c4..3f5b17c9bd 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -1,4 +1,4 @@ -"""Creates a difference map for any given drought index. +"""Plot relative and absolute differences between two time intervals. A global map is plotted for each dataset with an index (must be unique). The map shows the difference of the first and last N years @@ -54,6 +54,13 @@ "diff") the mean over two comparison periods ("first" and "last") is calculated. The "total" periods mean can be calculated and plotted as well. By default ["first", "last", "diff", "total", "percent"] +filters: dict, or list, optional + Filter for metadata keys to select datasets. Only datasets with matching + values will be processed. This can be usefull, if ancestors or preprocessed + data is abailable, that should not be processed by the diagnostic. + If a list of dicts is given, all datasets matching any of the filters will + be considered. + By default None. """ from __future__ import annotations @@ -304,14 +311,28 @@ def set_defaults(cfg: dict) -> None: cfg["plot_kwargs_overwrite"].extend(defaults["plot_kwargs_overwrite"]) +def filter_metas(metas: list, filters: dict|list) -> list: + """Filter metas by filter dicts.""" + if isinstance(filters, dict): + filters = [filters] + filtered = {} + for selection in filters: + for meta in e.select_metadata(metas, **selection): + filtered[meta["filename"]] = meta # unique + return list(filtered.values()) + + def main(cfg) -> None: """Execute Diagnostic.""" set_defaults(cfg) - groups = e.group_metadata(cfg["input_data"].values(), cfg["group_by"]) + metas = cfg["input_data"].values() + if cfg.get("filters") is not None: + metas = filter_metas(metas, cfg["filters"]) + groups = e.group_metadata(metas, cfg["group_by"]) output = {} - for group, metas in groups.items(): + for group, g_metas in groups.items(): mm_data = defaultdict(list) - for meta in metas: + for meta in g_metas: ut.guess_experiment(meta) # TODO: add in SPEI.R instead if "end_year" not in meta: meta.update(ut.get_time_range(meta["filename"])) @@ -320,8 +341,8 @@ def main(cfg) -> None: meta["start_year"] = cfg.get("start_year", meta["start_year"]) calculate_diff(cfg, meta, mm_data, output, group) do_mmm = cfg.get("plot_mmm", True) or cfg.get("save_mmm", True) - if do_mmm and len(metas) > 1: - calculate_mmm(cfg, metas[0], mm_data, output, group) + if do_mmm and len(g_metas) > 1: + calculate_mmm(cfg, g_metas[0], mm_data, output, group) ut.save_metadata(cfg, output) diff --git a/esmvaltool/recipes/droughts/recipe_spei.yml b/esmvaltool/recipes/droughts/recipe_spei.yml index 43a7c2d631..5cf0e8471a 100644 --- a/esmvaltool/recipes/droughts/recipe_spei.yml +++ b/esmvaltool/recipes/droughts/recipe_spei.yml @@ -60,7 +60,6 @@ diagnostics: ancestors: [pr] distribution: Gamma smooth_month: 6 - write_coeffs: True pet: pet_type: "Hargreaves" # "Penman_clt" script: droughts/pet.R From 9d0ae926c5b5a6134853f1dec992ae884a33ceab Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 3 Mar 2025 11:54:25 +0100 Subject: [PATCH 38/66] add whitespace --- esmvaltool/diag_scripts/droughts/diffmap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index 3f5b17c9bd..e302f9b1da 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -311,7 +311,7 @@ def set_defaults(cfg: dict) -> None: cfg["plot_kwargs_overwrite"].extend(defaults["plot_kwargs_overwrite"]) -def filter_metas(metas: list, filters: dict|list) -> list: +def filter_metas(metas: list, filters: dict | list) -> list: """Filter metas by filter dicts.""" if isinstance(filters, dict): filters = [filters] From 1de779012c81c6ddcae6e277afa2265d16ad00a9 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 3 Mar 2025 12:17:04 +0100 Subject: [PATCH 39/66] make utils.py less than 100 lines --- esmvaltool/diag_scripts/droughts/constants.py | 74 +++++++ esmvaltool/diag_scripts/droughts/utils.py | 190 +----------------- 2 files changed, 79 insertions(+), 185 deletions(-) create mode 100644 esmvaltool/diag_scripts/droughts/constants.py diff --git a/esmvaltool/diag_scripts/droughts/constants.py b/esmvaltool/diag_scripts/droughts/constants.py new file mode 100644 index 0000000000..5ea3e28e63 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/constants.py @@ -0,0 +1,74 @@ +"""Constants that might be usefull by any drought diagnostic.""" +from iris.coords import AuxCoord + +# fmt: off +DENSITY = AuxCoord(1000, long_name="density", units="kg m-3") + +FNAME_FORMAT = "{project}_{reference_dataset}_{mip}_{exp}_{ensemble}_" +"{short_name}_{start_year}-{end_year}" +CMIP6_FNAME = "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_" +"{grid}_{start_year}-{end_year}" +OBS_FNAME = "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_" +"{start_year}-{end_year}" + +CONTINENTAL_REGIONS = { + "Global": ["GLO"], # global + "North America": ["GIC", "NWN", "NEN", "WNA", "CNA", "ENA"], + "Central America": ["NCA", "SCA", "CAR"], + "Southern America": ["NWS", "NSA", "NES", "SAM", "SWS", "SES", "SSA"], + "Europe": ["NEU", "WCE", "EEU", "MED"], + "Africa": ["SAH", "WAF", "CAF", "NEAF", "SEAF", "WSAF", "ESAF", "MDG"], + "Asia": ["RAR", "WSB", "ESB", "RFE", "WCA", "ECA", "TIB", "EAS", "ARP", + "SAS", "SEA"], + "Australia": ["NAU", "CAU", "EAU", "SAU", "NZ", "WAN", "EAN"], +} + +HEX_POSITIONS = { + "NWN": [2, 0], "NEN": [4, 0], "GIC": [6.5, -0.5], "NEU": [14, 0], + "RAR": [20, 0], "WNA": [1, 1], "CNA": [3, 1], "ENA": [5, 1], + "WCE": [13, 1], "EEU": [15, 1], "WSB": [17, 1], "ESB": [19, 1], + "RFE": [21, 1], "NCA": [2, 2], "MED": [14, 2], "WCA": [16, 2], + "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], + # "CAR": [5, 3], + "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], + # "PAC": [27.5, 3.3], + "NWS": [6, 4], "NSA": [8, 4], "WAF": [12, 4], "CAF": [14, 4], + "NEAF": [16, 4], "NAU": [24.5, 4.3], "SAM": [7, 5], "NES": [9, 5], + "WSAF": [13, 5], "SEAF": [15, 5], "MDG": [17.5, 5.3], + "CAU": [23.5, 5.3], "EAU": [25.5, 5.3], "SWS": [6, 6], "SES": [8, 6], + "ESAF": [14, 6], "SAU": [24.5, 6.3], "NZ": [27, 6.5], "SSA": [7, 7], +} + +INDEX_META = { + "CDD": { + "long_name": "Conscutive Dry Days", + "short_name": "CDD", + "units": "days", + "standard_name": "consecutive_dry_days", + }, + "PDSI": { + "long_name": "Palmer Drought Severity Index", + "showrt_name": "PDSI", + "units": "1", + "standard_name": "palmer_drought_severity_index", + }, + "SCPDSI": { + "long_name": "Self-calibrated Palmer Drought Severity Index", + "short_name": "scPDSI", + "units": "1", + "standard_name": "self_calibrated_palmer_drought_severity_index", + }, + "SPI": { + "long_name": "Standardized Precipitation Index", + "short_name": "SPI", + "units": "1", + "standard_name": "standardized_precipitation_index", + }, + "SPEI": { + "long_name": "Standardized Precipitation Evapotranspiration Index", + "short_name": "SPEI", + "units": "1", + "standard_name": "standardized_precipitation_evapotranspiration_index", + }, +} +# fmt: on diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 97af70b761..2702e49e29 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -24,9 +24,13 @@ from cf_units import Unit from esmvalcore import preprocessor as pp from esmvalcore.iris_helpers import date2num -from iris.coords import AuxCoord from iris.cube import Cube, CubeList from iris.util import equalise_attributes +from esmvaltool.diag_scripts.droughts.constants import ( + CMIP6_FNAME, + INDEX_META, + OBS_FNAME, +) import esmvaltool.diag_scripts.shared.names as n from esmvaltool.diag_scripts.shared import ( @@ -40,78 +44,6 @@ log = logging.getLogger(Path(__file__).name) -# fmt: off -DENSITY = AuxCoord(1000, long_name="density", units="kg m-3") - -FNAME_FORMAT = "{project}_{reference_dataset}_{mip}_{exp}_{ensemble}_" -"{short_name}_{start_year}-{end_year}" -CMIP6_FNAME = "{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}_" -"{grid}_{start_year}-{end_year}" -OBS_FNAME = "{project}_{dataset}_{type}_{version}_{mip}_{short_name}_" -"{start_year}-{end_year}" - -CONTINENTAL_REGIONS = { - "Global": ["GLO"], # global - "North America": ["GIC", "NWN", "NEN", "WNA", "CNA", "ENA"], - "Central America": ["NCA", "SCA", "CAR"], - "Southern America": ["NWS", "NSA", "NES", "SAM", "SWS", "SES", "SSA"], - "Europe": ["NEU", "WCE", "EEU", "MED"], - "Africa": ["SAH", "WAF", "CAF", "NEAF", "SEAF", "WSAF", "ESAF", "MDG"], - "Asia": ["RAR", "WSB", "ESB", "RFE", "WCA", "ECA", "TIB", "EAS", "ARP", - "SAS", "SEA"], - "Australia": ["NAU", "CAU", "EAU", "SAU", "NZ", "WAN", "EAN"], -} - -HEX_POSITIONS = { - "NWN": [2, 0], "NEN": [4, 0], "GIC": [6.5, -0.5], "NEU": [14, 0], - "RAR": [20, 0], "WNA": [1, 1], "CNA": [3, 1], "ENA": [5, 1], - "WCE": [13, 1], "EEU": [15, 1], "WSB": [17, 1], "ESB": [19, 1], - "RFE": [21, 1], "NCA": [2, 2], "MED": [14, 2], "WCA": [16, 2], - "ECA": [18, 2], "TIB": [20, 2], "EAS": [22, 2], "SCA": [3, 3], - # "CAR": [5, 3], - "SAH": [13, 3], "ARP": [15, 3], "SAS": [19, 3], "SEA": [23, 3], - # "PAC": [27.5, 3.3], - "NWS": [6, 4], "NSA": [8, 4], "WAF": [12, 4], "CAF": [14, 4], - "NEAF": [16, 4], "NAU": [24.5, 4.3], "SAM": [7, 5], "NES": [9, 5], - "WSAF": [13, 5], "SEAF": [15, 5], "MDG": [17.5, 5.3], - "CAU": [23.5, 5.3], "EAU": [25.5, 5.3], "SWS": [6, 6], "SES": [8, 6], - "ESAF": [14, 6], "SAU": [24.5, 6.3], "NZ": [27, 6.5], "SSA": [7, 7], -} - -INDEX_META = { - "CDD": { - "long_name": "Conscutive Dry Days", - "short_name": "CDD", - "units": "days", - "standard_name": "consecutive_dry_days", - }, - "PDSI": { - "long_name": "Palmer Drought Severity Index", - "showrt_name": "PDSI", - "units": "1", - "standard_name": "palmer_drought_severity_index", - }, - "SCPDSI": { - "long_name": "Self-calibrated Palmer Drought Severity Index", - "short_name": "scPDSI", - "units": "1", - "standard_name": "self_calibrated_palmer_drought_severity_index", - }, - "SPI": { - "long_name": "Standardized Precipitation Index", - "short_name": "SPI", - "units": "1", - "standard_name": "standardized_precipitation_index", - }, - "SPEI": { - "long_name": "Standardized Precipitation Evapotranspiration Index", - "short_name": "SPEI", - "units": "1", - "standard_name": "standardized_precipitation_evapotranspiration_index", - }, -} -# fmt: on - def merge_list_cube( cube_list: list, @@ -672,61 +604,6 @@ def latlon_coords(cube: Cube) -> None: cube.coord("longitude").rename("lon") -def standard_time(cubes: Cube) -> None: - """Make sure all cubes share the same standard time coordinate. - - This function extracts the date information from the cube and - reconstructs the time coordinate, resetting the actual dates to the - 15th of the month or 1st of july for yearly data (consistent with - `regrid_time`), so that there are no mismatches in the time arrays. - It will use reset the calendar to - a default gregorian calendar with unit "days since 1850-01-01". - Might not work for (sub)daily data, because different calendars may have - different number of days in the year. - NOTE: this might be replaced by preprocessor - """ - t_unit = Unit("days since 1850-01-01", calendar="standard") - for cube in cubes: - # Extract date info from cube - coord = cube.coord("time") - years = [p.year for p in coord.units.num2date(coord.points)] - months = [p.month for p in coord.units.num2date(coord.points)] - days = [p.day for p in coord.units.num2date(coord.points)] - # Reconstruct default calendar - if 0 not in np.diff(years): - # yearly data - dates = [dt.datetime(year, 7, 1, 0, 0, 0) for year in years] - elif 0 not in np.diff(months): - # monthly data - dates = [ - dt.datetime(year, month, 15, 0, 0, 0) - for year, month in zip(years, months) - ] - elif 0 not in np.diff(days): - # daily data - dates = [ - dt.datetime(year, month, day, 0, 0, 0) - for year, month, day in zip(years, months, days) - ] - if coord.units != t_unit: - log.warning( - "Multimodel encountered (sub)daily data and inconsistent " - "time units or calendars. Attempting to continue, but " - "might produce unexpected results.", - ) - else: - raise ValueError( - "Multimodel statistics preprocessor currently does not " - "support sub-daily data.", - ) - - # Update the cubes' time coordinate (both point values and the units!) - cube.coord("time").points = date2num(dates, t_unit, coord.dtype) - cube.coord("time").units = t_unit - cube.coord("time").bounds = None - cube.coord("time").guess_bounds() - - def guess_lat_lon_bounds(cube: Cube) -> None: """Guess bounds for latitude and longitude if missing.""" if not cube.coord("latitude").has_bounds(): @@ -745,7 +622,6 @@ def mmm( """Calculate mean and stdev along a cube list over all cubes. Return two (mean and stdev) of same shape - TODO: merge alreadey exist, use that one, mean and std is trivial than. Parameters ---------- @@ -782,11 +658,6 @@ def mmm( return mean, sdev -def get_hex_positions() -> dict: - """Return a dictionary with hexagon positions for AR6 regions.""" - return HEX_POSITIONS - - def regional_stats(cfg, cube, operator="mean") -> dict: """Calculate statistic over AR6 IPCC reference regions.""" _ = cfg # we might need this in the future. dont tell codacy! @@ -807,27 +678,6 @@ def save_metadata(cfg: dict, metadata: dict) -> None: yaml.dump(metadata, wom) -def get_index_meta(index) -> dict: - """Return default meta data for a given index. - - kept for compability. Use INDEX_META dict instead. - """ - return INDEX_META[index] - - -def set_defaults(target: dict, defaults: dict) -> None: - """Apply set_default on target for each entry of a dictionary. - - This checks if a key exists in target, and only if not the keys are set - with values. It does not checks recursively for nested entries. - - NOTE: this might be obsolete since python 3.9, as there are direct - fallback assignments like: `target = defaults | target` - """ - for key in defaults: - target.setdefault(key, defaults[key]) - - def sub_cfg(cfg: dict, plot: str, key: str) -> dict: """Get get merged general and plot type specific kwargs.""" if isinstance(cfg.get(key, {}), dict): @@ -1031,36 +881,6 @@ def cube_to_save_ploted_ts(data_dict: dict) -> Cube: return cube -def _make_new_cube(cube): - """Make a new cube with an extra dimension for result of spell count.""" - new_shape = (*cube.shape, 4) - new_data = iris.util.broadcast_to_shape(cube.data, new_shape, [0, 1, 2]) - new_cube = Cube(new_data) - new_cube.add_dim_coord( - iris.coords.DimCoord(cube.coord("time").points, long_name="time"), - 0, - ) - new_cube.add_dim_coord( - iris.coords.DimCoord( - cube.coord("latitude").points, - long_name="latitude", - ), - 1, - ) - new_cube.add_dim_coord( - iris.coords.DimCoord( - cube.coord("longitude").points, - long_name="longitude", - ), - 2, - ) - new_cube.add_dim_coord( - iris.coords.DimCoord([0, 1, 2, 3], long_name="z"), - 3, - ) - return new_cube - - def runs_of_ones_array_spei(bits, spei) -> list: """Set 1 at beginning ond -1 at the end of events.""" # make sure all runs of ones are well-bounded From f0206a540353d5830ed465f701d1dc49d6830558 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 3 Mar 2025 12:23:14 +0100 Subject: [PATCH 40/66] fix imports --- esmvaltool/diag_scripts/droughts/utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 2702e49e29..7396ad2a08 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -23,16 +23,15 @@ from cartopy.mpl.geoaxes import GeoAxes from cf_units import Unit from esmvalcore import preprocessor as pp -from esmvalcore.iris_helpers import date2num from iris.cube import Cube, CubeList from iris.util import equalise_attributes + +import esmvaltool.diag_scripts.shared.names as n from esmvaltool.diag_scripts.droughts.constants import ( CMIP6_FNAME, INDEX_META, OBS_FNAME, ) - -import esmvaltool.diag_scripts.shared.names as n from esmvaltool.diag_scripts.shared import ( ProvenanceLogger, get_cfg, From 74c819745e776b7f5e51b05c8a41579d6508c657 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 3 Mar 2025 13:25:07 +0100 Subject: [PATCH 41/66] fix imports --- .../diag_scripts/droughts/collect_drought.py | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index 36bfcacebd..07d728e43e 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -49,16 +49,15 @@ import logging from pathlib import Path from pprint import pformat -import matplotlib.pyplot as plt import cartopy.crs as cart import iris +import matplotlib.pyplot as plt import numpy as np from iris.analysis import Aggregator import esmvaltool.diag_scripts.shared as e from esmvaltool.diag_scripts.droughts.utils import ( - _make_new_cube, count_spells, create_cube_from_data, ) @@ -581,6 +580,36 @@ def _plot_future_vs_past(cfg, cube, slices, fnames): _plot_multi_model_maps(cfg, slices[tstype], latslons, fnames, tstype) +def _make_new_cube(cube): + """Make a new cube with an extra dimension for result of spell count.""" + new_shape = (*cube.shape, 4) + new_data = iris.util.broadcast_to_shape(cube.data, new_shape, [0, 1, 2]) + new_cube = iris.cube.Cube(new_data) + new_cube.add_dim_coord( + iris.coords.DimCoord(cube.coord("time").points, long_name="time"), + 0, + ) + new_cube.add_dim_coord( + iris.coords.DimCoord( + cube.coord("latitude").points, + long_name="latitude", + ), + 1, + ) + new_cube.add_dim_coord( + iris.coords.DimCoord( + cube.coord("longitude").points, + long_name="longitude", + ), + 2, + ) + new_cube.add_dim_coord( + iris.coords.DimCoord([0, 1, 2, 3], long_name="z"), + 3, + ) + return new_cube + + def _set_tscube(cfg, cube, time, tstype): """Time slice from a cube with start/end given by cfg.""" if tstype == "Future": From b605d598e8825fb7e2fba657b1f7286e4b39551b Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 3 Mar 2025 13:45:36 +0100 Subject: [PATCH 42/66] add diffmap diagnostic to docs --- doc/sphinx/source/api/esmvaltool.diag_scripts.droughts.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts.rst b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts.rst index 012c851101..05850daae0 100644 --- a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts.rst +++ b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts.rst @@ -16,3 +16,4 @@ Diagnostic scripts :maxdepth: 1 esmvaltool.diag_scripts.droughts/collect_drought + esmvaltool.diag_scripts.droughts/diffmap From 438acc8f85fc969e9758b7dadc67888da9333b55 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 3 Mar 2025 15:56:28 +0100 Subject: [PATCH 43/66] fix doc titles, sort utils, add init --- .../diffmap.rst | 10 + esmvaltool/diag_scripts/droughts/__init__.py | 0 esmvaltool/diag_scripts/droughts/utils.py | 1189 ++++++++--------- 3 files changed, 597 insertions(+), 602 deletions(-) create mode 100644 doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst create mode 100644 esmvaltool/diag_scripts/droughts/__init__.py diff --git a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst new file mode 100644 index 0000000000..10bdcecf8b --- /dev/null +++ b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst @@ -0,0 +1,10 @@ + +.. _api.esmvaltool.diag_scripts.droughts.diffmap: + +Calculation and plotting of drought metrics following Martin (2018) +=================================================================== + +.. automodule:: esmvaltool.diag_scripts.droughts.collect_drought + :no-members: + :no-inherited-members: + :no-show-inheritance: \ No newline at end of file diff --git a/esmvaltool/diag_scripts/droughts/__init__.py b/esmvaltool/diag_scripts/droughts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 7396ad2a08..213667f456 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -44,6 +44,114 @@ log = logging.getLogger(Path(__file__).name) +### GENERAL HELPER ### + +def mkplotdir(cfg: dict, dname: str | Path) -> None: + """Create a sub directory for plots if it does not exist.""" + new_dir = Path(cfg["plot_dir"] / dname) + if not new_dir.is_dir(): + Path.mkdir(new_dir) + + +def quick_save(cube: Cube, name: str, cfg: dict) -> None: + """Simply save cube to netcdf file without additional information.""" + if cfg.get("write_netcdf", True): + diag_file = get_diagnostic_filename(name, cfg) + log.info("quick save %s", diag_file) + iris.save(cube, target=diag_file) + + +def quick_load(cfg: dict, context: dict, *, strict=True) -> Cube: + """Load input files from config wich matches the selection. + + Select, load and return the first match. + raises an error (if strict) or a warning for multiple matches. + """ + meta = cfg["input_data"].values() + var_meta = select_single_meta(meta, strict=strict, **context) + return iris.load_cube(var_meta["filename"]) + + +def get_plot_fname( + cfg: dict, + basename, + meta: dict | None = None, + replace: dict | None = None, +) -> str: + """Get a valid path for saving a diagnostic plot. + + This is an alternative to shared.get_diagnostic_filename. + It uses cfg as first argument and accept metadata to format the basename. + + Parameters + ---------- + cfg : dict + Dictionary with diagnostic configuration. + basename : str + The basename of the file. + meta : dict, optional + Metadata to format the basename. If None, empty dict is used. + replace : dict + Dictionary with strings to replace in the basename. + If None, empty dict is used. + + Returns + ------- + str: + A valid path for saving a diagnostic plot. + """ + meta = {} if meta is None else meta + replace = {} if replace is None else replace + basename = basename.format(**meta) + for key, value in replace.items(): + basename = basename.replace(key, value) + fpath = Path(cfg["plot_dir"]) / basename + return str(fpath.with_suffix(cfg["output_file_type"])) + + +def add_ancestor_input(cfg: dict) -> None: + """Read ancestors settings.yml and add it's input_data to this config.""" + log.info("add ancestors for %s", cfg[n.INPUT_FILES]) + for input_file in cfg[n.INPUT_FILES]: + cfg_anc_file = ( + "/run/".join(input_file.rsplit("/work/", 1)) + "/settings.yml" + ) + cfg_anc = get_cfg(cfg_anc_file) + cfg["input_data"].update(_get_input_data_files(cfg_anc)) + + +def abs_auxilary_path(cfg: dict, path: str | Path) -> str: + """Return absolut path of an auxilary file.""" + if Path(path).is_absolute(): + return str(path) + return str(Path(cfg["auxiliary_data_dir"]) / path) + + +def save_metadata(cfg: dict, metadata: dict) -> None: + """Save dict as metadata.yml in work folder.""" + with (Path(cfg["work_dir"]) / "metadata.yml").open("w") as wom: + yaml.dump(metadata, wom) + +def fix_interval(interval: dict) -> dict: + """Ensure that an interval has a label and a range. + + TODO: replace "/" with "_" in diagnostics who use this. + """ + if "range" not in interval: + interval["range"] = f"{interval['start']}/{interval['end']}" + if "label" not in interval: + interval["label"] = interval["range"] + return interval + + +def log_provenance(cfg: dict, fname: str | Path, record: dict) -> None: + """Add provenance information to the Provenancelog.""" + with ProvenanceLogger(cfg) as provenance_logger: + provenance_logger.log(fname, record) + + +### DATA PROCESSING ### + def merge_list_cube( cube_list: list, aux_name: str = "dataset", @@ -83,105 +191,6 @@ def merge_list_cube( return cubes.merge_cube() -def fold_meta( - cfg: dict, - meta: dict, - cfg_keys: list | None = None, - meta_keys: list | None = None, - variables: list | None = None, -) -> tuple: - """Create combinations of meta data and data constraints. - - cfg["variables"] overwrites meta["short_names"]. - - Parameters - ---------- - cfg: dict - Plot specific configuration with cfg_keys on root level. - meta: list - Full meta data including ancestor files. - cfg_keys: list, optional - Data constraints as config entries used for product. - Defaults to ["locations", "intervals"]. - meta_keys: list, optional - Keys for each meta used for product, short_name added automatically. - Defaults to ["dataset", "exp"]. - variables: list, optional - Variables to be used. Defaults to None. - - Returns - ------- - combinations : itertools.product - All combinations of the metadata and constraints. - groups : dict - Grouped metadata. - meta_keys : list - List of metadata keys. - """ - if variables is None: - variables = cfg.get("variables", ["pdsi", "spi"]) - if cfg_keys is None: - cfg_keys = ["locations", "intervals"] - if meta_keys is None: - meta_keys = ["dataset", "exp"] - - groups = { - gk: list( - group_metadata( - select_metadata(meta, short_name=variables[0]), - gk, - ).keys(), - ) - for gk in meta_keys - } - meta_keys.append("short_name") - groups["short_name"] = variables - g_map = {"locations": "location", "intervals": "interval"} - for ckey in cfg_keys: - try: - groups[g_map.get(ckey, ckey)] = cfg[ckey] - except KeyError: - log.warning("No '%s' found in plot config", ckey) - combinations = it.product(*groups.values()) - return combinations, groups, meta_keys - - -def select_meta_from_combi(meta: list, combi: dict, groups: dict) -> tuple: - """Select one meta data from list (filter valid keys). - - Parameters - ---------- - meta : list - List of metadata dictionaries. - combi : dict - Dictionary containing the combination of metadata values. - groups : dict - Dictionary containing the groups of metadata. - - Returns - ------- - tuple - A tuple containing the selected metadata and the configuration - dictionary. - """ - this_cfg = dict(zip(groups.keys(), combi)) - filter_cfg = clean_meta(this_cfg) # remove non meta keys - this_meta = select_metadata(meta, **filter_cfg)[0] - return this_meta, this_cfg - - -def list_meta_keys(meta: list, group: dict) -> list: - """Return a list of all keys found for a group in the meta data.""" - return list(group_metadata(meta, group).keys()) - - -def mkplotdir(cfg: dict, dname: str | Path) -> None: - """Create a sub directory for plots if it does not exist.""" - new_dir = Path(cfg["plot_dir"] / dname) - if not new_dir.is_dir(): - Path.mkdir(new_dir) - - def sort_cube(cube: Cube, coord: str = "longitude") -> Cube: """Sort data along a one-dimensional numerical coordinate. @@ -237,301 +246,243 @@ def fix_longitude(cube: Cube, coord="longitude") -> Cube: return cube -def get_meta_list(meta: dict, group: str, select: dict | None = None) -> list: - """ - List all entries found for the group key as a list. - - With a given selection, the meta data will be filtered first. - - Parameters - ---------- - meta : dict - Full meta data. - group : str - Key to search for. Defaults to "alias". - select : dict, optional - Dictionary like {'short_name': 'pdsi'} that is passed to a selection. +def smooth(dat, window=32, mode="same") -> np.ndarray: + """Smooth a 1D array with a window size. - Returns - ------- - list - Collected values for the group key. + from scipy.ndimage.filters import uniform_filter1d as unifilter + TODO: iris can also directly filter on cubes: + https://scitools-iris.readthedocs.io/en/latest/generated/gallery/general/ + plot_SOI_filtering.html#sphx-glr-generated-gallery-general-plot-soi- + filtering-py + smoothed = unifilter(dat, 32) # smooth over 4 year window """ - if select is not None: - meta = select_metadata(meta, **select) - return list(group_metadata(meta, group).keys()) + # using numpy convol + np_filter = np.ones(window) + return np.convolve(dat, np_filter, mode) / window -def get_datasets(cfg: dict) -> dict: - """Return a dictionary of datasets and their metadata.""" - metadata = cfg["input_data"].values() - return group_metadata(metadata, "dataset").keys() +def date_to_months(date: str, start_year: int) -> int: + """Translate date YYYY-MM to number of months since start_year.""" + years, months = [int(x) for x in date.split("-")] + return int(12 * (years - start_year) + months) +def remove_attributes( + cube: Cube | iris.Coord, + ignore: list | None = None, +) -> None: + """Remove most attributes of cube or coords in place. -def get_dataset_scenarios(cfg: dict) -> list: - """Combine datasets and scenarios to a list of pairs of strings.""" - metadata = cfg["input_data"].values() - input_datasets = group_metadata(metadata, "dataset").keys() - input_scenarios = group_metadata(metadata, "alias").keys() - return list(it.product(input_datasets, input_scenarios)) - - -def date_tick_layout( - fig, - axes, - dates: list | None = None, - label: str = "Time", - years: int | None = 1, -) -> None: - """Update a time series figure to use date/year ticks and grid.""" - axes.set_xlabel(label) - if dates is not None: - datemin = np.datetime64(dates[0], "Y") - datemax = np.datetime64(dates[-1], "Y") + np.timedelta64(1, "Y") - axes.set_xlim(datemin, datemax) - if years is None: - locator = mdates.AutoDateLocator() - min_locator = mdates.YearLocator(1) - else: - locator = mdates.YearLocator(years) # type: ignore[assignment] - min_locator = mdates.YearLocator(1) - year_formatter = mdates.DateFormatter("%Y") - axes.grid(True) - axes.xaxis.set_major_locator(locator) - axes.xaxis.set_major_formatter(year_formatter) - axes.xaxis.set_minor_locator(min_locator) - fig.autofmt_xdate() # align, rotate and space for tick labels - - -def auto_tick_layout(fig, axes, dates=None) -> None: - """Update a time series figure to use auto date ticks and grid. - - NOTE: can this be merged with date_tick_layout? - """ - axes.set_xlabel("Time") - if dates is not None: - datemin = np.datetime64(dates[0], "Y") - datemax = np.datetime64(dates[-1], "Y") + np.timedelta64(1, "Y") - axes.set_xlim(datemin, datemax) - year_locator = mdates.YearLocator(1) - months_locator = mdates.MonthLocator() - year_formatter = mdates.DateFormatter("%Y") - axes.grid(True) - axes.xaxis.set_major_locator(year_locator) - axes.xaxis.set_major_formatter(year_formatter) - axes.xaxis.set_minor_locator(months_locator) - fig.autofmt_xdate() # align, rotate and space for tick labels - - -def map_land_layout(axes: GeoAxes, plot, bounds, *, cbar: bool = True) -> None: - """Plot style for rectangular drought maps with land overlay. + Used to clean up differences in cubes coordinates before merging - Mask the ocean by overlay, add gridlines, set left/bottom tick labels. + Parameters + ---------- + cube + iris.Cube or iris.Coord + ignore + Optional: List of Strings of attributes, that are not removed + By default: [] """ - axes.coastlines() - axes.add_feature( - ct.feature.OCEAN, - edgecolor="black", - facecolor="white", - zorder=1, - ) - glines = axes.gridlines( - crs=ct.crs.PlateCarree(), - linewidth=1, - color="black", - alpha=0.6, - linestyle="--", - draw_labels=True, - zorder=2, - ) - glines.xlabels_top = False - glines.ylabels_right = False - if bounds is not None and cbar: - plt.colorbar( - plot, - ax=axes, - ticks=bounds, - extend="both", - fraction=0.022, - ) - elif cbar: - plt.colorbar(plot, ax=axes, extend="both", fraction=0.022) - - -def get_scenarios(meta, **kwargs) -> list: - """Return a list of alias values for scenario names.""" - selected = select_metadata(meta, **kwargs) - return list(group_metadata(selected, "alias").keys()) - - -def add_ancestor_input(cfg: dict) -> None: - """Read ancestors settings.yml and add it's input_data to this config.""" - log.info("add ancestors for %s", cfg[n.INPUT_FILES]) - for input_file in cfg[n.INPUT_FILES]: - cfg_anc_file = ( - "/run/".join(input_file.rsplit("/work/", 1)) + "/settings.yml" - ) - cfg_anc = get_cfg(cfg_anc_file) - cfg["input_data"].update(_get_input_data_files(cfg_anc)) + if ignore is None: + ignore = [] + remove = [attr for attr in cube.attributes if attr not in ignore] + for attr in remove: + del cube.attributes[attr] -def quick_save(cube: Cube, name: str, cfg: dict) -> None: - """Simply save cube to netcdf file without additional information.""" - if cfg.get("write_netcdf", True): - diag_file = get_diagnostic_filename(name, cfg) - log.info("quick save %s", diag_file) - iris.save(cube, target=diag_file) +def get_time_range(cube: Cube) -> dict: + """Guess the period of a cube based on the time coordinate.""" + if not isinstance(cube, Cube): + cube = iris.load_cube(cube) + time = cube.coord("time") + start = time.units.num2date(time.points[0]) + end = time.units.num2date(time.points[-1]) + return {"start_year": start.year, "end_year": end.year} -def quick_load(cfg: dict, context: dict, *, strict=True) -> Cube: - """Load input files from config wich matches the selection. +def monthly_to_daily( + cube: Cube, + units: str = "mm day-1", + *, + leap_years: bool = True, +) -> None: + """Convert monthly data to daily data inplace ignoring leap years. - Select, load and return the first match. - raises an error (if strict) or a warning for multiple matches. + With leap_years=False this is similar to the same named function in utils.R + and compatible with the pet.R diagnostic. """ - meta = cfg["input_data"].values() - var_meta = select_single_meta(meta, strict=strict, **context) - return iris.load_cube(var_meta["filename"]) + months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + months = months * int((cube.shape[0] / 12) + 1) + for idx, sli in enumerate(cube.slices_over(["time"])): + if not leap_years: + days = months[idx] + cube.data[idx] = cube.data[idx] / days + continue + # consider leap days + time = sli.coord("time") + date = time.units.num2date(time.points[0]) + days = monthrange(date.year, date.month)[1] + cube.data[idx] = cube.data[idx] / days + cube.units = units -def smooth(dat, window=32, mode="same") -> np.ndarray: - """Smooth a 1D array with a window size. +def daily_to_monthly( + cube: Cube, + units: str = "mm month-1", + *, + leap_years: bool = True, +) -> None: + """Convert daily data to monthly data inplace. - from scipy.ndimage.filters import uniform_filter1d as unifilter - TODO: iris can also directly filter on cubes: - https://scitools-iris.readthedocs.io/en/latest/generated/gallery/general/ - plot_SOI_filtering.html#sphx-glr-generated-gallery-general-plot-soi- - filtering-py - smoothed = unifilter(dat, 32) # smooth over 4 year window + With leap_years=False this is similar to the same named function in utils.R + and compatible with the pet.R diagnostic. """ - # using numpy convol - np_filter = np.ones(window) - return np.convolve(dat, np_filter, mode) / window - - -def get_basename(cfg, meta, prefix=None, suffix=None) -> str: - """Return a formatted basename for a diagnostic file.""" - _ = cfg # we might need this in the future. dont tell codacy! - formats = { - "CMIP6": CMIP6_FNAME, - "OBS": OBS_FNAME, - } - basename = formats[meta["project"]].format(**meta) - if suffix: - basename += f"_{suffix}" - if prefix: - basename = f"{prefix}_{basename}" - return basename - + months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + months = months * int((cube.shape[0] / 12) + 1) + for idx, sli in enumerate(cube.slices_over(["time"])): + if not leap_years: + days = months[idx] + cube.data[idx] = cube.data[idx] * days + continue + # consider leap days + time = sli.coord("time") + date = time.units.num2date(time.points[0]) + days = monthrange(date.year, date.month)[1] + cube.data[idx] = cube.data[idx] * days + cube.units = units -def clean_meta(meta) -> dict: - """Return a copy of meta data with only selected keys. - Keys are: short_name, dataset, alias, exp - """ - valid_keys = ["short_name", "dataset", "alias", "exp"] - return {key: val for key, val in meta.items() if key in valid_keys} +def _get_data_hlp(axis, data, ilat, ilon): + """Get data_help dependend on axis.""" + if axis == 0: + data_help = (data[:, ilat, ilon])[:, 0] + elif axis == 1: + data_help = (data[ilat, :, ilon])[:, 0] + elif axis == 2: + data_help = data[ilat, ilon, :] + else: + data_help = None + return data_help -def select_single_metadata( - meta: list, - *, - strict: bool = True, - **kwargs: dict[str, Any], -) -> dict | None: - """Filter meta data by arbitrary keys and return one matching result. +def create_cube_from_data(var, data_dict) -> Cube: + """Create cube to prepare plotted data for saving to netCDF. - For more/less then one match the first/none is returned or an error is - raised with strict=True. + Renamed function from cube_to_save_ploted_data Parameters ---------- - meta - esmvaltool meta data dict - strict, optional - Raise error if not exactly one match exists, by default True - - Returns - ------- - Dict: One value from the input meta - - Raises - ------ - ValueError - Too many matching entries - ValueError - No matching entry + var : np.ndarray + Data to be plotted. + data_dict : dict + Dictionary containing metadata for the data. It must contain: "var", + "drought_char", "unit", "latitude", and "longitude". """ - selected_meta = select_metadata(meta, **kwargs) - if len(selected_meta) > 1: - log.warning("Multiple entries found for Metadata: %s", selected_meta) - if strict: - raise ValueError("Too many matching entries") - elif len(selected_meta) == 0: - log.warning("No Metadata found! For: %s", kwargs) - if strict: - raise ValueError("No matching entry") - return None - return selected_meta[0] - - -# alias for convenience -select_single_meta = select_single_metadata - - -def date_to_months(date: str, start_year: int) -> int: - """Translate date YYYY-MM to number of months since start_year.""" - years, months = [int(x) for x in date.split("-")] - return int(12 * (years - start_year) + months) - + cube = Cube( + var, + var_name=data_dict["var"], + long_name=data_dict["drought_char"], + units=data_dict["unit"], + ) + cube.add_dim_coord( + iris.coords.DimCoord( + data_dict["latitude"], + var_name="lat", + long_name="latitude", + units="degrees_north", + ), + 0, + ) + cube.add_dim_coord( + iris.coords.DimCoord( + data_dict["longitude"], + var_name="lon", + long_name="longitude", + units="degrees_east", + ), + 1, + ) + return cube -def fix_interval(interval: dict) -> dict: - """Ensure that an interval has a label and a range. - TODO: replace "/" with "_" in diagnostics who use this. - """ - if "range" not in interval: - interval["range"] = f"{interval['start']}/{interval['end']}" - if "label" not in interval: - interval["label"] = interval["range"] - return interval +def cube_to_save_ploted_ts(data_dict: dict) -> Cube: + """Create cube to prepare plotted time series for saving to netCDF.""" + cube = Cube( + data_dict["data"], + var_name=data_dict["var"], + long_name=data_dict["var"], + units=data_dict["unit"], + ) + coord = iris.coords.DimCoord( + data_dict["time"], + var_name="time", + long_name="Time", + units="month", + ) + cube.add_dim_coord(coord, 0) + return cube -def get_plot_fname( - cfg: dict, - basename, - meta: dict | None = None, - replace: dict | None = None, -) -> str: - """Get a valid path for saving a diagnostic plot. +def runs_of_ones_array_spei(bits, spei) -> list: + """Set 1 at beginning ond -1 at the end of events.""" + # make sure all runs of ones are well-bounded + bounded = np.hstack(([0], bits, [0])) + # get 1 at run starts and -1 at run ends + difs = np.diff(bounded) + (run_starts,) = np.where(difs > 0) + (run_ends,) = np.where(difs < 0) + spei_sum = np.full(len(run_starts), 0.5) + for iii, indexs in enumerate(run_starts): + spei_sum[iii] = np.sum(spei[indexs: run_ends[iii]]) + return [run_ends - run_starts, spei_sum] - This is an alternative to shared.get_diagnostic_filename. - It uses cfg as first argument and accept metadata to format the basename. - Parameters - ---------- - cfg : dict - Dictionary with diagnostic configuration. - basename : str - The basename of the file. - meta : dict, optional - Metadata to format the basename. If None, empty dict is used. - replace : dict - Dictionary with strings to replace in the basename. - If None, empty dict is used. +def count_spells(data, threshold, axis) -> np.ndarray: + """Functions for Iris Aggregator to count spells.""" + if axis < 0: + # just cope with negative axis numbers + axis += data.ndim + data = data[:, :, 0, :] + if axis > 2: + axis = axis - 1 + listshape = [] + inoax = [] + for iii, ishape in enumerate(data.shape): + if iii != axis: + listshape.append(ishape) + inoax.append(iii) + listshape.append(4) + return_var = np.zeros(tuple(listshape)) + for ilat in range(listshape[0]): + for ilon in range(listshape[1]): + data_help = _get_data_hlp(axis, data, ilat, ilon) + if data_help.count() == 0: + return_var[ilat, ilon, 0] = data_help[0] + return_var[ilat, ilon, 1] = data_help[0] + return_var[ilat, ilon, 2] = data_help[0] + return_var[ilat, ilon, 3] = data_help[0] + else: + data_hits = data_help < threshold + [events, spei_sum] = runs_of_ones_array_spei( + data_hits, + data_help, + ) + return_var[ilat, ilon, 0] = np.count_nonzero(events) + return_var[ilat, ilon, 1] = np.mean(events) + return_var[ilat, ilon, 2] = np.mean( + (spei_sum * events) + / (np.mean(data_help[data_hits]) * np.mean(events)), + ) + return_var[ilat, ilon, 3] = np.mean(spei_sum / events) + return return_var - Returns - ------- - str: - A valid path for saving a diagnostic plot. - """ - meta = {} if meta is None else meta - replace = {} if replace is None else replace - basename = basename.format(**meta) - for key, value in replace.items(): - basename = basename.replace(key, value) - fpath = Path(cfg["plot_dir"]) / basename - return str(fpath.with_suffix(cfg["output_file_type"])) + +def get_latlon_index(coords, lim1, lim2) -> np.ndarray: + """Get index for given values between two limits (1D), e.g. lats, lons.""" + return ( + np.where( + np.absolute(coords - (lim2 + lim1) / 2.0) <= (lim2 - lim1) / 2.0, + ) + )[0] def slice_cube_interval(cube: Cube, interval: list) -> Cube: @@ -560,23 +511,6 @@ def find_first(nparr: np.ndarray) -> int: return int(idx if np.arr[idx] else -1) -def add_spei_meta(cfg: dict, name: str = "spei", pos: int = 0) -> None: - """Add missing meta for specific ancestor script (workaround). - - NOTE: should be obsolete, since PET.R and SPEI.R write metadata. - """ - log.info("adding meta file for save_spei output") - spei_fname = ( - cfg["tmp_meta"]["filename"].split("/")[-1].replace("_pr_", f"_{name}_") - ) - spei_file = str(Path(cfg["input_files"][pos]) / spei_fname) - log.info("spei file path: %s", spei_file) - meta = cfg["tmp_meta"].copy() - meta.update(INDEX_META[name.upper()]) - meta["filename"] = spei_file - cfg["input_data"][spei_file] = meta - - def fix_calendar(cube: Cube) -> Cube: """Convert cubes calendar to gregorian. @@ -671,83 +605,223 @@ def transpose_by_names(cube: Cube, names: list) -> None: cube.transpose(new_dims) -def save_metadata(cfg: dict, metadata: dict) -> None: - """Save dict as metadata.yml in work folder.""" - with (Path(cfg["work_dir"]) / "metadata.yml").open("w") as wom: - yaml.dump(metadata, wom) +### META DATA ### +def fold_meta( + cfg: dict, + meta: dict, + cfg_keys: list | None = None, + meta_keys: list | None = None, + variables: list | None = None, +) -> tuple: + """Create combinations of meta data and data constraints. -def sub_cfg(cfg: dict, plot: str, key: str) -> dict: - """Get get merged general and plot type specific kwargs.""" - if isinstance(cfg.get(key, {}), dict): - general = cfg.get(key, {}).copy() - specific = cfg.get(plot, {}).get(key, {}) - general.update(specific) - return general - try: - return cfg[plot][key] - except KeyError: - return cfg[key] + cfg["variables"] overwrites meta["short_names"]. + Parameters + ---------- + cfg: dict + Plot specific configuration with cfg_keys on root level. + meta: list + Full meta data including ancestor files. + cfg_keys: list, optional + Data constraints as config entries used for product. + Defaults to ["locations", "intervals"]. + meta_keys: list, optional + Keys for each meta used for product, short_name added automatically. + Defaults to ["dataset", "exp"]. + variables: list, optional + Variables to be used. Defaults to None. -def abs_auxilary_path(cfg: dict, path: str | Path) -> str: - """Return absolut path of an auxilary file.""" - if Path(path).is_absolute(): - return str(path) - return str(Path(cfg["auxiliary_data_dir"]) / path) + Returns + ------- + combinations : itertools.product + All combinations of the metadata and constraints. + groups : dict + Grouped metadata. + meta_keys : list + List of metadata keys. + """ + if variables is None: + variables = cfg.get("variables", ["pdsi", "spi"]) + if cfg_keys is None: + cfg_keys = ["locations", "intervals"] + if meta_keys is None: + meta_keys = ["dataset", "exp"] + groups = { + gk: list( + group_metadata( + select_metadata(meta, short_name=variables[0]), + gk, + ).keys(), + ) + for gk in meta_keys + } + meta_keys.append("short_name") + groups["short_name"] = variables + g_map = {"locations": "location", "intervals": "interval"} + for ckey in cfg_keys: + try: + groups[g_map.get(ckey, ckey)] = cfg[ckey] + except KeyError: + log.warning("No '%s' found in plot config", ckey) + combinations = it.product(*groups.values()) + return combinations, groups, meta_keys -def remove_attributes( - cube: Cube | iris.Coord, - ignore: list | None = None, -) -> None: - """Remove most attributes of cube or coords in place. - Used to clean up differences in cubes coordinates before merging +def select_meta_from_combi(meta: list, combi: dict, groups: dict) -> tuple: + """Select one meta data from list (filter valid keys). Parameters ---------- - cube - iris.Cube or iris.Coord - ignore - Optional: List of Strings of attributes, that are not removed - By default: [] + meta : list + List of metadata dictionaries. + combi : dict + Dictionary containing the combination of metadata values. + groups : dict + Dictionary containing the groups of metadata. + + Returns + ------- + tuple + A tuple containing the selected metadata and the configuration + dictionary. """ - if ignore is None: - ignore = [] - remove = [attr for attr in cube.attributes if attr not in ignore] - for attr in remove: - del cube.attributes[attr] + this_cfg = dict(zip(groups.keys(), combi)) + filter_cfg = clean_meta(this_cfg) # remove non meta keys + this_meta = select_metadata(meta, **filter_cfg)[0] + return this_meta, this_cfg -def font_color(background: str | tuple | float) -> str: - """Return black or white depending on backgrounds greyscale. +def list_meta_keys(meta: list, group: dict) -> list: + """Return a list of all keys found for a group in the meta data.""" + return list(group_metadata(meta, group).keys()) + + +def get_meta_list(meta: dict, group: str, select: dict | None = None) -> list: + """ + List all entries found for the group key as a list. + + With a given selection, the meta data will be filtered first. Parameters ---------- - background : str, tuple, or float - color as string (grayscale value, name, hex) or tuple (rgb, rgba) + meta : dict + Full meta data. + group : str + Key to search for. Defaults to "alias". + select : dict, optional + Dictionary like {'short_name': 'pdsi'} that is passed to a selection. + + Returns + ------- + list + Collected values for the group key. """ - if sum(mpl.colors.to_rgb(background)) > 1.5: - return "black" - return "white" + if select is not None: + meta = select_metadata(meta, **select) + return list(group_metadata(meta, group).keys()) -def log_provenance(cfg: dict, fname: str | Path, record: dict) -> None: - """Add provenance information to the Provenancelog.""" - with ProvenanceLogger(cfg) as provenance_logger: - provenance_logger.log(fname, record) +def get_datasets(cfg: dict) -> dict: + """Return a dictionary of datasets and their metadata.""" + metadata = cfg["input_data"].values() + return group_metadata(metadata, "dataset").keys() -def get_time_range(cube: Cube) -> dict: - """Guess the period of a cube based on the time coordinate.""" - if not isinstance(cube, Cube): - cube = iris.load_cube(cube) - time = cube.coord("time") - start = time.units.num2date(time.points[0]) - end = time.units.num2date(time.points[-1]) - return {"start_year": start.year, "end_year": end.year} +def get_dataset_scenarios(cfg: dict) -> list: + """Combine datasets and scenarios to a list of pairs of strings.""" + metadata = cfg["input_data"].values() + input_datasets = group_metadata(metadata, "dataset").keys() + input_scenarios = group_metadata(metadata, "alias").keys() + return list(it.product(input_datasets, input_scenarios)) + + +def get_scenarios(meta, **kwargs) -> list: + """Return a list of alias values for scenario names.""" + selected = select_metadata(meta, **kwargs) + return list(group_metadata(selected, "alias").keys()) + + +def get_basename(cfg, meta, prefix=None, suffix=None) -> str: + """Return a formatted basename for a diagnostic file.""" + _ = cfg # we might need this in the future. dont tell codacy! + formats = { + "CMIP6": CMIP6_FNAME, + "OBS": OBS_FNAME, + } + basename = formats[meta["project"]].format(**meta) + if suffix: + basename += f"_{suffix}" + if prefix: + basename = f"{prefix}_{basename}" + return basename + + +def clean_meta(meta) -> dict: + """Return a copy of meta data with only selected keys. + + Keys are: short_name, dataset, alias, exp + """ + valid_keys = ["short_name", "dataset", "alias", "exp"] + return {key: val for key, val in meta.items() if key in valid_keys} + + +def select_single_metadata( + meta: list, + *, + strict: bool = True, + **kwargs: dict[str, Any], +) -> dict | None: + """Filter meta data by arbitrary keys and return one matching result. + + For more/less then one match the first/none is returned or an error is + raised with strict=True. + + Parameters + ---------- + meta + esmvaltool meta data dict + strict, optional + Raise error if not exactly one match exists, by default True + + Returns + ------- + Dict: One value from the input meta + + Raises + ------ + ValueError + Too many matching entries + ValueError + No matching entry + """ + selected_meta = select_metadata(meta, **kwargs) + if len(selected_meta) > 1: + log.warning("Multiple entries found for Metadata: %s", selected_meta) + if strict: + raise ValueError("Too many matching entries") + elif len(selected_meta) == 0: + log.warning("No Metadata found! For: %s", kwargs) + if strict: + raise ValueError("No matching entry") + return None + return selected_meta[0] + +select_single_meta = select_single_metadata +def sub_cfg(cfg: dict, plot: str, key: str) -> dict: + """Get get merged general and plot type specific kwargs.""" + if isinstance(cfg.get(key, {}), dict): + general = cfg.get(key, {}).copy() + specific = cfg.get(plot, {}).get(key, {}) + general.update(specific) + return general + try: + return cfg[plot][key] + except KeyError: + return cfg[key] def guess_experiment(meta: dict) -> None: """Guess missing 'exp' in metadata from filename.""" @@ -757,187 +831,98 @@ def guess_experiment(meta: dict) -> None: meta["exp"] = exp -def monthly_to_daily( - cube: Cube, - units: str = "mm day-1", - *, - leap_years: bool = True, -) -> None: - """Convert monthly data to daily data inplace ignoring leap years. - - With leap_years=False this is similar to the same named function in utils.R - and compatible with the pet.R diagnostic. - """ - months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - months = months * int((cube.shape[0] / 12) + 1) - for idx, sli in enumerate(cube.slices_over(["time"])): - if not leap_years: - days = months[idx] - cube.data[idx] = cube.data[idx] / days - continue - # consider leap days - time = sli.coord("time") - date = time.units.num2date(time.points[0]) - days = monthrange(date.year, date.month)[1] - cube.data[idx] = cube.data[idx] / days - cube.units = units - +### PLOT HELPER ### -def daily_to_monthly( - cube: Cube, - units: str = "mm month-1", - *, - leap_years: bool = True, +def date_tick_layout( + fig, + axes, + dates: list | None = None, + label: str = "Time", + years: int | None = 1, ) -> None: - """Convert daily data to monthly data inplace. - - With leap_years=False this is similar to the same named function in utils.R - and compatible with the pet.R diagnostic. - """ - months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - months = months * int((cube.shape[0] / 12) + 1) - for idx, sli in enumerate(cube.slices_over(["time"])): - if not leap_years: - days = months[idx] - cube.data[idx] = cube.data[idx] * days - continue - # consider leap days - time = sli.coord("time") - date = time.units.num2date(time.points[0]) - days = monthrange(date.year, date.month)[1] - cube.data[idx] = cube.data[idx] * days - cube.units = units - - -def _get_data_hlp(axis, data, ilat, ilon): - """Get data_help dependend on axis.""" - if axis == 0: - data_help = (data[:, ilat, ilon])[:, 0] - elif axis == 1: - data_help = (data[ilat, :, ilon])[:, 0] - elif axis == 2: - data_help = data[ilat, ilon, :] + """Update a time series figure to use date/year ticks and grid.""" + axes.set_xlabel(label) + if dates is not None: + datemin = np.datetime64(dates[0], "Y") + datemax = np.datetime64(dates[-1], "Y") + np.timedelta64(1, "Y") + axes.set_xlim(datemin, datemax) + if years is None: + locator = mdates.AutoDateLocator() + min_locator = mdates.YearLocator(1) else: - data_help = None - return data_help - + locator = mdates.YearLocator(years) # type: ignore[assignment] + min_locator = mdates.YearLocator(1) + year_formatter = mdates.DateFormatter("%Y") + axes.grid(True) + axes.xaxis.set_major_locator(locator) + axes.xaxis.set_major_formatter(year_formatter) + axes.xaxis.set_minor_locator(min_locator) + fig.autofmt_xdate() # align, rotate and space for tick labels -def create_cube_from_data(var, data_dict) -> Cube: - """Create cube to prepare plotted data for saving to netCDF. - Renamed function from cube_to_save_ploted_data +def auto_tick_layout(fig, axes, dates=None) -> None: + """Update a time series figure to use auto date ticks and grid. - Parameters - ---------- - var : np.ndarray - Data to be plotted. - data_dict : dict - Dictionary containing metadata for the data. It must contain: "var", - "drought_char", "unit", "latitude", and "longitude". + NOTE: can this be merged with date_tick_layout? """ - cube = Cube( - var, - var_name=data_dict["var"], - long_name=data_dict["drought_char"], - units=data_dict["unit"], - ) - cube.add_dim_coord( - iris.coords.DimCoord( - data_dict["latitude"], - var_name="lat", - long_name="latitude", - units="degrees_north", - ), - 0, - ) - cube.add_dim_coord( - iris.coords.DimCoord( - data_dict["longitude"], - var_name="lon", - long_name="longitude", - units="degrees_east", - ), - 1, - ) - return cube + axes.set_xlabel("Time") + if dates is not None: + datemin = np.datetime64(dates[0], "Y") + datemax = np.datetime64(dates[-1], "Y") + np.timedelta64(1, "Y") + axes.set_xlim(datemin, datemax) + year_locator = mdates.YearLocator(1) + months_locator = mdates.MonthLocator() + year_formatter = mdates.DateFormatter("%Y") + axes.grid(True) + axes.xaxis.set_major_locator(year_locator) + axes.xaxis.set_major_formatter(year_formatter) + axes.xaxis.set_minor_locator(months_locator) + fig.autofmt_xdate() # align, rotate and space for tick labels -def cube_to_save_ploted_ts(data_dict: dict) -> Cube: - """Create cube to prepare plotted time series for saving to netCDF.""" - cube = Cube( - data_dict["data"], - var_name=data_dict["var"], - long_name=data_dict["var"], - units=data_dict["unit"], +def map_land_layout(axes: GeoAxes, plot, bounds, *, cbar: bool = True) -> None: + """Plot style for rectangular drought maps with land overlay. + + Mask the ocean by overlay, add gridlines, set left/bottom tick labels. + """ + axes.coastlines() + axes.add_feature( + ct.feature.OCEAN, + edgecolor="black", + facecolor="white", + zorder=1, ) - coord = iris.coords.DimCoord( - data_dict["time"], - var_name="time", - long_name="Time", - units="month", + glines = axes.gridlines( + crs=ct.crs.PlateCarree(), + linewidth=1, + color="black", + alpha=0.6, + linestyle="--", + draw_labels=True, + zorder=2, ) - cube.add_dim_coord(coord, 0) - return cube - - -def runs_of_ones_array_spei(bits, spei) -> list: - """Set 1 at beginning ond -1 at the end of events.""" - # make sure all runs of ones are well-bounded - bounded = np.hstack(([0], bits, [0])) - # get 1 at run starts and -1 at run ends - difs = np.diff(bounded) - (run_starts,) = np.where(difs > 0) - (run_ends,) = np.where(difs < 0) - spei_sum = np.full(len(run_starts), 0.5) - for iii, indexs in enumerate(run_starts): - spei_sum[iii] = np.sum(spei[indexs: run_ends[iii]]) - return [run_ends - run_starts, spei_sum] - + glines.xlabels_top = False + glines.ylabels_right = False + if bounds is not None and cbar: + plt.colorbar( + plot, + ax=axes, + ticks=bounds, + extend="both", + fraction=0.022, + ) + elif cbar: + plt.colorbar(plot, ax=axes, extend="both", fraction=0.022) -def count_spells(data, threshold, axis) -> np.ndarray: - """Functions for Iris Aggregator to count spells.""" - if axis < 0: - # just cope with negative axis numbers - axis += data.ndim - data = data[:, :, 0, :] - if axis > 2: - axis = axis - 1 - listshape = [] - inoax = [] - for iii, ishape in enumerate(data.shape): - if iii != axis: - listshape.append(ishape) - inoax.append(iii) - listshape.append(4) - return_var = np.zeros(tuple(listshape)) - for ilat in range(listshape[0]): - for ilon in range(listshape[1]): - data_help = _get_data_hlp(axis, data, ilat, ilon) - if data_help.count() == 0: - return_var[ilat, ilon, 0] = data_help[0] - return_var[ilat, ilon, 1] = data_help[0] - return_var[ilat, ilon, 2] = data_help[0] - return_var[ilat, ilon, 3] = data_help[0] - else: - data_hits = data_help < threshold - [events, spei_sum] = runs_of_ones_array_spei( - data_hits, - data_help, - ) - return_var[ilat, ilon, 0] = np.count_nonzero(events) - return_var[ilat, ilon, 1] = np.mean(events) - return_var[ilat, ilon, 2] = np.mean( - (spei_sum * events) - / (np.mean(data_help[data_hits]) * np.mean(events)), - ) - return_var[ilat, ilon, 3] = np.mean(spei_sum / events) - return return_var +def font_color(background: str | tuple | float) -> str: + """Return black or white depending on backgrounds greyscale. -def get_latlon_index(coords, lim1, lim2) -> np.ndarray: - """Get index for given values between two limits (1D), e.g. lats, lons.""" - return ( - np.where( - np.absolute(coords - (lim2 + lim1) / 2.0) <= (lim2 - lim1) / 2.0, - ) - )[0] + Parameters + ---------- + background : str, tuple, or float + color as string (grayscale value, name, hex) or tuple (rgb, rgba) + """ + if sum(mpl.colors.to_rgb(background)) > 1.5: + return "black" + return "white" From 3755a1a9f0dfe9ce8af94fb7c6e8c00b01c95aca Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 3 Mar 2025 16:22:06 +0100 Subject: [PATCH 44/66] remove old recipes and diagnostics --- .../droughtindex/collect_drought_func.py | 650 ------------------ .../droughtindex/collect_drought_model.py | 147 ---- .../droughtindex/collect_drought_obs_multi.py | 155 ----- .../diag_scripts/droughtindex/diag_save_spi.R | 127 ---- .../diag_scripts/droughtindex/diag_spei.R | 298 -------- .../diag_scripts/droughtindex/diag_spi.R | 212 ------ esmvaltool/recipes/recipe_martin18grl.yml | 164 ----- esmvaltool/recipes/recipe_spei.yml | 64 -- 8 files changed, 1817 deletions(-) delete mode 100644 esmvaltool/diag_scripts/droughtindex/collect_drought_func.py delete mode 100644 esmvaltool/diag_scripts/droughtindex/collect_drought_model.py delete mode 100644 esmvaltool/diag_scripts/droughtindex/collect_drought_obs_multi.py delete mode 100644 esmvaltool/diag_scripts/droughtindex/diag_save_spi.R delete mode 100644 esmvaltool/diag_scripts/droughtindex/diag_spei.R delete mode 100644 esmvaltool/diag_scripts/droughtindex/diag_spi.R delete mode 100644 esmvaltool/recipes/recipe_martin18grl.yml delete mode 100644 esmvaltool/recipes/recipe_spei.yml diff --git a/esmvaltool/diag_scripts/droughtindex/collect_drought_func.py b/esmvaltool/diag_scripts/droughtindex/collect_drought_func.py deleted file mode 100644 index 908212c6c7..0000000000 --- a/esmvaltool/diag_scripts/droughtindex/collect_drought_func.py +++ /dev/null @@ -1,650 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - - -"""Drought characteristics and plots based on Martin (2018). - -############################################################################### -droughtindex/collect_drought_obs_multi.py -Author: Katja Weigel, Kemisola Adeniyi (IUP, Uni Bremen, Germany) -EVal4CMIP project -############################################################################### - -Description ------------ - Functions for: - collect_drought_obs_multi.py and droughtindex/collect_drought_model.py. - -Configuration options ---------------------- - None - -############################################################################### - -""" - - -import logging -import os -from pprint import pformat -import numpy as np -import iris -from iris.analysis import Aggregator -import cartopy.crs as cart -import matplotlib.pyplot as plt -import matplotlib.dates as mda -from esmvaltool.diag_scripts.shared import (ProvenanceLogger, - get_diagnostic_filename, - get_plot_filename) - -logger = logging.getLogger(os.path.basename(__file__)) - - -def _get_data_hlp(axis, data, ilat, ilon): - """Get data_help dependend on axis.""" - if axis == 0: - data_help = (data[:, ilat, ilon])[:, 0] - elif axis == 1: - data_help = (data[ilat, :, ilon])[:, 0] - elif axis == 2: - data_help = data[ilat, ilon, :] - - return data_help - - -def _get_drought_data(cfg, cube): - """Prepare data and calculate characteristics.""" - # make a new cube to increase the size of the data array - # Make an aggregator from the user function. - spell_no = Aggregator('spell_count', - count_spells, - units_func=lambda units: 1) - new_cube = _make_new_cube(cube) - - # calculate the number of drought events and their average duration - drought_show = new_cube.collapsed('time', spell_no, - threshold=cfg['threshold']) - drought_show.rename('Drought characteristics') - # length of time series - time_length = len(new_cube.coord('time').points) / 12.0 - # Convert number of droughtevents to frequency (per year) - drought_show.data[:, :, 0] = drought_show.data[:, :, - 0] / time_length - return drought_show - - -def _provenance_map_spei(cfg, name_dict, spei, dataset_name): - """Set provenance for plot_map_spei.""" - caption = 'Global map of ' + \ - name_dict['drought_char'] + \ - ' [' + name_dict['unit'] + '] ' + \ - 'based on ' + cfg['indexname'] + '.' - - if cfg['indexname'].lower == "spei": - set_refs = ['martin18grl', 'vicente10jclim', ] - elif cfg['indexname'].lower == "spi": - set_refs = ['martin18grl', 'mckee93proc', ] - else: - set_refs = ['martin18grl', ] - - provenance_record = get_provenance_record([name_dict['input_filenames']], - caption, - ['global'], - set_refs) - - diagnostic_file = get_diagnostic_filename(cfg['indexname'] + '_map' + - name_dict['add_to_filename'] + - '_' + - dataset_name, cfg) - plot_file = get_plot_filename(cfg['indexname'] + '_map' + - name_dict['add_to_filename'] + - '_' + - dataset_name, cfg) - - logger.info("Saving analysis results to %s", diagnostic_file) - - cubesave = cube_to_save_ploted(spei, name_dict) - iris.save(cubesave, target=diagnostic_file) - - logger.info("Recording provenance of %s:\n%s", diagnostic_file, - pformat(provenance_record)) - with ProvenanceLogger(cfg) as provenance_logger: - provenance_logger.log(plot_file, provenance_record) - provenance_logger.log(diagnostic_file, provenance_record) - - -def _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames): - """Set provenance for plot_map_spei_multi.""" - caption = 'Global map of the multi-model mean of ' + \ - data_dict['drought_char'] + \ - ' [' + data_dict['unit'] + '] ' + \ - 'based on ' + cfg['indexname'] + '.' - - if cfg['indexname'].lower == "spei": - set_refs = ['martin18grl', 'vicente10jclim', ] - elif cfg['indexname'].lower == "spi": - set_refs = ['martin18grl', 'mckee93proc', ] - else: - set_refs = ['martin18grl', ] - - provenance_record = get_provenance_record(input_filenames, caption, - ['global'], - set_refs) - - diagnostic_file = get_diagnostic_filename(cfg['indexname'] + '_map' + - data_dict['filename'] + '_' + - data_dict['datasetname'], cfg) - plot_file = get_plot_filename(cfg['indexname'] + '_map' + - data_dict['filename'] + '_' + - data_dict['datasetname'], cfg) - - logger.info("Saving analysis results to %s", diagnostic_file) - - iris.save(cube_to_save_ploted(spei, data_dict), target=diagnostic_file) - - logger.info("Recording provenance of %s:\n%s", diagnostic_file, - pformat(provenance_record)) - with ProvenanceLogger(cfg) as provenance_logger: - provenance_logger.log(plot_file, provenance_record) - provenance_logger.log(diagnostic_file, provenance_record) - - -def _provenance_time_series_spei(cfg, data_dict): - """Provenance for time series plots.""" - caption = 'Time series of ' + \ - data_dict['var'] + \ - ' at' + data_dict['area'] + '.' - - if cfg['indexname'].lower == "spei": - set_refs = ['vicente10jclim', ] - elif cfg['indexname'].lower == "spi": - set_refs = ['mckee93proc', ] - else: - set_refs = ['martin18grl', ] - - provenance_record = get_provenance_record([data_dict['filename']], - caption, - ['reg'], set_refs, - plot_type='times') - - diagnostic_file = get_diagnostic_filename(cfg['indexname'] + - '_time_series_' + - data_dict['area'] + - '_' + - data_dict['dataset_name'], cfg) - plot_file = get_plot_filename(cfg['indexname'] + - '_time_series_' + - data_dict['area'] + - '_' + - data_dict['dataset_name'], cfg) - logger.info("Saving analysis results to %s", diagnostic_file) - - cubesave = cube_to_save_ploted_ts(data_dict) - iris.save(cubesave, target=diagnostic_file) - - logger.info("Recording provenance of %s:\n%s", diagnostic_file, - pformat(provenance_record)) - with ProvenanceLogger(cfg) as provenance_logger: - provenance_logger.log(plot_file, provenance_record) - provenance_logger.log(diagnostic_file, provenance_record) - - -def cube_to_save_ploted(var, data_dict): - """Create cube to prepare plotted data for saving to netCDF.""" - plot_cube = iris.cube.Cube(var, var_name=data_dict['var'], - long_name=data_dict['drought_char'], - units=data_dict['unit']) - plot_cube.add_dim_coord(iris.coords.DimCoord(data_dict['latitude'], - var_name='lat', - long_name='latitude', - units='degrees_north'), 0) - plot_cube.add_dim_coord(iris.coords.DimCoord(data_dict['longitude'], - var_name='lon', - long_name='longitude', - units='degrees_east'), 1) - - return plot_cube - - -def cube_to_save_ploted_ts(data_dict): - """Create cube to prepare plotted time series for saving to netCDF.""" - plot_cube = iris.cube.Cube(data_dict['data'], var_name=data_dict['var'], - long_name=data_dict['var'], - units=data_dict['unit']) - plot_cube.add_dim_coord(iris.coords.DimCoord(data_dict['time'], - var_name='time', - long_name='Time', - units='month'), 0) - - return plot_cube - - -def get_provenance_record(ancestor_files, caption, - domains, refs, plot_type='geo'): - """Get Provenance record.""" - record = { - 'caption': caption, - 'statistics': ['mean'], - 'domains': domains, - 'plot_type': plot_type, - 'themes': ['phys'], - 'authors': [ - 'weigel_katja', - 'adeniyi_kemisola', - ], - 'references': refs, - 'ancestors': ancestor_files, - } - return record - - -def _make_new_cube(cube): - """Make a new cube with an extra dimension for result of spell count.""" - new_shape = cube.shape + (4,) - new_data = iris.util.broadcast_to_shape(cube.data, new_shape, [0, 1, 2]) - new_cube = iris.cube.Cube(new_data) - new_cube.add_dim_coord(iris.coords.DimCoord( - cube.coord('time').points, long_name='time'), 0) - new_cube.add_dim_coord(iris.coords.DimCoord( - cube.coord('latitude').points, long_name='latitude'), 1) - new_cube.add_dim_coord(iris.coords.DimCoord( - cube.coord('longitude').points, long_name='longitude'), 2) - new_cube.add_dim_coord(iris.coords.DimCoord( - [0, 1, 2, 3], long_name='z'), 3) - return new_cube - - -def _plot_multi_model_maps(cfg, all_drought_mean, lats_lons, input_filenames, - tstype): - """Prepare plots for multi-model mean.""" - data_dict = {'latitude': lats_lons[0], - 'longitude': lats_lons[1], - 'model_kind': tstype - } - if tstype == 'Difference': - # RCP85 Percentage difference - data_dict.update({'data': all_drought_mean[:, :, 0], - 'var': 'diffnumber', - 'datasetname': 'Percentage', - 'drought_char': 'Number of drought events', - 'unit': '%', - 'filename': 'Percentage_difference_of_No_of_Events', - 'drought_numbers_level': np.arange(-100, 110, 10)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='rainbow') - - data_dict.update({'data': all_drought_mean[:, :, 1], - 'var': 'diffduration', - 'drought_char': 'Duration of drought events', - 'filename': 'Percentage_difference_of_Dur_of_Events', - 'drought_numbers_level': np.arange(-100, 110, 10)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='rainbow') - - data_dict.update({'data': all_drought_mean[:, :, 2], - 'var': 'diffseverity', - 'drought_char': 'Severity Index of drought events', - 'filename': 'Percentage_difference_of_Sev_of_Events', - 'drought_numbers_level': np.arange(-50, 60, 10)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='rainbow') - - data_dict.update({'data': all_drought_mean[:, :, 3], - 'var': 'diff' + (cfg['indexname']).lower(), - 'drought_char': 'Average ' + cfg['indexname'] + - ' of drought events', - 'filename': 'Percentage_difference_of_Avr_of_Events', - 'drought_numbers_level': np.arange(-50, 60, 10)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='rainbow') - else: - data_dict.update({'data': all_drought_mean[:, :, 0], - 'var': 'frequency', - 'unit': 'year-1', - 'drought_char': 'Number of drought events per year', - 'filename': tstype + '_No_of_Events_per_year', - 'drought_numbers_level': np.arange(0, 0.4, 0.05)}) - if tstype == 'Observations': - data_dict['datasetname'] = 'Mean' - else: - data_dict['datasetname'] = 'MultiModelMean' - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='gnuplot') - - data_dict.update({'data': all_drought_mean[:, :, 1], - 'var': 'duration', - 'unit': 'month', - 'drought_char': 'Duration of drought events [month]', - 'filename': tstype + '_Dur_of_Events', - 'drought_numbers_level': np.arange(0, 6, 1)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='gnuplot') - - data_dict.update({'data': all_drought_mean[:, :, 2], - 'var': 'severity', - 'unit': '1', - 'drought_char': 'Severity Index of drought events', - 'filename': tstype + '_Sev_index_of_Events', - 'drought_numbers_level': np.arange(0, 9, 1)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='gnuplot') - namehlp = 'Average ' + cfg['indexname'] + ' of drought events' - namehlp2 = tstype + '_Average_' + cfg['indexname'] + '_of_Events' - data_dict.update({'data': all_drought_mean[:, :, 3], - 'var': (cfg['indexname']).lower(), - 'unit': '1', - 'drought_char': namehlp, - 'filename': namehlp2, - 'drought_numbers_level': np.arange(-2.8, -1.8, 0.2)}) - plot_map_spei_multi(cfg, data_dict, input_filenames, - colormap='gnuplot') - - -def _plot_single_maps(cfg, cube2, drought_show, tstype, input_filenames): - """Plot map of drought characteristics for individual models and times.""" - cube2.data = drought_show.data[:, :, 0] - name_dict = {'add_to_filename': tstype + '_No_of_Events_per_year', - 'name': tstype + ' Number of drought events per year', - 'var': 'frequency', - 'unit': 'year-1', - 'drought_char': 'Number of drought events per year', - 'input_filenames': input_filenames} - plot_map_spei(cfg, cube2, np.arange(0, 0.4, 0.05), - name_dict) - - # plot the average duration of drought events - cube2.data = drought_show.data[:, :, 1] - name_dict.update({'add_to_filename': tstype + '_Dur_of_Events', - 'name': tstype + ' Duration of drought events(month)', - 'var': 'duration', - 'unit': 'month', - 'drought_char': 'Number of drought events per year', - 'input_filenames': input_filenames}) - plot_map_spei(cfg, cube2, np.arange(0, 6, 1), name_dict) - - # plot the average severity index of drought events - cube2.data = drought_show.data[:, :, 2] - name_dict.update({'add_to_filename': tstype + '_Sev_index_of_Events', - 'name': tstype + ' Severity Index of drought events', - 'var': 'severity', - 'unit': '1', - 'drought_char': 'Number of drought events per year', - 'input_filenames': input_filenames}) - plot_map_spei(cfg, cube2, np.arange(0, 9, 1), name_dict) - - # plot the average spei of drought events - cube2.data = drought_show.data[:, :, 3] - - namehlp = tstype + '_Avr_' + cfg['indexname'] + '_of_Events' - namehlp2 = tstype + '_Average_' + cfg['indexname'] + '_of_Events' - name_dict.update({'add_to_filename': namehlp, - 'name': namehlp2, - 'var': 'severity', - 'unit': '1', - 'drought_char': 'Number of drought events per year', - 'input_filenames': input_filenames}) - plot_map_spei(cfg, cube2, np.arange(-2.8, -1.8, 0.2), name_dict) - - -def runs_of_ones_array_spei(bits, spei): - """Set 1 at beginning ond -1 at the end of events.""" - # make sure all runs of ones are well-bounded - bounded = np.hstack(([0], bits, [0])) - # get 1 at run starts and -1 at run ends - difs = np.diff(bounded) - run_starts, = np.where(difs > 0) - run_ends, = np.where(difs < 0) - spei_sum = np.full(len(run_starts), 0.5) - for iii, indexs in enumerate(run_starts): - spei_sum[iii] = np.sum(spei[indexs:run_ends[iii]]) - - return [run_ends - run_starts, spei_sum] - - -def count_spells(data, threshold, axis): - """Functions for Iris Aggregator to count spells.""" - if axis < 0: - # just cope with negative axis numbers - axis += data.ndim - data = data[:, :, 0, :] - if axis > 2: - axis = axis - 1 - - listshape = [] - inoax = [] - for iii, ishape in enumerate(data.shape): - if iii != axis: - listshape.append(ishape) - inoax.append(iii) - - listshape.append(4) - return_var = np.zeros(tuple(listshape)) - - for ilat in range(listshape[0]): - for ilon in range(listshape[1]): - data_help = _get_data_hlp(axis, data, ilat, ilon) - - if data_help.count() == 0: - return_var[ilat, ilon, 0] = data_help[0] - return_var[ilat, ilon, 1] = data_help[0] - return_var[ilat, ilon, 2] = data_help[0] - return_var[ilat, ilon, 3] = data_help[0] - else: - data_hits = data_help < threshold - [events, spei_sum] = runs_of_ones_array_spei(data_hits, - data_help) - - return_var[ilat, ilon, 0] = np.count_nonzero(events) - return_var[ilat, ilon, 1] = np.mean(events) - return_var[ilat, ilon, 2] = np.mean((spei_sum * events) / - (np.mean(data_help - [data_hits]) - * np.mean(events))) - return_var[ilat, ilon, 3] = np.mean(spei_sum / events) - - return return_var - - -def get_latlon_index(coords, lim1, lim2): - """Get index for given values between two limits (1D), e.g. lats, lons.""" - index = (np.where(np.absolute(coords - (lim2 + lim1) - / 2.0) <= (lim2 - lim1) - / 2.0))[0] - return index - - -def plot_map_spei_multi(cfg, data_dict, input_filenames, colormap='jet'): - """Plot contour maps for multi model mean.""" - spei = np.ma.array(data_dict['data'], mask=np.isnan(data_dict['data'])) - - # Get latitudes and longitudes from cube - lons = data_dict['longitude'] - if max(lons) > 180.0: - lons = np.where(lons > 180, lons - 360, lons) - # sort the array - index = np.argsort(lons) - lons = lons[index] - spei = spei[np.ix_(range(data_dict['latitude'].size), index)] - - # Plot data - # Create figure and axes instances - subplot_kw = {'projection': cart.PlateCarree(central_longitude=0.0)} - fig, axx = plt.subplots(figsize=(6.5, 4), subplot_kw=subplot_kw) - axx.set_extent([-180.0, 180.0, -90.0, 90.0], - cart.PlateCarree(central_longitude=0.0)) - - # Draw filled contours - cnplot = plt.contourf(lons, data_dict['latitude'], spei, - data_dict['drought_numbers_level'], - transform=cart.PlateCarree(central_longitude=0.0), - cmap=colormap, extend='both', corner_mask=False) - # Draw coastlines - axx.coastlines() - - # Add colorbar - cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation='horizontal') - - # Add colorbar title string - if data_dict['model_kind'] == 'Difference': - cbar.set_label(data_dict['model_kind'] + ' ' - + data_dict['drought_char'] + ' [%]') - else: - cbar.set_label(data_dict['model_kind'] + ' ' - + data_dict['drought_char']) - - # Set labels and title to each plot - axx.set_xlabel('Longitude') - axx.set_ylabel('Latitude') - axx.set_title(data_dict['datasetname'] + ' ' + data_dict['model_kind'] + - ' ' + data_dict['drought_char']) - - # Sets number and distance of x ticks - axx.set_xticks(np.linspace(-180, 180, 7)) - # Sets strings for x ticks - axx.set_xticklabels(['180°W', '120°W', '60°W', - '0°', '60°E', '120°E', - '180°E']) - # Sets number and distance of y ticks - axx.set_yticks(np.linspace(-90, 90, 7)) - # Sets strings for y ticks - axx.set_yticklabels(['90°S', '60°S', '30°S', '0°', - '30°N', '60°N', '90°N']) - - fig.tight_layout() - fig.savefig(get_plot_filename(cfg['indexname'] + '_map' + - data_dict['filename'] + '_' + - data_dict['datasetname'], cfg), dpi=300) - plt.close() - - _provenance_map_spei_multi(cfg, data_dict, spei, input_filenames) - - -def plot_map_spei(cfg, cube, levels, name_dict): - """Plot contour map.""" - mask = np.isnan(cube.data) - spei = np.ma.array(cube.data, mask=mask) - np.ma.masked_less_equal(spei, 0) - - # Get latitudes and longitudes from cube - name_dict.update({'latitude': cube.coord('latitude').points}) - lons = cube.coord('longitude').points - lons = np.where(lons > 180, lons - 360, lons) - # sort the array - index = np.argsort(lons) - lons = lons[index] - name_dict.update({'longitude': lons}) - spei = spei[np.ix_(range(len(cube.coord('latitude').points)), index)] - - # Get data set name from cube - try: - dataset_name = cube.metadata.attributes['model_id'] - except KeyError: - try: - dataset_name = cube.metadata.attributes['source_id'] - except KeyError: - dataset_name = 'Observations' - - # Plot data - # Create figure and axes instances - subplot_kw = {'projection': cart.PlateCarree(central_longitude=0.0)} - fig, axx = plt.subplots(figsize=(8, 4), subplot_kw=subplot_kw) - axx.set_extent([-180.0, 180.0, -90.0, 90.0], - cart.PlateCarree(central_longitude=0.0)) - - # np.set_printoptions(threshold=np.nan) - - # Draw filled contours - cnplot = plt.contourf(lons, cube.coord('latitude').points, spei, - levels, - transform=cart.PlateCarree(central_longitude=0.0), - cmap='gnuplot', extend='both', corner_mask=False) - # Draw coastlines - axx.coastlines() - - # Add colorbar - cbar = fig.colorbar(cnplot, ax=axx, shrink=0.6, orientation='horizontal') - - # Add colorbar title string - cbar.set_label(name_dict['name']) - - # Set labels and title to each plot - axx.set_xlabel('Longitude') - axx.set_ylabel('Latitude') - axx.set_title(dataset_name + ' ' + name_dict['name']) - - # Sets number and distance of x ticks - axx.set_xticks(np.linspace(-180, 180, 7)) - # Sets strings for x ticks - axx.set_xticklabels(['180°W', '120°W', '60°W', - '0°', '60°E', '120°E', - '180°E']) - # Sets number and distance of y ticks - axx.set_yticks(np.linspace(-90, 90, 7)) - # Sets strings for y ticks - axx.set_yticklabels(['90°S', '60°S', '30°S', '0°', - '30°N', '60°N', '90°N']) - - fig.tight_layout() - - fig.savefig(get_plot_filename(cfg['indexname'] + '_map' + - name_dict['add_to_filename'] + '_' + - dataset_name, cfg), dpi=300) - plt.close() - - _provenance_map_spei(cfg, name_dict, spei, dataset_name) - - -def plot_time_series_spei(cfg, cube, filename, add_to_filename=''): - """Plot time series.""" - # SPEI vector to plot - spei = cube.data - # Get time from cube - time = cube.coord('time').points - # Adjust (ncdf) time to the format matplotlib expects - add_m_delta = mda.datestr2num('1850-01-01 00:00:00') - time = time + add_m_delta - - # Get data set name from cube - try: - dataset_name = cube.metadata.attributes['model_id'] - except KeyError: - try: - dataset_name = cube.metadata.attributes['source_id'] - except KeyError: - dataset_name = 'Observations' - - data_dict = {'data': spei, - 'time': time, - 'var': cfg['indexname'], - 'dataset_name': dataset_name, - 'unit': '1', - 'filename': filename, - 'area': add_to_filename} - - fig, axx = plt.subplots(figsize=(16, 4)) - axx.plot_date(time, spei, '-', tz=None, xdate=True, ydate=False, - color='r', linewidth=4., linestyle='-', alpha=1., - marker='x') - axx.axhline(y=-2, color='k') - - # Plot labels and title - axx.set_xlabel('Time') - axx.set_ylabel(cfg['indexname']) - axx.set_title('Mean ' + cfg['indexname'] + ' ' + - data_dict['dataset_name'] + ' ' - + data_dict['area']) - - # Set limits for y-axis - axx.set_ylim(-4.0, 4.0) - - # Often improves the layout - fig.tight_layout() - # Save plot to file - fig.savefig(get_plot_filename(cfg['indexname'] + - '_time_series_' + - data_dict['area'] + - '_' + - data_dict['dataset_name'], cfg), dpi=300) - plt.close() - - _provenance_time_series_spei(cfg, data_dict) diff --git a/esmvaltool/diag_scripts/droughtindex/collect_drought_model.py b/esmvaltool/diag_scripts/droughtindex/collect_drought_model.py deleted file mode 100644 index da0c64bd40..0000000000 --- a/esmvaltool/diag_scripts/droughtindex/collect_drought_model.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - - -"""Collects SPI or SPEI data comparing historic and future model scenarios. - -Applies drought characteristics based on Martin (2018). - -############################################################################### -droughtindex/collect_spei.py -Author: Katja Weigel (IUP, Uni Bremen, Germany) -EVal4CMIP project -############################################################################### - -Description ------------ - Collects data produced by diag_save_spi.R or diad_save_spei_all.R - to plot/process them further. - -Configuration options ---------------------- - indexname: "SPI" or "SPEI" - start_year: year, start of historical time series - end_year: year, end of future scenario - comparison_period: should be < (end_year - start_year)/2 - -############################################################################### - -""" -# The imported modules and libraries below allow this script to run accordingly - -import os -import glob -import datetime -import iris -import numpy as np -import esmvaltool.diag_scripts.shared as e -import esmvaltool.diag_scripts.shared.names as n -from esmvaltool.diag_scripts.droughtindex.collect_drought_func import ( - _get_drought_data, _plot_multi_model_maps, _plot_single_maps) - - -def _get_and_plot_multimodel(cfg, cube, all_drought, input_filenames): - """Calculate multi-model mean and compare historic and future.""" - all_drought_mean = {} - for tstype in ['Historic', 'Future']: - all_drought_mean[tstype] = np.nanmean(all_drought[tstype], axis=-1) - - all_drought_mean['Difference'] = ((all_drought_mean['Future'] - - all_drought_mean['Historic']) / - (all_drought_mean['Future'] + - all_drought_mean['Historic']) * 200) - - # Plot multi model means - for tstype in ['Historic', 'Future', 'Difference']: - _plot_multi_model_maps(cfg, all_drought_mean[tstype], - [cube.coord('latitude').points, - cube.coord('longitude').points], - input_filenames, - tstype) - - -def _set_tscube(cfg, cube, time, tstype): - """Time slice from a cube with start/end given by cfg.""" - if tstype == 'Future': - # extract time series from rcp model data - # cfg['end_year'] - cfg['comparison_period'] to cfg['end_year'] - start = datetime.datetime(cfg['end_year'] - - cfg['comparison_period'], 1, 15, 0, 0, 0) - end = datetime.datetime(cfg['end_year'], 12, 16, 0, 0, 0) - elif tstype == 'Historic': - # extract time series from historical model data - # cfg['start_year'] to cfg['start_year'] + cfg['comparison_period'] - start = datetime.datetime(cfg['start_year'], 1, 15, 0, 0, 0) - end = datetime.datetime(cfg['start_year'] + - cfg['comparison_period'], 12, 16, 0, 0, 0) - stime = time.nearest_neighbour_index(time.units.date2num(start)) - etime = time.nearest_neighbour_index(time.units.date2num(end)) - tscube = cube[stime:etime, :, :] - return tscube - - -def main(cfg): - """Run the diagnostic. - - Parameters : - - ------------ - cfg : dict - """ - ###################################################################### - # Read recipe data - ###################################################################### - - # Make an aggregator from the user function. - # spell_no = Aggregator('spell_count', count_spells, - # units_func=lambda units: 1) - - # Define the parameters of the test. - first_run = 1 - - # Get filenames of input files produced by diag_spei.r - # input_filenames = (cfg[n.INPUT_FILES])[0] + "/*.nc" - input_filenames = (cfg[n.INPUT_FILES])[0] + "/*_" + \ - (cfg['indexname']).lower() + "_*.nc" - - for iii, spei_file in enumerate(glob.iglob(input_filenames)): - # Loads the file into a special structure (IRIS cube), - # which allows us to access the data and additional information - # with python. - cube = iris.load(spei_file)[0] - # lats = cube.coord('latitude').points - # lons = cube.coord('longitude').points - time = cube.coord('time') - # The data are 3D (time x latitude x longitude) - # To plot them, we need to reduce them to 2D or 1D - # First here is an average over time. - cube2 = cube.collapsed('time', iris.analysis.MEAN) # 3D to 2D - - if first_run == 1: - ncfiles = list(filter(lambda f: f.endswith('.nc'), - os.listdir((cfg[n.INPUT_FILES])[0]))) - all_drought = {} - all_drought['Historic'] = np.full(cube2.data.shape + (4,) + - (len(ncfiles),), np.nan) - all_drought['Future'] = np.full(cube2.data.shape + (4,) + - (len(ncfiles),), np.nan) - first_run = 0 - # Test if time series goes until cfg['end_year']/12 - timecheck = time.units.date2num(datetime.datetime(cfg['end_year'], - 11, 30, 0, 0, 0)) - - if time.points[-1] > timecheck: - for tstype in ['Historic', 'Future']: - tscube = _set_tscube(cfg, cube, time, tstype) - drought_show = _get_drought_data(cfg, tscube) - all_drought[tstype][:, :, :, iii] = drought_show.data - _plot_single_maps(cfg, cube2, drought_show, tstype, spei_file) - - # Calculating multi model mean and plot it - _get_and_plot_multimodel(cfg, cube, all_drought, - glob.glob(input_filenames)) - - -if __name__ == '__main__': - with e.run_diagnostic() as config: - main(config) diff --git a/esmvaltool/diag_scripts/droughtindex/collect_drought_obs_multi.py b/esmvaltool/diag_scripts/droughtindex/collect_drought_obs_multi.py deleted file mode 100644 index 6091b507bf..0000000000 --- a/esmvaltool/diag_scripts/droughtindex/collect_drought_obs_multi.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - - -"""Collects SPI or SPEI data comparing models and observations/reanalysis. - -Applies drought characteristics based on Martin (2018). - -############################################################################### -droughtindex/collect_drought_obs_multi.py -Author: Katja Weigel (IUP, Uni Bremen, Germany) -EVal4CMIP project -############################################################################### - -Description ------------ - Collects data produced by diag_save_spi.R or diad_save_spei_all.R - to plot/process them further. - -Configuration options ---------------------- - indexname: "SPI" or "SPEI" - -############################################################################### - -""" -import os -import glob -import iris -import numpy as np -import esmvaltool.diag_scripts.shared as e -import esmvaltool.diag_scripts.shared.names as n -from esmvaltool.diag_scripts.droughtindex.collect_drought_func import ( - _get_drought_data, _plot_multi_model_maps, _plot_single_maps, - get_latlon_index, plot_time_series_spei) - - -def _get_and_plot_obsmodel(cfg, cube, all_drought, all_drought_obs, - input_filenames): - """Calculate multi-model mean and compare it to observations.""" - lats = cube.coord('latitude').points - lons = cube.coord('longitude').points - all_drought_hist_mean = np.nanmean(all_drought, axis=-1) - perc_diff = ((all_drought_obs - all_drought_hist_mean) - / (all_drought_obs + all_drought_hist_mean) * 200) - - # Plot multi model means - _plot_multi_model_maps(cfg, all_drought_hist_mean, [lats, lons], - input_filenames, 'Historic') - _plot_multi_model_maps(cfg, all_drought_obs, [lats, lons], - input_filenames, 'Observations') - _plot_multi_model_maps(cfg, perc_diff, [lats, lons], - input_filenames, 'Difference') - - -def ini_time_series_plot(cfg, cube, area, filename): - """Set up cube for time series plot.""" - coords = ('longitude', 'latitude') - if area == 'Bremen': - index_lat = get_latlon_index(cube.coord('latitude').points, 52, 53) - index_lon = get_latlon_index(cube.coord('longitude').points, 7, 9) - elif area == 'Nigeria': - index_lat = get_latlon_index(cube.coord('latitude').points, 7, 9) - index_lon = get_latlon_index(cube.coord('longitude').points, 8, 10) - - cube_grid_areas = iris.analysis.cartography.area_weights( - cube[:, index_lat[0]:index_lat[-1] + 1, - index_lon[0]:index_lon[-1] + 1]) - cube4 = ((cube[:, index_lat[0]:index_lat[-1] + 1, - index_lon[0]:index_lon[-1] + - 1]).collapsed(coords, iris.analysis.MEAN, - weights=cube_grid_areas)) - - plot_time_series_spei(cfg, cube4, filename, area) - - -def main(cfg): - """Run the diagnostic. - - Parameters : - - ------------ - cfg : dict - Configuration dictionary of the recipe. - - """ - ####################################################################### - # Read recipe data - ####################################################################### - - # Get filenames of input files produced by diag_spei.r - # "cfg[n.INPUT_FILES]" is produced by the ESMValTool and contains - # information on the SPEI input files produced by diag_spei.r - input_filenames = (cfg[n.INPUT_FILES])[0] + "/*_" + \ - (cfg['indexname']).lower() + "_*.nc" - print(cfg.keys()) - first_run = 1 - iobs = 0 - - # For loop: "glob.iglob" findes all files which match the - # pattern of "input_filenames". - # It writes the resulting exact file name onto spei_file - # and runs the following indented lines for all possibilities - # for spei_file. - for iii, spei_file in enumerate(glob.iglob(input_filenames)): - # Loads the file into a special structure (IRIS cube) - cube = iris.load(spei_file)[0] - cube.coord('latitude').guess_bounds() - cube.coord('longitude').guess_bounds() - # time = cube.coord('time') - - # The data are 3D (time x latitude x longitude) - # To plot them, we need to reduce them to 2D or 1D - # First here is an average over time, i.e. data you need - # to plot the average over the time series of SPEI on a map - cube2 = cube.collapsed('time', iris.analysis.MEAN) - - # This is only possible because all data must be on the same grid - if first_run == 1: - files = os.listdir((cfg[n.INPUT_FILES])[0]) - ncfiles = list(filter(lambda f: f.endswith('.nc'), files)) - shape_all = cube2.data.shape + (4,) + \ - (len(ncfiles) - 1, ) - all_drought = np.full(shape_all, np.nan) - first_run = 0 - - ini_time_series_plot(cfg, cube, 'Bremen', spei_file) - ini_time_series_plot(cfg, cube, 'Nigeria', spei_file) - - drought_show = _get_drought_data(cfg, cube) - - # Distinguish between model and observations/reanalysis. - # Collest all model data in one array. - try: - dataset_name = cube.metadata.attributes['model_id'] - all_drought[:, :, :, iii - iobs] = drought_show.data - except KeyError: - try: - dataset_name = cube.metadata.attributes['source_id'] - all_drought[:, :, :, iii - iobs] = drought_show.data - except KeyError: - dataset_name = 'Observations' - all_drought_obs = drought_show.data - iobs = 1 - print(dataset_name) - _plot_single_maps(cfg, cube2, drought_show, 'Historic', spei_file) - - # Calculating multi model mean and plot it - _get_and_plot_obsmodel(cfg, cube, all_drought, all_drought_obs, - glob.glob(input_filenames)) - - -if __name__ == '__main__': - with e.run_diagnostic() as config: - main(config) diff --git a/esmvaltool/diag_scripts/droughtindex/diag_save_spi.R b/esmvaltool/diag_scripts/droughtindex/diag_save_spi.R deleted file mode 100644 index 151e2d5ed2..0000000000 --- a/esmvaltool/diag_scripts/droughtindex/diag_save_spi.R +++ /dev/null @@ -1,127 +0,0 @@ -library(yaml) -library(ncdf4) -library(SPEI) -library(RColorBrewer) # nolint - -getnc <- function(yml, m, lat = FALSE) { - id <- nc_open(yml[m][[1]]$filename, readunlim = FALSE) - if (lat){ - v <- ncvar_get(id, "lat") - }else{ - v <- ncvar_get(id, yml[m][[1]]$short_name) - } - nc_close(id) - return(v) -} - -ncwritespi <- function(yml, m, data, wdir){ - fnam <- strsplit(yml[m][[1]]$filename, "/")[[1]] - pcs <- strsplit(fnam[length(fnam)], "_")[[1]] - pcs[which(pcs == yml[m][[1]]$short_name)] <- "spi" - onam <- paste0(wdir, "/", paste(pcs, collapse = "_")) - ncid_in <- nc_open(yml[m][[1]]$filename) - # var <- ncid_in$var[[yml[m][[1]]$short_name]] - xdim <- ncid_in$dim[["lon"]] - ydim <- ncid_in$dim[["lat"]] - tdim <- ncid_in$dim[["time"]] - allatt <- ncatt_get(ncid_in, "pr") - fillvalue <- ncatt_get(ncid_in,"pr","_FillValue") - globat <- ncatt_get(ncid_in, 0) - fillfloat <- 1.e+20 - as.single(fillfloat) - var_spi <- ncvar_def("spi", "1", list(xdim, ydim, tdim), fillfloat) - idw <- nc_create(onam, var_spi) - ncvar_put(idw, "spi", data) - cntatt <- 1 - for (thisattname in names(globat)){ - ncatt_put(idw, 0, thisattname, globat[[cntatt]]) - cntatt <- cntatt + 1 - } - nc_close(idw) - nc_close(ncid_in) - return(onam) -} - -whfcn <- function(x, ilow, ihigh){ - return(length(which(x >= ilow & x < ihigh))) -} - -args <- commandArgs(trailingOnly = TRUE) -params <- read_yaml(args[1]) -metadata <- read_yaml(params$input_files) -modfile <- names(metadata) -wdir <- params$work_dir -dir.create(wdir, recursive = TRUE) -rundir <- params$run_dir -pdir <- params$plot_dir -dir.create(pdir, recursive = TRUE) -var1_input <- read_yaml(params$input_files[1]) - -nmods <- length(names(var1_input)) - -fillfloat <- 1.e+20 -as.single(fillfloat) - -# setup provenance file and list -provenance_file <- paste0(rundir, "/", "diagnostic_provenance.yml") -provenance <- list() - - -refnam <- var1_input[1][[1]]$reference_dataset -n <- 1 -while (n <= nmods){ - if (var1_input[n][[1]]$dataset == refnam) break - n <- n + 1 -} -nref <- n -lat <- getnc(var1_input, nref, lat = TRUE) -if (max(lat) > 90){ - print(paste0("Latitude must be [-90,90]: min=", - min(lat), " max=", max(lat))) - stop("Aborting!") -} -ref <- getnc(var1_input, nref, lat = FALSE) -refmsk <- apply(ref, c(1, 2), FUN = mean, na.rm = TRUE) -refmsk[refmsk > 10000] <- fillfloat -refmsk[!is.na(refmsk)] <- 1 - -xprov <- list( - ancestors = list(""), - authors = list("weigel_katja"), - references = list("mckee93proc"), - projects = list("eval4cmip"), - caption = "", - statistics = list("other"), - realms = list("atmos"), - themes = list("phys"), - domains = list("global") -) - -for (mod in 1:nmods){ - v1 <- getnc(var1_input, mod) - d <- dim(v1) - v1_spi <- array(fillfloat, dim=d) - for (i in 1:d[1]){ - wh <- which(!is.na(refmsk[i,])) - if (length(wh) > 0){ - tmp <- v1[i,wh,] - v1_spi[i,wh,] <- t(spi(t(tmp), params$smooth_month, na.rm = TRUE, - distribution = params$distribution)$fitted) - } - } - v1_spi[is.infinite(v1_spi)] <- fillfloat - v1_spi[is.na(v1_spi)] <- fillfloat - v1_spi[v1_spi > 10000] <- fillfloat - filename <- ncwritespi(var1_input, mod, v1_spi, wdir) - xprov$caption <- "SPI index per grid point." - xprov$ancestors <- list(modfile[mod]) - provenance[[filename]] <- xprov - print("provenance[[filename]] kwnew") - print(provenance[[filename]]) -} - -print("provenance_file kwnew") -print(provenance_file) -print("provenance kwnew") -print(provenance) -write_yaml(provenance, provenance_file) diff --git a/esmvaltool/diag_scripts/droughtindex/diag_spei.R b/esmvaltool/diag_scripts/droughtindex/diag_spei.R deleted file mode 100644 index 7c1e90b53e..0000000000 --- a/esmvaltool/diag_scripts/droughtindex/diag_spei.R +++ /dev/null @@ -1,298 +0,0 @@ -library(yaml) -library(ncdf4) -library(SPEI) -library(RColorBrewer) # nolint - -leap_year <- function(year) { - return(ifelse((year %% 4 == 0 & year %% 100 != 0) | - year %% 400 == 0, TRUE, FALSE)) -} - -getnc <- function(yml, m, lat = FALSE) { - id <- nc_open(yml[m][[1]]$filename, readunlim = FALSE) - if (lat) { - v <- ncvar_get(id, "lat") - } else { - v <- ncvar_get(id, yml[m][[1]]$short_name) - if (yml[m][[1]]$short_name == "tas") { - v <- v - 273.15 - } - if (yml[m][[1]]$short_name == "pr") { - time <- ncvar_get(id, "time") - tcal <- ncatt_get(id, "time", attname = "calendar") - tunits <- ncatt_get(id, "time", attname = "units") - tustr <- strsplit(tunits$value, " ") - stdate <- as.Date(time[1], origin = unlist(tustr)[3]) - nddate <- - as.Date(time[length(time)], origin = unlist(tustr)[3]) - if (tcal$value == "365_day") { - # Correct for missing leap years in nddate - diff <- as.numeric(nddate - stdate, units = "days") - dcorr <- floor((diff / 365 - diff / 365.25) * 365.25) - nddate <- nddate + dcorr - } - if (tcal$value == "360_day") { - v <- v * 30 * 24 * 3600. - } else { - cnt <- 1 - monarr <- c(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) - date <- stdate - while (date <= nddate) { - year <- as.numeric(substr(date, 1, 4)) - lpyear <- leap_year(year) - month <- as.numeric(substr(date, 6, 7)) - mdays <- monarr[month] - pdays <- mdays - if (month == 2 & lpyear == TRUE) { - pdays <- 29 - if (tcal$value != "365_day") { - mdays <- 29 - } else { - mdays <- 28 - } - } - v[, , cnt] <- v[, , cnt] * mdays * 24 * 3600. - date <- date + pdays - cnt <- cnt + 1 - } - } - } - } - nc_close(id) - return(v) -} - -ncwritenew <- function(yml, m, hist, wdir, bins) { - fnam <- strsplit(yml[m][[1]]$filename, "/")[[1]] - pcs <- strsplit(fnam[length(fnam)], "_")[[1]] - pcs[which(pcs == yml[m][[1]]$short_name)] <- "spei" - onam <- paste(pcs, collapse = "_") - onam <- paste0(wdir, "/", strsplit(onam, ".nc"), "_hist.nc") - ncid_in <- nc_open(yml[m][[1]]$filename) - var <- ncid_in$var[[yml[m][[1]]$short_name]] - xdim <- ncid_in$dim[["lon"]] - ydim <- ncid_in$dim[["lat"]] - hdim <- ncdim_def("bins", "level", bins[1:(length(bins) - 1)]) - hdim2 <- ncdim_def("binsup", "level", bins[2:length(bins)]) - var_hist <- - ncvar_def("hist", "counts", list(xdim, ydim, hdim), NA) - idw <- nc_create(onam, var_hist) - ncvar_put(idw, "hist", hist) - nc_close(idw) - return(onam) -} - -whfcn <- function(x, ilow, ihigh) { - return(length(which(x >= ilow & x < ihigh))) -} - -dothornthwaite <- function(v, lat) { - print("Estimating PET with Thornthwaite method.") - dpet <- v * NA - d <- dim(dpet) - for (i in 1:d[2]) { - tmp <- v[, i, ] - tmp2 <- thornthwaite(t(tmp), rep(lat[i], d[1]), na.rm = TRUE) - d2 <- dim(tmp2) - tmp2 <- as.numeric(tmp2) - dim(tmp2) <- d2 - dpet[, i, ] <- t(tmp2) - } - return(dpet) -} - -args <- commandArgs(trailingOnly = TRUE) -params <- read_yaml(args[1]) -metadata1 <- read_yaml(params$input_files[1]) -metadata2 <- read_yaml(params$input_files[2]) -modfile1 <- names(metadata1) -modfile2 <- names(metadata2) -wdir <- params$work_dir -rundir <- params$run_dir -dir.create(wdir, recursive = TRUE) -pdir <- params$plot_dir -dir.create(pdir, recursive = TRUE) -var1_input <- read_yaml(params$input_files[1]) -var2_input <- read_yaml(params$input_files[2]) -nmods <- length(names(var1_input)) - -# setup provenance file and list -provenance_file <- paste0(rundir, "/", "diagnostic_provenance.yml") -provenance <- list() - -histbrks <- c(-99999, -2, -1.5, -1, 1, 1.5, 2, 99999) -histnams <- c( - "Extremely dry", - "Moderately dry", - "Dry", - "Neutral", - "Wet", - "Moderately wet", - "Extremely wet" -) -refnam <- var1_input[1][[1]]$reference_dataset -n <- 1 -while (n <= nmods) { - if (var1_input[n][[1]]$dataset == refnam) { - break - } - n <- n + 1 -} -nref <- n -lat <- getnc(var1_input, nref, lat = TRUE) -if (max(lat) > 90) { - print(paste0( - "Latitude must be [-90,90]: min=", - min(lat), " max=", max(lat) - )) - stop("Aborting!") -} -ref <- getnc(var1_input, nref, lat = FALSE) -refmsk <- apply(ref, c(1, 2), FUN = mean, na.rm = TRUE) -refmsk[refmsk > 10000] <- NA -refmsk[!is.na(refmsk)] <- 1 - -xprov <- list( - ancestors = list(""), - authors = list("berg_peter"), - references = list("vicente10jclim"), - projects = list("c3s-magic"), - caption = "", - statistics = list("other"), - realms = list("atmos"), - themes = list("phys"), - domains = list("global") -) - -histarr <- array(NA, c(nmods, length(histnams))) -for (mod in 1:nmods) { - lat <- getnc(var1_input, mod, TRUE) - v1 <- getnc(var1_input, mod, FALSE) - v2 <- getnc(var2_input, mod, FALSE) - if (var1_input[1][[1]]$short_name == "pr") { - prtas <- TRUE - } else { - prtas <- FALSE - } - if (prtas) { - pet <- dothornthwaite(v2, lat) - pme <- v1 - pet - } else { - pet <- dothornthwaite(v1, lat) - pme <- v2 - pet - } - print(var1_input[mod][[1]]$cmor_table) - d <- dim(pme) - pme_spei <- pme * NA - for (i in 1:d[1]) { - wh <- which(!is.na(refmsk[i, ])) - if (length(wh) > 0) { - tmp <- pme[i, wh, ] - pme_spei[i, wh, ] <- t(spei(t(tmp), 1, na.rm = TRUE)$fitted) - } - } - pme_spei[is.infinite(pme_spei)] <- NA - pme_spei[pme_spei > 10000] <- NA - hist_spei <- array(NA, c(d[1], d[2], length(histbrks) - 1)) - for (nnh in 1:(length(histbrks) - 1)) { - hist_spei[, , nnh] <- apply( - pme_spei, - c(1, 2), - FUN = whfcn, - ilow = histbrks[nnh], - ihigh = histbrks[nnh + 1] - ) - } - filename <- - ncwritenew(var1_input, mod, hist_spei, wdir, histbrks) - # Set provenance for output files - xprov$caption <- "Histogram of SPEI index per grid point." - xprov$ancestors <- list(modfile1[mod], modfile2[mod]) - provenance[[filename]] <- xprov - for (t in 1:d[3]) { - tmp <- pme_spei[, , t] - tmp[is.na(refmsk)] <- NA - pme_spei[, , t] <- tmp - } - pme_spei[is.infinite(pme_spei)] <- NA - pme_spei[pme_spei > 10000] <- NA - # Weight against latitude - h <- seq_along(histnams) * 0 - for (j in 1:d[2]) { - h <- h + hist(pme_spei[j, , ], - breaks = histbrks, - plot = FALSE - )$counts * cos(lat[j] * pi / 180.) - } - histarr[mod, ] <- h / sum(h, na.rm = TRUE) -} -filehist <- paste0(params$work_dir, "/", "histarr.rsav") -save(histarr, file = filehist) -plot_file <- paste0(params$plot_dir, "/", "histplot.png") -xprov$caption <- "Global latitude-weighted histogram of SPEI index." -xprov$ancestors <- c(modfile1, modfile2) -provenance[[plot_file]] <- xprov -provenance[[filehist]] <- xprov -write_yaml(provenance, provenance_file) - -bhistarr <- array(NA, c(nmods - 1, 7)) -marr <- c(1:nmods)[c(1:nmods) != nref] -cnt <- 1 -for (m in marr) { - bhistarr[cnt, ] <- histarr[m, ] - histarr[nref, ] - cnt <- cnt + 1 -} -parr <- c(nref, marr) - -mnam <- c(1:nmods) * NA -for (m in 1:nmods) { - mnam[m] <- var1_input[m][[1]]$dataset -} - -qual_col_pals <- - brewer.pal.info[brewer.pal.info$category == "qual", ] # nolint -col_vector <- - unlist(mapply( - brewer.pal, qual_col_pals$maxcolors, # nolint - rownames(qual_col_pals) - )) -cols <- c("black", sample(col_vector, nmods - 1)) - -png(plot_file, width = 1000, height = 500) -par( - mfrow = c(2, 1), - oma = c(3, 3, 3, 13), - mar = c(2, 1, 1, 1) -) -barplot( - histarr[parr, ], - beside = 1, - names.arg = histnams, - col = cols, - xaxs = "i" -) -box() -mtext("Probability", side = 2, line = 2.1) -barplot( - bhistarr, - beside = 1, - names.arg = histnams, - col = cols[2:nmods], - xaxs = "i" -) -box() -mtext("Absolute difference", side = 2, line = 2.1) -mtext( - "Standardized precipitation-evapotranspiration index", - outer = TRUE, - cex = 2, - font = 2 -) -par( - fig = c(0.8, .95, 0.1, 0.9), - new = T, - oma = c(1, 1, 1, 1) * 0, - mar = c(0, 0, 0, 0) -) -legend("topright", mnam[parr], fill = cols) -dev.off() diff --git a/esmvaltool/diag_scripts/droughtindex/diag_spi.R b/esmvaltool/diag_scripts/droughtindex/diag_spi.R deleted file mode 100644 index b35c2b21e9..0000000000 --- a/esmvaltool/diag_scripts/droughtindex/diag_spi.R +++ /dev/null @@ -1,212 +0,0 @@ -library(yaml) -library(ncdf4) -library(SPEI) -library(RColorBrewer) # nolint - -getnc <- function(yml, m, lat = FALSE) { - id <- nc_open(yml[m][[1]]$filename, readunlim = FALSE) - if (lat) { - v <- ncvar_get(id, "lat") - } else { - v <- ncvar_get(id, yml[m][[1]]$short_name) - } - nc_close(id) - return(v) -} - -ncwritenew <- function(yml, m, hist, wdir, bins) { - fnam <- strsplit(yml[m][[1]]$filename, "/")[[1]] - pcs <- strsplit(fnam[length(fnam)], "_")[[1]] - pcs[which(pcs == yml[m][[1]]$short_name)] <- "spi" - onam <- paste(pcs, collapse = "_") - onam <- paste0(wdir, "/", strsplit(onam, ".nc"), "_hist.nc") - ncid_in <- nc_open(yml[m][[1]]$filename) - var <- ncid_in$var[[yml[m][[1]]$short_name]] - xdim <- ncid_in$dim[["lon"]] - ydim <- ncid_in$dim[["lat"]] - hdim <- ncdim_def("bins", "level", bins[1:(length(bins) - 1)]) - hdim2 <- ncdim_def("binsup", "level", bins[2:length(bins)]) - var_hist <- - ncvar_def("hist", "counts", list(xdim, ydim, hdim), NA) - idw <- nc_create(onam, var_hist) - ncvar_put(idw, "hist", hist) - nc_close(idw) - return(onam) -} - -whfcn <- function(x, ilow, ihigh) { - return(length(which(x >= ilow & x < ihigh))) -} - -args <- commandArgs(trailingOnly = TRUE) -params <- read_yaml(args[1]) -metadata <- read_yaml(params$input_files) -modfile <- names(metadata) -wdir <- params$work_dir -rundir <- params$run_dir -dir.create(wdir, recursive = TRUE) -pdir <- params$plot_dir -dir.create(pdir, recursive = TRUE) -var1_input <- read_yaml(params$input_files[1]) -nmods <- length(names(var1_input)) - -# setup provenance file and list -provenance_file <- paste0(rundir, "/", "diagnostic_provenance.yml") -provenance <- list() - -histbrks <- c(-99999, -2, -1.5, -1, 1, 1.5, 2, 99999) -histnams <- c( - "Extremely dry", - "Moderately dry", - "Dry", - "Neutral", - "Wet", - "Moderately wet", - "Extremely wet" -) -refnam <- var1_input[1][[1]]$reference_dataset -n <- 1 -while (n <= nmods) { - if (var1_input[n][[1]]$dataset == refnam) { - break - } - n <- n + 1 -} -nref <- n -lat <- getnc(var1_input, nref, lat = TRUE) -if (max(lat) > 90) { - print(paste0( - "Latitude must be [-90,90]: min=", - min(lat), " max=", max(lat) - )) - stop("Aborting!") -} -ref <- getnc(var1_input, nref, lat = FALSE) -refmsk <- apply(ref, c(1, 2), FUN = mean, na.rm = TRUE) -refmsk[refmsk > 10000] <- NA -refmsk[!is.na(refmsk)] <- 1 - -xprov <- list( - ancestors = list(""), - authors = list("berg_peter"), - references = list("mckee93proc"), - projects = list("c3s-magic"), - caption = "", - statistics = list("other"), - realms = list("atmos"), - themes = list("phys"), - domains = list("global") -) - -histarr <- array(NA, c(nmods, length(histnams))) -for (mod in 1:nmods) { - v1 <- getnc(var1_input, mod) - print(var1_input[mod][[1]]$cmor_table) - d <- dim(v1) - v1_spi <- v1 * NA - for (i in 1:d[1]) { - wh <- which(!is.na(refmsk[i, ])) - if (length(wh) > 0) { - tmp <- v1[i, wh, ] - v1_spi[i, wh, ] <- t(spi(t(tmp), 1, - na.rm = TRUE, - distribution = "PearsonIII" - )$fitted) - } - } - v1_spi[is.infinite(v1_spi)] <- NA - v1_spi[v1_spi > 10000] <- NA - hist_spi <- array(NA, c(d[1], d[2], length(histbrks) - 1)) - for (nnh in 1:(length(histbrks) - 1)) { - hist_spi[, , nnh] <- apply(v1_spi, - c(1, 2), - FUN = whfcn, - ilow = histbrks[nnh], - ihigh = histbrks[nnh + 1] - ) - } - filename <- ncwritenew(var1_input, mod, hist_spi, wdir, histbrks) - # Set provenance for output files - xprov$caption <- "Histogram of SPI index per grid point." - xprov$ancestors <- modfile[mod] - provenance[[filename]] <- xprov - # Weight against latitude - h <- seq_along(histnams) * 0 - for (j in 1:d[2]) { - h <- h + hist(v1_spi[j, , ], - breaks = histbrks, - plot = FALSE - )$counts * cos(lat[j] * pi / 180.) - } - histarr[mod, ] <- h / sum(h, na.rm = TRUE) -} -filehist <- paste0(params$work_dir, "/", "histarr.rsav") -save(histarr, file = filehist) -plot_file <- paste0(params$plot_dir, "/", "histplot.png") -xprov$caption <- "Global latitude-weighted histogram of SPI index." -xprov$ancestors <- modfile -provenance[[plot_file]] <- xprov -provenance[[filehist]] <- xprov -write_yaml(provenance, provenance_file) - -bhistarr <- array(NA, c(nmods - 1, 7)) -marr <- c(1:nmods)[c(1:nmods) != nref] -cnt <- 1 -for (m in marr) { - bhistarr[cnt, ] <- histarr[m, ] - histarr[nref, ] - cnt <- cnt + 1 -} -parr <- c(nref, marr) - -mnam <- c(1:nmods) * NA -for (m in 1:nmods) { - mnam[m] <- var1_input[m][[1]]$dataset -} - -qual_col_pals <- - brewer.pal.info[brewer.pal.info$category == "qual", ] # nolint -col_vector <- - unlist(mapply( - brewer.pal, qual_col_pals$maxcolors, # nolint - rownames(qual_col_pals) - )) -cols <- c("black", sample(col_vector, nmods - 1)) - -png(plot_file, width = 1000, height = 500) -par( - mfrow = c(2, 1), - oma = c(3, 3, 3, 13), - mar = c(2, 1, 1, 1) -) -barplot( - histarr[parr, ], - beside = 1, - names.arg = histnams, - col = cols, - xaxs = "i" -) -box() -mtext("Probability", side = 2, line = 2.1) -barplot( - bhistarr, - beside = 1, - names.arg = histnams, - col = cols[2:nmods], - xaxs = "i" -) -box() -mtext("Absolute difference", side = 2, line = 2.1) -mtext( - "Standardized precipitation index", - outer = TRUE, - cex = 2, - font = 2 -) -par( - fig = c(0.8, .95, 0.1, 0.9), - new = T, - oma = c(0, 0, 0, 0), - mar = c(0, 0, 0, 0) -) -legend("topright", mnam[parr], fill = cols) -dev.off() diff --git a/esmvaltool/recipes/recipe_martin18grl.yml b/esmvaltool/recipes/recipe_martin18grl.yml deleted file mode 100644 index 8b109affb0..0000000000 --- a/esmvaltool/recipes/recipe_martin18grl.yml +++ /dev/null @@ -1,164 +0,0 @@ -# ESMValTool -# recipe_martin18grl.yml ---- -documentation: - title: "Drought characteristics following Martin (2018)" - description: | - Calculate the SPI and counting drought events following Martin (2018). - authors: - - weigel_katja - - adeniyi_kemisola - - references: - - martin18grl - - maintainer: - - weigel_katja - - projects: - - eval4cmip - -preprocessors: - preprocessor1: - regrid: - target_grid: 2.0x2.0 - scheme: linear - preprocessor2: - regrid: - target_grid: 2.0x2.0 - scheme: linear - -diagnostics: - diagnostic1: - variables: - pr: - reference_dataset: MIROC-ESM - preprocessor: preprocessor1 - field: T2Ms - start_year: 1901 - end_year: 2000 - units: mm day-1 - additional_datasets: - # - {dataset: ERA-Interim, project: OBS6, mip: Amon, type: reanaly, - # version: 1, start_year: 1979, end_year: 2005, tier: 3} - # - {dataset: MIROC-ESM, project: CMIP5, mip: Amon, exp: historical, - # ensemble: r1i1p1, start_year: 1979, end_year: 2005} - # - {dataset: GFDL-CM3, project: CMIP5, mip: Amon, - # exp: historical, ensemble: r1i1p1, - # start_year: 1979, end_year: 2005} - - {dataset: CRU, mip: Amon, project: OBS, type: reanaly, - version: TS4.02, tier: 2} - - {dataset: ACCESS1-0, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: ACCESS1-3, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: CNRM-CM5, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: BNU-ESM, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: GFDL-CM3, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: GFDL-ESM2G, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: GISS-E2-H, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: HadGEM2-CC, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: IPSL-CM5A-LR, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: IPSL-CM5A-MR, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: IPSL-CM5B-LR, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: MIROC-ESM, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: MPI-ESM-MR, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: MRI-ESM1, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - {dataset: NorESM1-M, project: CMIP5, mip: Amon, exp: historical, - ensemble: r1i1p1} - - scripts: - script1: - script: droughtindex/diag_save_spi.R - smooth_month: 6 - # distribution: "Gamma" usually for SPI. - # distribution: "log-Logistic" usually for SPEI- - # Also available distribution: "PearsonIII" - distribution: "Gamma" - - spi_collect: - description: Wrapper to collect and plot previously calculated SPEI index - scripts: - spi_collect: - script: droughtindex/collect_drought_obs_multi.py - indexname: "SPI" - # Threshold under which an event is defined as drought. - # Usually -2.0 for SPI and SPEI. - threshold: -2.0 - ancestors: ['diagnostic1/script1'] - - - diagnostic2: - variables: - pr: - reference_dataset: MIROC-ESM - preprocessor: preprocessor2 - field: T2Ms - mip: Amon - project: CMIP5 - exp: [historical, rcp85] - start_year: 1950 - end_year: 2100 - additional_datasets: - - {dataset: ACCESS1-0, ensemble: r1i1p1} - - {dataset: ACCESS1-3, ensemble: r1i1p1} - - {dataset: CNRM-CM5, ensemble: r1i1p1} - - {dataset: BNU-ESM, ensemble: r1i1p1} - - {dataset: GFDL-CM3, ensemble: r1i1p1} - - {dataset: GFDL-ESM2G, ensemble: r1i1p1} - - {dataset: GISS-E2-H, ensemble: r1i1p1} - - {dataset: HadGEM2-CC, ensemble: r1i1p1} - - {dataset: IPSL-CM5A-LR, ensemble: r1i1p1} - - {dataset: IPSL-CM5A-MR, ensemble: r1i1p1} - - {dataset: IPSL-CM5B-LR, ensemble: r1i1p1} - - {dataset: MIROC-ESM, ensemble: r1i1p1} - - {dataset: MPI-ESM-MR, ensemble: r1i1p1} - - {dataset: MRI-ESM1, exp: [esmHistorical, esmrcp85], ensemble: r1i1p1} - - {dataset: NorESM1-M, ensemble: r1i1p1} - # - {dataset: MIROC-ESM, project: CMIP5, mip: Amon, - # exp: [historical, rcp85], ensemble: r1i1p1, - # start_year: 1950, end_year: 2100} - # - {dataset: GFDL-CM3, project: CMIP5, mip: Amon, - # exp: [historical, rcp85], ensemble: r1i1p1, - # start_year: 1950, end_year: 2100} - # - {dataset: IPSL-CM5A-LR, project: CMIP5, mip: Amon, - # exp: [historical, rcp85], ensemble: r1i1p1, - # start_year: 1950, end_year: 2100} - # - {dataset: MRI-ESM1, project: CMIP5, mip: Amon, - # exp: [esmHistorical, esmrcp85], ensemble: r1i1p1, - # start_year: 1950, end_year: 2100} - scripts: - script2: - script: droughtindex/diag_save_spi.R - smooth_month: 6 - # distribution: "Gamma" usually for SPI. - # distribution: "log-Logistic" usually for SPEI. - # Also available distribution: "PearsonIII". - distribution: "Gamma" - - spi_collect2: - description: Wrapper to collect and plot previously calculated SPI index - scripts: - spi_collect2: - script: droughtindex/collect_drought_model.py - start_year: 1950 - end_year: 2100 - # comparison_period should be < (end_year - start_year)/2 - comparison_period: 50 - indexname: "SPI" - # Threshold under which an event is defined as drought. - # Usually -2.0 for SPI and SPEI. - threshold: -2.0 - ancestors: ['diagnostic2/script2'] diff --git a/esmvaltool/recipes/recipe_spei.yml b/esmvaltool/recipes/recipe_spei.yml deleted file mode 100644 index b6ecae3681..0000000000 --- a/esmvaltool/recipes/recipe_spei.yml +++ /dev/null @@ -1,64 +0,0 @@ -# ESMValTool -# recipe_spei.yml ---- -documentation: - title: Drought indices SPI and SPEI - - description: | - Calculates the SPI and SPEI drought indices - - authors: - - berg_peter - - maintainer: - - weigel_katja - - projects: - - c3s-magic - - references: - - acknow_project - -datasets: -# - {dataset: CRU, project: OBS, type: reanaly, version: 1, tier: 3} - - {dataset: ERA-Interim, project: OBS6, type: reanaly, version: 1, tier: 3} - - {dataset: ACCESS1-0, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: ACCESS1-0, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: ACCESS1-3, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: BNU-ESM, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: CNRM-CM5, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: EC-EARTH, project: CMIP5, exp: historical, ensemble: r12i1p1} -# - {dataset: GFDL-CM3, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: GISS-E2-H, project: CMIP5, exp: historical, ensemble: r6i1p1} -# - {dataset: HadGEM2-CC, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: HadGEM2-ES, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: inmcm4, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: IPSL-CM5A-LR, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: IPSL-CM5A-MR, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: IPSL-CM5B-LR, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: MPI-ESM-MR, project: CMIP5, exp: historical, ensemble: r1i1p1} -# - {dataset: NorESM1-M, project: CMIP5, exp: historical, ensemble: r1i1p1} - -preprocessors: - preprocessor: - regrid: - target_grid: reference_dataset - scheme: linear - -diagnostics: - diagnostic: - description: Calculating SPI and SPEI index - variables: - pr: &var - reference_dataset: ERA-Interim - preprocessor: preprocessor - start_year: 2000 - end_year: 2005 - mip: Amon - tas: *var - scripts: - spi: - script: droughtindex/diag_spi.R - ancestors: [pr] - spei: - script: droughtindex/diag_spei.R From 0eb835a26a384b18f18d0aff77cb8747e60383b4 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 3 Mar 2025 17:04:02 +0100 Subject: [PATCH 45/66] fix codacy one more time --- esmvaltool/diag_scripts/droughts/utils.py | 28 +++++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 213667f456..01e5414993 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -29,7 +29,6 @@ import esmvaltool.diag_scripts.shared.names as n from esmvaltool.diag_scripts.droughts.constants import ( CMIP6_FNAME, - INDEX_META, OBS_FNAME, ) from esmvaltool.diag_scripts.shared import ( @@ -44,7 +43,8 @@ log = logging.getLogger(Path(__file__).name) -### GENERAL HELPER ### +# GENERAL HELPER + def mkplotdir(cfg: dict, dname: str | Path) -> None: """Create a sub directory for plots if it does not exist.""" @@ -132,6 +132,7 @@ def save_metadata(cfg: dict, metadata: dict) -> None: with (Path(cfg["work_dir"]) / "metadata.yml").open("w") as wom: yaml.dump(metadata, wom) + def fix_interval(interval: dict) -> dict: """Ensure that an interval has a label and a range. @@ -150,7 +151,8 @@ def log_provenance(cfg: dict, fname: str | Path, record: dict) -> None: provenance_logger.log(fname, record) -### DATA PROCESSING ### +# DATA PROCESSING + def merge_list_cube( cube_list: list, @@ -227,12 +229,12 @@ def fix_longitude(cube: Cube, coord="longitude") -> Cube: """ # make sure coords are -180 to 180 fixed_lons = [ - lon if lon < 180 else lon - 360 - for lon in cube.coord(coord).points + lon if lon < 180 else lon - 360 for lon in cube.coord(coord).points ] lon_dim = cube.coord_dims(cube.coord(coord))[0] cube.add_aux_coord( - iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), lon_dim, + iris.coords.AuxCoord(fixed_lons, long_name="fixed_lon"), + lon_dim, ) cube = sort_cube(cube, coord="fixed_lon") # set new coordinates as dimcoords, add new dim and remove old and aux @@ -266,6 +268,7 @@ def date_to_months(date: str, start_year: int) -> int: years, months = [int(x) for x in date.split("-")] return int(12 * (years - start_year) + months) + def remove_attributes( cube: Cube | iris.Coord, ignore: list | None = None, @@ -432,7 +435,7 @@ def runs_of_ones_array_spei(bits, spei) -> list: (run_ends,) = np.where(difs < 0) spei_sum = np.full(len(run_starts), 0.5) for iii, indexs in enumerate(run_starts): - spei_sum[iii] = np.sum(spei[indexs: run_ends[iii]]) + spei_sum[iii] = np.sum(spei[indexs : run_ends[iii]]) return [run_ends - run_starts, spei_sum] @@ -492,7 +495,7 @@ def slice_cube_interval(cube: Cube, interval: list) -> Cube: For 3D cubes time needs to be first dim. """ if isinstance(interval[0], int) and isinstance(interval[1], int): - return cube[interval[0]: interval[1], :, :] + return cube[interval[0] : interval[1], :, :] dt_start = dt.datetime.strptime(interval[0], "%Y-%m") dt_end = dt.datetime.strptime(interval[1], "%Y-%m") time = cube.coord("time") @@ -605,7 +608,8 @@ def transpose_by_names(cube: Cube, names: list) -> None: cube.transpose(new_dims) -### META DATA ### +# META DATA + def fold_meta( cfg: dict, @@ -809,8 +813,10 @@ def select_single_metadata( return None return selected_meta[0] + select_single_meta = select_single_metadata + def sub_cfg(cfg: dict, plot: str, key: str) -> dict: """Get get merged general and plot type specific kwargs.""" if isinstance(cfg.get(key, {}), dict): @@ -823,6 +829,7 @@ def sub_cfg(cfg: dict, plot: str, key: str) -> dict: except KeyError: return cfg[key] + def guess_experiment(meta: dict) -> None: """Guess missing 'exp' in metadata from filename.""" exps = ["historical", "ssp126", "ssp245", "ssp370", "ssp585"] @@ -831,7 +838,8 @@ def guess_experiment(meta: dict) -> None: meta["exp"] = exp -### PLOT HELPER ### +# PLOT HELPER + def date_tick_layout( fig, From 37b8506eeed6557e0b0c902cc31a05fb221ce5c2 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 3 Mar 2025 17:12:38 +0100 Subject: [PATCH 46/66] remove whitespace --- esmvaltool/diag_scripts/droughts/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 01e5414993..4647137239 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -435,7 +435,7 @@ def runs_of_ones_array_spei(bits, spei) -> list: (run_ends,) = np.where(difs < 0) spei_sum = np.full(len(run_starts), 0.5) for iii, indexs in enumerate(run_starts): - spei_sum[iii] = np.sum(spei[indexs : run_ends[iii]]) + spei_sum[iii] = np.sum(spei[indexs: run_ends[iii]]) return [run_ends - run_starts, spei_sum] @@ -495,7 +495,7 @@ def slice_cube_interval(cube: Cube, interval: list) -> Cube: For 3D cubes time needs to be first dim. """ if isinstance(interval[0], int) and isinstance(interval[1], int): - return cube[interval[0] : interval[1], :, :] + return cube[interval[0]: interval[1], :, :] dt_start = dt.datetime.strptime(interval[0], "%Y-%m") dt_end = dt.datetime.strptime(interval[1], "%Y-%m") time = cube.coord("time") From 63282abae4c1dd272cde687683809962de62fab9 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 4 Mar 2025 09:51:34 +0100 Subject: [PATCH 47/66] correct diffmap doc page --- .../source/api/esmvaltool.diag_scripts.droughts/diffmap.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst index 10bdcecf8b..dc2840485e 100644 --- a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst +++ b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst @@ -1,10 +1,10 @@ .. _api.esmvaltool.diag_scripts.droughts.diffmap: -Calculation and plotting of drought metrics following Martin (2018) -=================================================================== +Difference Maps +=============== -.. automodule:: esmvaltool.diag_scripts.droughts.collect_drought +.. automodule:: esmvaltool.diag_scripts.droughts.diffmap :no-members: :no-inherited-members: :no-show-inheritance: \ No newline at end of file From 2c03e24b4c7205183034a38f4842890ddb418cc4 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Thu, 6 Mar 2025 16:24:31 +0100 Subject: [PATCH 48/66] add titles, sort config options --- esmvaltool/diag_scripts/droughts/diffmap.py | 99 +++++++++++---------- 1 file changed, 52 insertions(+), 47 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index e302f9b1da..92644b9753 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -15,15 +15,33 @@ Configuration options in recipe ------------------------------- -plot_mmm: bool, optional (default: True) - Calculate and plot the average over all datasets. -plot_models: bool, optional (default: True) - Plot maps for each dataset. basename: str, optional Format string for the plot filename. Can use meta keys and diffmap_metric. For multi-model mean the dataset will be set to "MMM". Data will be saved as same name with .nc extension. By default: "{short_name}_{exp}_{diffmap_metric}_{dataset}" +clip_land: bool, optional (default: False) + Clips map plots to non polar land area (220, 170, -55, 90). +comparison_period: int, optional (default: 10) + Number of years to compare (first and last N years). Must be less or equal + half of the total time period. +filters: dict, or list, optional + Filter for metadata keys to select datasets. Only datasets with matching + values will be processed. This can be usefull, if ancestors or preprocessed + data is abailable, that should not be processed by the diagnostic. + If a list of dicts is given, all datasets matching any of the filters will + be considered. + By default None. +group_by: str, optional (default: short_name) + Meta key to loop over for multiple datasets. +metrics: list, optional + List of metrics to calculate and plot. For the difference ("percent" and + "diff") the mean over two comparison periods ("first" and "last") is + calculated. The "total" periods mean can be calculated and plotted as well. + By default ["first", "last", "diff", "total", "percent"] +mdtol: float, optional (default: 0.5) + Tolerance for missing data in multi-model mean calculation. 0 means no + missing data is allowed. For 1 mean is calculated if any data is available. plot_kwargs: dict, optional Kwargs passed to diag_scripts.shared.plot.global_contourf function. The "cbar_label" parameter is formatted with meta keys. So placeholders @@ -37,30 +55,19 @@ All other given keys are applied to the plot_kwargs dict for this plot. Settings will be applied in order of the list, so later entries can overwrite previous ones. -comparison_period: int, optional (default: 10) - Number of years to compare (first and last N years). Must be less or equal - half of the total time period. -group_by: str, optional (default: short_name) - Meta key to loop over for multiple datasets. -clip_land: bool, optional (default: False) - Clips map plots to non polar land area (220, 170, -55, 90). +plot_mmm: bool, optional (default: True) + Calculate and plot the average over all datasets. +plot_models: bool, optional (default: True) + Plot maps for each dataset. strip_plots: bool, optional (default: False) Removes titles, margins and colorbars from plots (to use them in panels). -mdtol: float, optional (default: 0.5) - Tolerance for missing data in multi-model mean calculation. 0 means no - missing data is allowed. For 1 mean is calculated if any data is available. -metrics: list, optional - List of metrics to calculate and plot. For the difference ("percent" and - "diff") the mean over two comparison periods ("first" and "last") is - calculated. The "total" periods mean can be calculated and plotted as well. - By default ["first", "last", "diff", "total", "percent"] -filters: dict, or list, optional - Filter for metadata keys to select datasets. Only datasets with matching - values will be processed. This can be usefull, if ancestors or preprocessed - data is abailable, that should not be processed by the diagnostic. - If a list of dicts is given, all datasets matching any of the filters will - be considered. - By default None. +titles: dict, optional + Customize plot titles for different metrics. Possible dict keys are + "first", "last", "trend", "diff", "total", "percent". The values are + formatted using meta data. Placeholders like "{short_name}" can be used. + By default {"first": "Mean Historical", "last": "Mean Future", + "trend": "Future - Historical", "diff": "Future - Historical", + "total": "Mean Full Period", "percent": "Relative Change"}. """ from __future__ import annotations @@ -79,6 +86,7 @@ from cartopy.util import add_cyclic_point from esmvalcore import preprocessor as pp from iris.analysis import MEAN +from iris.cube import Cube import esmvaltool.diag_scripts.droughts.utils as ut import esmvaltool.diag_scripts.shared as e @@ -88,15 +96,6 @@ log = logging.getLogger(__file__) -TITLES = { - "first": "Mean Historical", - "last": "Mean Future", - "trend": "Future - Historical", - "diff": "Future - Historical", - "total": "Mean Full Period", - "percent": "Relative Change", -} - METRICS = ["first", "last", "diff", "total", "percent"] @@ -139,10 +138,20 @@ def plot_colorbar( fig.savefig(plotfile + "_cb.png") # , bbox_inches="tight") +def fill_era5_gap(meta: dict, cube: Cube) -> None: + """Fill missing gap at 360 for era5 pet calculation.""" + if ( + meta["dataset"] == "ERA5" + and meta["short_name"] == "evspsblpot" + and len(cube.data[0]) == 360 + ): + cube.data[:, 359] = cube.data[:, 0] + + def plot( cfg: dict, meta: dict, - cube: iris.cube, + cube: Cube, basename: str, kwargs: dict | None = None, ) -> None: @@ -161,16 +170,10 @@ def plot( plot_kwargs["cbar_label"] = label.format(**meta) for coord in cube.coords(dim_coords=True): if not coord.has_bounds(): - log.info("NO BOUNDS GUESSING: %s", coord.name()) + log.warning("NO BOUNDS. GUESSING: %s", coord.name()) cube.coord(coord.name()).guess_bounds() + fill_era5_gap(meta, cube) add_cyclic_point(cube.data, cube.coord("longitude").points) - if ( - meta["dataset"] == "ERA5" - and meta["short_name"] == "evspsblpot" - and len(cube.data[0]) == 360 - ): - # NOTE: fill missing gap at 360 for era5 pet calculation - cube.data[:, 359] = cube.data[:, 0] mapplot = e.plot.global_contourf(cube, **plot_kwargs) if cfg.get("clip_land", False): plt.gca().set_extent((220, 170, -55, 90)) # type: ignore[attr-defined] @@ -211,7 +214,7 @@ def apply_plot_kwargs_overwrite( return kwargs -def calculate_diff(cfg, meta, mm_data, output_meta, group): +def calculate_diff(cfg, meta, mm_data, output_meta, group) -> None: """Absolute difference between first and last years of a cube. Calculates the absolut and relative difference between the first and last @@ -254,7 +257,7 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group): meta["diffmap_metric"] = key meta["exp"] = meta.get("exp", "exp") basename = cfg["basename"].format(**meta) - meta["title"] = cfg.get("titles", TITLES)[key].format(**meta) + meta["title"] = cfg["titles"][key].format(**meta) if cfg.get("plot_models", True): plot_kwargs = cfg.get("plot_kwargs", {}).copy() apply_plot_kwargs_overwrite( @@ -309,6 +312,9 @@ def set_defaults(cfg: dict) -> None: cfg.setdefault(key, val) if cfg["plot_kwargs_overwrite"] is not defaults["plot_kwargs_overwrite"]: cfg["plot_kwargs_overwrite"].extend(defaults["plot_kwargs_overwrite"]) + titles = defaults.get("titles", {}) + titles.update(cfg["titles"]) + cfg["titles"] = titles def filter_metas(metas: list, filters: dict | list) -> list: @@ -333,7 +339,6 @@ def main(cfg) -> None: for group, g_metas in groups.items(): mm_data = defaultdict(list) for meta in g_metas: - ut.guess_experiment(meta) # TODO: add in SPEI.R instead if "end_year" not in meta: meta.update(ut.get_time_range(meta["filename"])) # adjust norm for selected time period From 32a65d6fb50b6bd95474696c83af1c996f7c8c6b Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Thu, 6 Mar 2025 16:35:17 +0100 Subject: [PATCH 49/66] change martin title in docs --- .../api/esmvaltool.diag_scripts.droughts/collect_drought.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst index 2bae251c6c..270e0619f4 100644 --- a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst +++ b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst @@ -1,8 +1,8 @@ .. _api.esmvaltool.diag_scripts.droughts.collect_drought: -Calculation and plotting of drought metrics following Martin (2018) -=================================================================== +Drought Metrics following Martin (2018) +======================================= .. automodule:: esmvaltool.diag_scripts.droughts.collect_drought :no-members: From 5ca41793be423eac2b60ce657478a4cfba81c369 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Thu, 6 Mar 2025 17:20:35 +0100 Subject: [PATCH 50/66] init branch for lindenlaub25 --- .../recipes/droughts/recipe_lindenlaub25.rst | 29 ++ .../recipe_lindenlaub25_historical.yml | 347 ++++++++++++++ .../recipe_lindenlaub25_scenarios.yml | 443 ++++++++++++++++++ 3 files changed, 819 insertions(+) create mode 100644 doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst create mode 100644 esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml create mode 100644 esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml diff --git a/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst new file mode 100644 index 0000000000..8cba657f1a --- /dev/null +++ b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst @@ -0,0 +1,29 @@ + +.. _recipes_martin18grl: + +Agricultural Droughts in CMIP6 Future Projections +================================================= + +Overview +-------- + +The two recipes presented here evaulate historical simulations of 18 CMIP6 models and analyse their projections for three different future pathways. The results are published in Lindenlaub (2025). + + +Available recipes and diagnostics +--------------------------------- + +Recipes are stored in ``recipes/droughts/`` + + * recipe_lindenlaub25_historical.yml + * recipe_lindenlaub25_scenarios.yml + +Diagnostics used by this recipes: + + * :ref:`droughts/diffmap.py ` + + +References +---------- + +* Lindenlaub, L. (2025). Agricultural Droughts in CMIP6 Future Projections. Journal of Climate, 38(1), 1-15. https://doi.org/10.1029/2025JC012345 \ No newline at end of file diff --git a/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml new file mode 100644 index 0000000000..35d8944128 --- /dev/null +++ b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml @@ -0,0 +1,347 @@ +--- +documentation: + title: "Validation of drought related CMIP6 variables with ERA5" + description: | + This recipe calculates PET for ERA5 and a subset of CMIP6 models and + compares the results and each individual input variable and derived + soil moisture. For the comparison averages and change rates are plotted as + maps and pattern correlation between ERA5 and CMIP6 multi-model mean are + calculated. The normalized centered root mean square error vs ERA5 is + calculated for each individual variable and CMIP6 model and displayed as + portrait plot. + + The recipe is split into blocks that can be run individually by using the + `--diagnostics` argument, e.g. `pet_obs`, `validate_models/diffmaps` or + `validate_models/pattern_correlation` on the esmvaltool run command: + `esmvaltool run recipes/droughts/recipe_lindenlaub25_validation.yml` + authors: + - lindenlaub_lukas + maintainers: + - lindenlaub_lukas + projects: + - eval4cmip + +GRID: &grid + # target_grid: 3x3 + target_grid: 1x1 + +OBS_PERIOD: &obs_period + start_year: 1980 + end_year: 2014 + +CMIP_DEFAULT: &cmip_default + project: CMIP6 + mip: Amon + ensemble: r1i1p1f1 + grid: gn + + +# Subset of models with soil moisture: +CMIP6_DATA_LMON: &cmip6_data_lmon + - {<<: *cmip_default, mip: Lmon, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS } + - {<<: *cmip_default, mip: Lmon, dataset: BCC-CSM2-MR, institute: BCC } + - {<<: *cmip_default, mip: Lmon, dataset: CanESM5, institute: CCCma } + - {<<: *cmip_default, mip: Lmon, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name + - {<<: *cmip_default, mip: Lmon, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 + - {<<: *cmip_default, mip: Lmon, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} + - {<<: *cmip_default, mip: Lmon, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) + - {<<: *cmip_default, mip: Lmon, dataset: FIO-ESM-2-0, institute: FIO-QLNM } + - {<<: *cmip_default, mip: Lmon, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} + - {<<: *cmip_default, mip: Lmon, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} + - {<<: *cmip_default, mip: Lmon, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} + - {<<: *cmip_default, mip: Lmon, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} + - {<<: *cmip_default, mip: Lmon, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source + - {<<: *cmip_default, mip: Lmon, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} + - {<<: *cmip_default, mip: Lmon, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo + - {<<: *cmip_default, mip: Lmon, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 + + +CMIP6_DATA: &cmip6_data + - {<<: *cmip_default, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS} + - {<<: *cmip_default, dataset: AWI-CM-1-1-MR} + - {<<: *cmip_default, dataset: BCC-CSM2-MR, institute: BCC} + - {<<: *cmip_default, dataset: CanESM5, institute: CCCma} + - {<<: *cmip_default, dataset: CAS-ESM2-0, institute: CAS} + - {<<: *cmip_default, dataset: CMCC-ESM2} # retry other ds name + - {<<: *cmip_default, dataset: CNRM-CM6-1, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 + - {<<: *cmip_default, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} + - {<<: *cmip_default, dataset: FGOALS-g3, institute: CAS} # latitude as auxilary (not monotonic) + - {<<: *cmip_default, dataset: FIO-ESM-2-0, institute: FIO-QLNM} + - {<<: *cmip_default, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} + - {<<: *cmip_default, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} + - {<<: *cmip_default, dataset: INM-CM5-0, grid: gr1} # no mrsos? works for pr/tas pet 3x3 + - {<<: *cmip_default, dataset: INM-CM5-0, institute: INM, grid: gr1} # no mrsos? works for pr/tas + - {<<: *cmip_default, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} + - {<<: *cmip_default, dataset: KACE-1-0-G, grid: gr} + - {<<: *cmip_default, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source + - {<<: *cmip_default, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} + - {<<: *cmip_default, dataset: MRI-ESM2-0, institute: MRI} # also calendar issues.. but works solo + - {<<: *cmip_default, dataset: UKESM1-0-LL, ensemble: r1i1p1f2} # pet works 3x3 + + +ERA5: &era5 + dataset: ERA5 + project: native6 + type: reanaly + version: v1 + tier: 3 + <<: *obs_period + reference_for_metric: true + split: ERA5 + +CRU: &cru + dataset: CRU + project: OBS6 + type: reanaly + version: TS4.07 + tier: 2 + <<: *obs_period + split: REF2 + reference_for_metric: true + +GPCP-SG: &gpcp + dataset: GPCP-SG + project: OBS + type: atmos + version: 2.3 + tier: 2 + <<: *obs_period + reference_for_metric: true + +CDS: &cds + dataset: CDS-SATELLITE-SOIL-MOISTURE + project: OBS + type: sat + split: REF2 + tier: 3 + version: COMBINED + start_year: 1979 + end_year: 2014 + reference_for_metric: true + +# ERA5 and CRU data is available for most variables. PET is derived only for +# ERA5 and soil moisture is derived from ERA5 and CDS-SATELLITE-SOIL-MOISTURE +# for this variables datasets need to set manually and/or added. +# Also note that both variables are found in different mips. +OBS_DATA: &obs_data + - <<: *era5 + mip: Amon + - <<: *cru + mip: Amon + + +VAR_DEFAULT: &var_default + grid: gn + preprocessor: default + mip: Amon + project: CMIP6 + exp: historical + <<: *obs_period + + +# RECIPE STARTS HERE +preprocessors: + default: &default + regrid: + <<: *grid + scheme: nearest + regrid_time: + calendar: standard + mask_landsea: + mask_out: sea + mask_glaciated: + mask_out: glaciated + +diagnostics: + pet_historical: &pet_historical + variables: + pr: &pet_default + <<: *var_default + exp: ["historical"] + <<: *obs_period + additional_datasets: *cmip6_data + tasmin: *pet_default + tasmax: *pet_default + sfcWind: *pet_default + # clt: *pet_default + rsds: *pet_default + ps: *pet_default + scripts: &scripts_pet + pet_pm: + script: droughts/pet.R + pet_type: "Penman" + + spei_historical: &spei_historical + scripts: + spei: + script: droughts/spei.R + spei_type: "SPEI" + ancestors: + - pet_historical/pet_pm + - pet_historical/pr + smooth_month: 6 + distribution: "log-Logistic" + refstart_year: 1950 + refend_year: 2014 + refstart_month: 1 + refend_month: 12 + + pet_obs: &pet_obs + variables: + pr: &pet_default_obs + <<: *obs_period + preprocessor: default + mip: Amon + additional_datasets: + - <<: *era5 + tasmin: *pet_default_obs + tasmax: *pet_default_obs + sfcWind: *pet_default_obs + # clt: *pet_default_obs + rsds: *pet_default_obs + ps: *pet_default_obs + scripts: + pet_pm: + script: droughts/pet.R + pet_type: "Penman" # "Penman_clt" + + validate_obs: + variables: + sm: + additional_datasets: + - <<: *era5 + mip: Lmon + start_year: 1979 + end_year: 2014 + - <<: *cds + mip: Lmon + pr: &add_cru + mip: Amon + additional_datasets: + - <<: *cru + tasmin: *add_cru + tasmax: *add_cru + # clt: *add_cru + evspsblpot: + additional_datasets: + - <<: *cru + mip: Emon + scripts: + diffmaps: + script: droughts/diffmap.py + ancestors: + - pet_obs/pr + - pet_obs/tasmin + - pet_obs/tasmax + - pet_obs/sfcWind + - pet_obs/rsds + - pet_obs/ps + - validate_obs/pr + - validate_obs/sm + - validate_obs/tasmin + - validate_obs/tasmax + - pet_obs/pet_pm + - validate_obs/evspsblpot + + validate_models: &validate_models + # additional_datasets: *cmip6_data + variables: + # pr: &var_default_historical + # <<: *var_default + # <<: *obs_period + # exp: ["historical"] + # additional_datasets: *cmip6_data + # tasmin: + # <<: *var_default_historical + # tasmax: + # <<: *var_default_historical + sm: + <<: *var_default + mip: Lmon + start_year: 1979 + end_year: 2014 + derive: true + exp: ["historical"] + additional_datasets: *cmip6_data_lmon + # sfcWind: + # <<: *var_default_historical + # ps: + # <<: *var_default_historical + scripts: + diffmaps: + script: droughts/diffmap.py + plot_models: False + save_models: True + plot_mmm: True + save_mmm: True + ancestors: + - pet_historical/pr + - pet_historical/tasmin + - pet_historical/tasmax + - pet_historical/sfcWind + - pet_historical/rsds + - pet_historical/ps + - pet_historical/pet_pm + - validate_models/sm + + pattern_correlation: + reference: ERA5 + relative_change: true + group_by: diffmap_metric + script: droughts/pattern_correlation.py + labels: + pr: $P$ + evspsblpot: $ET_0$ + tasmax: $T_{\text{max}}$ + tasmin: $T_{\text{min}}$ + sfcWind: $V_{\text{10m}}$ + clt: $C_{\text{total}}$ + rsds: $RS_{\text{down}}$ + ps: $p_{\text{surf}}$ + sm: $SM_{\text{surf}}$ + ancestors: + - validate_models/diffmaps + - validate_obs/diffmaps + perfmetric: + script: perfmetrics/portrait_plot.py + distance_metric: rmse + nan_color: null + y_labels: + pr: $PR$ + evspsblpot: $ET_0$ + tasmax: $T_{\text{max}}$ + tasmin: $T_{\text{min}}$ + sfcWind: $V_{\text{10m}}$ + ps: $p_{\text{surf}}$ + sm: $SM_{\text{surf}}$ + rsds: $RS_{\text{down}}$ + y_order: + - pr + - evspsblpot + - tasmax + - tasmin + - sfcWind + - rsds + - ps + - sm + ancestors: + - pet_obs/pet_pm + - pet_obs/pr + - pet_obs/tasmin + - pet_obs/tasmax + - pet_obs/ps + - pet_obs/rsds + - pet_obs/sfcWind + - pet_historical/pet_pm + - pet_historical/pr + - pet_historical/tasmin + - pet_historical/tasmax + - pet_historical/ps + - pet_historical/rsds + - pet_historical/sfcWind + - validate_obs/pr + - validate_obs/tasmin + - validate_obs/tasmax + - validate_obs/evspsblpot + - validate_obs/sm + - validate_models/sm \ No newline at end of file diff --git a/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml b/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml new file mode 100644 index 0000000000..36fe11eb4f --- /dev/null +++ b/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml @@ -0,0 +1,443 @@ +--- +documentation: + title: "Analysis of droughts in CMIP6 future projections." + description: | + This recipe calculates PET and SPEI values for CMIP6 future projections. + 12 models for the SSP126, SSP245 and SSP585 scenarios are used. + The reference period for index calibration 1950-2014 is part of the + historical experiment. + The indices are analysed using a couple of diagnostics: + - `*_ssp*` for maps of average and change rates of variables per scenarios. + - `pet_*, spei_*` for PET and index calculation. + - `*_trend` for trend maps of PET and SPEI. + authors: + - ruhe_lukas + maintainers: + - ruhe_lukas + projects: + - eval4cmip + +GRID: &grid + # target_grid: 0.25x0.25 + target_grid: 1x1 # might require a lot of memory (limit parallel tasks) + + +# longest period for CMIP6 only analysis +FULL_PERIOD: &full_period + start_year: 1950 + end_year: 2100 + +# future period for projected CMIP6 variables +SSP_PERIOD: &ssp_period + start_year: 2015 + end_year: 2100 + + +DIFFMAPS_DEFAULT: &diffmaps_default + script: droughtindex/diffmap.py + plot_models: False + plot_mmm: True + clip_land: True + strip_plots: True + + +CMIP_DEFAULT: &cmip_default + project: CMIP6 + mip: Amon + ensemble: r1i1p1f1 + grid: gn + + +CMIP6_DATA_LMON: &cmip6_data_lmon # no awi or inm + - {<<: *cmip_default, mip: Lmon, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS } + - {<<: *cmip_default, mip: Lmon, dataset: BCC-CSM2-MR, institute: BCC } + - {<<: *cmip_default, mip: Lmon, dataset: CanESM5, institute: CCCma } + - {<<: *cmip_default, mip: Lmon, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name + - {<<: *cmip_default, mip: Lmon, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 + - {<<: *cmip_default, mip: Lmon, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} + - {<<: *cmip_default, mip: Lmon, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) + # - {<<: *cmip_default, mip: Lmon, dataset: FIO-ESM-2-0, institute: FIO-QLNM } # cmor error in mrsos + # - {<<: *cmip_default, mip: Lmon, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} # cmor error sdepths + - {<<: *cmip_default, mip: Lmon, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} + - {<<: *cmip_default, mip: Lmon, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} + - {<<: *cmip_default, mip: Lmon, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} + - {<<: *cmip_default, mip: Lmon, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source + - {<<: *cmip_default, mip: Lmon, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} + - {<<: *cmip_default, mip: Lmon, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo + - {<<: *cmip_default, mip: Lmon, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 + + +CMIP6_DATA: &cmip6_data + # trying to add: + # - {<<: *cmip_default, dataset: HadGEM3-GC31-LL, ensemble: r1i1p1f3} # only -MM in picontrol but only -LL in ssp245 + - {<<: *cmip_default, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS } + - {<<: *cmip_default, dataset: AWI-CM-1-1-MR, institute: AWI } + - {<<: *cmip_default, dataset: BCC-CSM2-MR, institute: BCC } + - {<<: *cmip_default, dataset: CanESM5, institute: CCCma } + - {<<: *cmip_default, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name + - {<<: *cmip_default, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 + - {<<: *cmip_default, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} + - {<<: *cmip_default, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) + - {<<: *cmip_default, dataset: FIO-ESM-2-0, institute: FIO-QLNM } + - {<<: *cmip_default, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} + - {<<: *cmip_default, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} + - {<<: *cmip_default, dataset: INM-CM5-0, institute: INM, grid: gr1} # no mrsos? works for pr/tas + - {<<: *cmip_default, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} + - {<<: *cmip_default, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} + - {<<: *cmip_default, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source + - {<<: *cmip_default, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} + - {<<: *cmip_default, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo + - {<<: *cmip_default, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 + + + +VAR_DEFAULT: &var_default + grid: gn + # ensemble: r1i1p1f1 + mip: Amon + project: CMIP6 + additional_datasets: *cmip6_data + +VAR_SM: &var_sm + grid: gn + mip: Lmon + project: CMIP6 + additional_datasets: *cmip6_data_lmon + +preprocessors: + default: + regrid: + <<: *grid + scheme: nearest + regrid_time: + calendar: standard + mask_landsea: + mask_out: sea + mask_glaciated: + mask_out: glaciated + + +diagnostics: + ssp_maps: + variables: + pr: &ssp585_variable + <<: *var_default + exp: ssp585 + <<: *ssp_period + tasmax: *ssp585_variable + tasmin: *ssp585_variable + # sm: *var_sm + scripts: + diffmaps: &pr_plot + <<: *diffmaps_default + + sm_ssp585: &sm_ssp585 + variables: + sm: &sm_default + <<: *var_default + exp: ssp585 + <<: *ssp_period + mip: Lmon + derive: true + additional_datasets: *cmip6_data_lmon + scripts: + diffmaps: &sm_plot + <<: *diffmaps_default + # plot_kwargs_diff: + # vmin: -0.04 + # vmax: 0.04 + # cmap: "RdYlBu" + sm_ssp245: + variables: + sm: + <<: *sm_default + exp: ssp245 + scripts: + diffmaps: + <<: *diffmaps_default + sm_ssp126: + variables: + sm: + <<: *sm_default + exp: ssp126 + scripts: + diffmaps: + <<: *diffmaps_default + + # --- FULL PERIOD SPEI --- # + # TODO: test with python repo + pet_ssp585: &pet_ssp585 + variables: + pr: &pet_585 + <<: *var_default + exp: ["historical", "ssp585"] + <<: *full_period + additional_datasets: *cmip6_data + tasmin: *pet_585 + tasmax: *pet_585 + sfcWind: *pet_585 + # clt: *pet_585 + rsds: *pet_585 + ps: *pet_585 + scripts: + pet_pm: + script: droughtindex/diag_pet.R + pet_type: "Penman" # "Penman_clt" + pet_ssp245: &pet_ssp245 + variables: + pr: &pet_245 + <<: *var_default + exp: ["historical", "ssp245"] + <<: *full_period + additional_datasets: *cmip6_data + tasmin: *pet_245 + tasmax: *pet_245 + sfcWind: *pet_245 + # clt: *pet_245 + rsds: *pet_245 + ps: *pet_245 + scripts: + pet_pm: + script: droughtindex/diag_pet.R + pet_type: "Penman" # "Penman_clt" + pet_ssp126: &pet_ssp126 + variables: + pr: &pet_126 + <<: *var_default + exp: ["historical", "ssp126"] + <<: *full_period + additional_datasets: *cmip6_data + tasmin: *pet_126 + tasmax: *pet_126 + sfcWind: *pet_126 + # clt: *pet_126 + rsds: *pet_126 + ps: *pet_126 + scripts: + pet_pm: + script: droughtindex/diag_pet.R + pet_type: "Penman" # "Penman_clt" + + spei_ssp585: &spei_ssp585 + scripts: + spei: &script_spei_585 + script: droughtindex/diag_spei.R + smooth_month: 6 + distribution: "log-Logistic" + refstart_year: 1950 + refend_year: 2014 + refstart_month: 1 + refend_month: 12 + ancestors: [pet_ssp585/pet_pm, pet_ssp585/pr] + spei_ssp245: &spei_ssp245 + scripts: + spei: + <<: *script_spei_585 + ancestors: [pet_ssp245/pet_pm, pet_ssp245/pr] + spei_ssp126: &spei_ssp126 + scripts: + spei: + <<: *script_spei_585 + ancestors: [pet_ssp126/pet_pm, pet_ssp126/pr] + + # --- EXTRA WB --- # + wb_ssp126: &wb_ssp126 + scripts: + wb: + script: droughtindex/diag_wb.py + ancestors: [pet_ssp126/pet_pm, pet_ssp126/pr] + wb_ssp245: &wb_ssp245 + scripts: + wb: + script: droughtindex/diag_wb.py + ancestors: [pet_ssp245/pet_pm, pet_ssp245/pr] + wb_ssp585: &wb_ssp585 + scripts: + wb: + script: droughtindex/diag_wb.py + ancestors: [pet_ssp585/pet_pm, pet_ssp585/pr] + # --- SPEI Plots --- # + diffmap: + scripts: + ssp: &diff_ssp_default + # THIS does not work for different scenarios as only looped over group by and + # not scenarios. The loop to calc multi model means need to be aware of exp (hardcoded) + # or allow multiple group_by keys as combination.. (or based on basename?) + # until than use diffmap/spei_ssp126.. runs + script: droughtindex/diffmap.py + ancestors: + - pet_ssp126/pr + - pet_ssp245/pr + - pet_ssp585/pr + - pet_ssp126/pet_pm + - pet_ssp245/pet_pm + - pet_ssp585/pet_pm + - spei_ssp126/spei + - spei_ssp245/spei + - spei_ssp585/spei + save_models: True + save_mmm: True + plot_models: False + plot_mmm: True + <<: *ssp_period + plot_kwargs_diff: + cmap: YlOrRd + vmax: 6e-5 + vmin: 0 + ssp585: &diff_pet_default + script: droughtindex/diffmap.py + ancestors: + - "pet_ssp585/pet_pm" + - "spei_ssp585/spei" + save_models: True + save_mmm: True + plot_models: False + plot_mmm: True + <<: *ssp_period + plot_kwargs_diff: + cmap: YlOrRd + vmax: 6e-5 + vmin: 0 + pet_ssp245: + <<: *diff_pet_default + ancestors: ["pet_ssp245/pet_pm"] + pet_ssp126: + <<: *diff_pet_default + ancestors: ["pet_ssp126/pet_pm"] + spei_ssp585: &diff_spei_default + script: droughtindex/diffmap.py + ancestors: ["spei_ssp585/spei"] + <<: *ssp_period + save_models: True + save_mmm: True + plot_models: False + plot_mmm: True + plot_kwargs_diff: + cmap: RdYlBu + vmax: 4 + vmin: -4 + spei_ssp245: + <<: *diff_spei_default + ancestors: ["spei_ssp245/spei"] + spei_ssp126: + <<: *diff_spei_default + ancestors: ["spei_ssp126/spei"] + wb_ssp126: &diff_wb_default + script: droughtindex/diffmap.py + ancestors: ["wb_ssp126/wb"] + <<: *ssp_period + save_models: False + save_mmm: True + plot_models: False + plot_mmm: True + wb_ssp245: + <<: *diff_wb_default + ancestors: ["wb_ssp245/wb"] + wb_ssp585: + <<: *diff_wb_default + ancestors: ["wb_ssp585/wb"] + event_area: + scripts: + ssps: + subplots: True + figsize: [9, 1.3] + yticks: [0, 0.2, 0.4, 0.6, 0.8, 1] + ylabels: + historical-ssp126: SSP1-2.6 + historical-ssp245: SSP2-4.5 + historical-ssp585: SSP5-8.5 + intervals: + - [240, 481] + # - [780, 1021] + - [1560, null] + reference_dataset: MIROC6 # to pick timeseries from + ancestors: [spei_ssp585/spei, spei_ssp245/spei, spei_ssp126/spei] + script: droughtindex/event_area_timeseries.py + interval: 240 + latest_legend: true + plot_models: False + index: spei + plot_kwargs: + baseline: "zero" + shapefile: ar6_regions/IPCC-WGI-reference-regions-v4.shp + # regions: ['WCE'] + regions: ®ions + scripts: + spei_ssp585: + ancestors: [diffmap/spei_ssp585] + select_metadata: + experiment: ssp585 + short_name: spei + dataset: MMM + diffmap_metric: diff + script: droughtindex/regional_hexagons.py + shapefile: ar6_regions/IPCC-WGI-reference-regions-v4.shp + exclude_regions: [] + statistics: [mean] + split_by: exp + labels: false + vmin: -0.4 + vmax: 0.4 + cmap: "RdYlBu" + strip_plot: true + trend_spei_scenarios: + ancestors: + - diffmap/spei_ssp126 + - diffmap/spei_ssp245 + - diffmap/spei_ssp585 + script: droughtindex/regional_hexagons.py + # TODO: use preproc instead of shapefile + shapefile: ar6_regions/IPCC-WGI-reference-regions-v4.shp + exclude_regions: [] + statistics: ['mean', 'std_dev'] + split_by: exp + vmin: -0.4 + vmax: 0.4 + cmap: "RdYlBu" + statistics: + scripts: + histogram: + script: droughtindex/distribution.py + clip_land: true + # comparison_period: 20 + ancestors: + - spei_ssp126/spei + - spei_ssp245/spei + - spei_ssp585/spei + strip_plots: true + plot_mmm: true + plot_models: false + plot_regions: true + plog_global: false + sort_regions: true + split_by: exp + plot_kwargs: + alpha: 0.75 + plot_properties: + ylim: (0, 0.45) + + + timeseries: + scripts: + scenarios: + script: droughtindex/timeseries.py + ancestors: + - pet_ssp126/pr + - pet_ssp245/pr + - pet_ssp585/pr + - pet_ssp126/pet_pm + - pet_ssp245/pet_pm + - pet_ssp585/pet_pm + - spei_ssp126/spei + - spei_ssp245/spei + - spei_ssp585/spei + plot_models: False + strip_plots: True + save_mm: True + reuse_mm: True + smooth: True + figsize: [9, 2] + y_labels: + pr: $PR$ [mm/day] + evspsblpot: $ET_0$ [mm/day] + spei: $SPEI$ \ No newline at end of file From 4ffc13fb2966a37cbfb93c18d05d3672e7c669c9 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 7 Mar 2025 11:47:07 +0100 Subject: [PATCH 51/66] remove prints, fix params defaults --- esmvaltool/diag_scripts/droughts/pet.R | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/pet.R b/esmvaltool/diag_scripts/droughts/pet.R index 8e858e2064..56345fe154 100644 --- a/esmvaltool/diag_scripts/droughts/pet.R +++ b/esmvaltool/diag_scripts/droughts/pet.R @@ -66,8 +66,6 @@ calculate_hargreaves <- function(metas, xprov, use_pr=FALSE) { } dpet <- data$tasmin * NA for (i in 1:dim(dpet)[2]) { - print("IS TS?") - print(is.ts(t(data$tasmin[, i, ]))) pet_tmp <- hargreaves( t(data$tasmin[, i, ]), t(data$tasmax[, i, ]), @@ -123,7 +121,6 @@ calculate_penman <- function(metas, xprov, method="ICID", crop="tall") { # load relevant variables for(meta in metas){ if (meta$short_name %in% names(data)){ - print(meta$filename) data[[meta$short_name]] <- get_var_from_nc(meta) xprov$ancestors <- append(xprov$ancestors, meta$filename) if(meta$short_name == "tasmin"){ @@ -150,7 +147,7 @@ calculate_penman <- function(metas, xprov, method="ICID", crop="tall") { Rs = t_or_null(data$rsds[, i, ]), na.rm = TRUE, method = method, - crop = "tall" + crop = crop ) d2 <- dim(pet_tmp) pet_tmp <- as.numeric(pet_tmp) @@ -166,8 +163,8 @@ calculate_penman <- function(metas, xprov, method="ICID", crop="tall") { # ---------------------------------------------------------------------------- # params <- read_yaml(commandArgs(trailingOnly = TRUE)[1]) -ifelse(!is.null(params$method), params$method, "ICID") -ifelse(!is.null(params$crop), params$crop, "tall") +params$method = ifelse(!is.null(params$method), params$method, "ICID") +params$crop = ifelse(!is.null(params$crop), params$crop, "tall") dir.create(params$work_dir, recursive = TRUE) dir.create(params$plot_dir, recursive = TRUE) fillfloat <- 1.e+20 From 0941660c16cb1ff129409281fe41e3ccc5350bac Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 7 Mar 2025 11:48:09 +0100 Subject: [PATCH 52/66] add diagnostics from private --- .../diag_scripts/droughts/distribution.py | 470 ++++++++++++++ .../droughts/event_area_timeseries.py | 602 ++++++++++++++++++ .../droughts/event_area_timeseries.yml | 71 +++ .../droughts/pattern_correlation.py | 293 +++++++++ .../droughts/regional_hexagons.py | 379 +++++++++++ .../droughts/timeseries_scenarios.py | 294 +++++++++ 6 files changed, 2109 insertions(+) create mode 100644 esmvaltool/diag_scripts/droughts/distribution.py create mode 100644 esmvaltool/diag_scripts/droughts/event_area_timeseries.py create mode 100644 esmvaltool/diag_scripts/droughts/event_area_timeseries.yml create mode 100644 esmvaltool/diag_scripts/droughts/pattern_correlation.py create mode 100644 esmvaltool/diag_scripts/droughts/regional_hexagons.py create mode 100644 esmvaltool/diag_scripts/droughts/timeseries_scenarios.py diff --git a/esmvaltool/diag_scripts/droughts/distribution.py b/esmvaltool/diag_scripts/droughts/distribution.py new file mode 100644 index 0000000000..ff0f5214ba --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/distribution.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Creates histograms and regional boxplots for given timeperiods. + +Global histograms are plotted to compare distributions of any variable in given +intervals and/or by experiment. Combined experiments (historical-ssp*) will +be splitted into individual ones. + +NOTE: This diagnostic loads all values from all datasets into memory. If you +provide a lot of data make sure enough memory is available. + +TODO: Free up memory each split for global histograms only save bins and fit. + +Configuration options in recipe +------------------------------- +group_by: str, optional (default: short_name) + All input datasets are grouped by this metadata key. The diagnostic runs + for each of this groups. Consider adding group_by to the basename. +split_experiments: bool, optional (default: False) + If true, combined experiments like "historical-ssp126" get treated + individually as "historical" and "ssp126" when grouped. + NOTE: Only works for historical + scenarioMIP (cutted at 2014) yet. +split_by: str, optional (default: exp) + Create individual distributions for each split in the same figure. + Can be any metadata key or 'interval'. For 'interval' the corresponding + parameter must be given. + TODO: For 'exp' consider changing split_experiments. +intervals: list of dicts, optional (default: []) + List of dicts containing a `label` (optional) and a `range` + (timerange in ISO 8601 format. For example YYYY/YYYY) or + `start` and `end` (ISO 8601). + In the diagnostics `interval_label` and `interval_range` will be added + to metadata and can be used as split_by option and in basename. + NOTE: if splitted by experiment, the first interval is used for historical, + the second for scenarioMIP. For other splits this option is ignored. +regions: list of str, optional (default: []) + List of AR6 regions to extract data for histograms (all given regions) + and boxplots (each region). +sort_regions_by: str, optional (default: "") + Sort regions by median value of given split_by value. If empty no sorting. +basename: str, optional (default: '{plot_type}_{group}') + Filename for plot files. Can contain placeholders for group, plot_type + and interval_label or any other metadata key. +comparison_period: int, optional (default: 10) + Number of years to compare (first and last N years). Must be less or equal + half of the total time period. +plot_mmm: bool, optional (default: True) + Calculate and plot the average over all datasets. +plot_models: bool, optional (default: True) + Plot maps for each dataset. +plot_properties: dict, optional (default: {}) + Kwargs passed to all axes.set() functions. Can be styling of ticks, labels, + limits, grid, etc. See matplotlib documentation for all options: + https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.set.html + Use `histogram.plot_properties` or `regional_stats.plot_properties` + to specify kwargs for corresponding plots. +plot_kwargs: dict, optional (default: {}) + Kwargs to all plot functions. Use `histogram.plot_kwargs` or + `regional_stats.plot_kwargs` to specify kwargs for corresponding plots. +regional_stats.fig_kwargs: dict, optional (default: + {'figsize': (15, 3), 'dpi': 300}) + Kwargs passed to the figure function for the scenarios plot. The best size + for the figure depends on the number of splits and regions and the wanted + aspect ratio. 15,3 is the default for a large number of regions. +colors: dict, optional (default: {}) + Define colors as dict. Keys are the split_by values. For experiment or + dataset splits colors are used from ipcc colors, if available. + TODO: add ipcc dataset colors. +labels: dict, optional (default: {}) + Define labels as dict. Keys are the split_by values. Values are strings + shown in the legend. +strip_plots: bool, optional (default: False) + Removes titles, margins and colorbars from plots (to use them in panels). +grid_lines: list of float, optional (default: []) + Draw helper lines to the plots to indicate threshholds or ranges. At + given locations hlines are drawn in boxplots and vlines in histograms. +grid_line_style: dict, optional (default: + {'color': 'gray', 'linestyle': '--', 'linewidth': 0.5}) + Style of the grid lines. See matplotlib documentation for all options. +""" + +import iris +import yaml +import copy +import numpy as np +import xarray as xr +from cycler import cycler +from esmvalcore import preprocessor as pp +# from matplotlib.lines import Line2D +from matplotlib import cbook +from iris.analysis.cartography import area_weights +from iris.time import PartialDateTime +import logging +from collections import defaultdict +import matplotlib.pyplot as plt +from esmvaltool.diag_scripts.shared import ( + # get_plot_filename, + group_metadata, + run_diagnostic, + get_diagnostic_filename, +) +from scipy.stats import norm +from esmvaltool.diag_scripts.droughtindex import ( + colors as ipcc_colors, + utils as ut, +) + +log = logging.getLogger(__file__) + + +SER_KEYS = ["mean", "med", "q1", "q3", "iqr", + "whislo", "whishi", "cihi", "cilo"] +BOX_PLOT_KWARGS = {} + +def load_constrained(cfg, meta, regions=[]): + """Load cube with constraints in meta.""" + cube = iris.load_cube(meta["filename"], constraint=meta.get("cons", None)) + ut.guess_lat_lon_bounds(cube) + if regions != []: + log.debug("Extracting regions: %s", regions) + cube = pp.extract_shape(cube, shapefile="ar6", ids={"Acronym": regions}) + if "interval_range" in meta: + log.debug("clipping timerange to %s", meta["interval_range"]) + cube = pp.clip_timerange(cube, meta["interval_range"]) + return cube + + +def group_by_interval(cfg, metas: list): + """Group metadata by intervals.""" + groups = defaultdict(list) + for interval in cfg["intervals"]: + imetas = copy.deepcopy(metas) + for m in imetas: + m["interval_range"] = interval["range"] + m["interval_label"] = interval["label"] + groups[interval["label"]] = imetas + return groups + + +def group_by_exp(cfg, metas, historical_first=False): + """similar to shared.group_metadata but splits combined experiments. + meta data will be added to individual experiments. + To keep this function lazy an iris constraint is added (`meta["cons"]`), + to be applied when loading the file. + If comparison_period is given, last part of its length in each experiment + is used. For historical the first part is used if `historical_first=True`. + """ + groups = group_metadata(metas, "exp") + historical_added = [] + year = PartialDateTime(year=2015) + groups = defaultdict(list, groups) + remove_keys = set() + # split combined experiments: + for exp in list(groups.keys()): + metas = groups[exp] + if not exp.startswith("historical-"): + continue + if exp not in historical_added: + for meta in metas: + hmeta = copy.deepcopy(meta) + hmeta["cons"] = iris.Constraint(time=lambda cell: cell < year) + hmeta["exp"] = "historical" + if len(cfg["intervals"]) > 0: + log.warning("set interval range for historical") + hmeta["interval_range"] = cfg["intervals"][0]["range"] + groups["historical"].append(hmeta) + historical_added.append(exp) + for meta in metas: + meta["cons"] = iris.Constraint(time=lambda cell: cell >= year) + meta["exp"] = exp.split("-")[1] + if len(cfg["intervals"]) > 0: + log.warning("set interval range for ssp") + meta["interval_range"] = cfg["intervals"][1]["range"] + groups[meta["exp"]].append(meta) + remove_keys.add(exp) + for rmkey in remove_keys: + del groups[rmkey] + return groups + + + +def calculate_histogram(cfg, splits, output, group): + """load data for each split and calculate counts, bins and fit parameters. + Safe parameters to netcdf file, to optionally skip this part on rerun. + """ + labels = [] + # weights = [] + fits = [] + counts = [] + bins = [] + log.info("start split loading") + for split, metas in splits.items(): + labels.append(cfg.get("labels", {}).get(split, split)) + split_data = [] + split_weights = [] + for meta in metas: # merge everything else + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + cube = load_constrained(cfg, meta, regions=cfg["regions"]) + split_weights.append(area_weights(cube)) + applied_mask = cube.data.filled(np.nan) # type: ignore + split_data.append(applied_mask.ravel()) + flat = np.array(split_data).ravel() + flat_weights = np.array(split_weights).ravel() + fits.append(norm.fit(flat[~np.isnan(flat)])) # mu, std + mybins = np.arange(-8, 6.5, step=0.5) + s_counts, s_bins = np.histogram(flat, mybins, weights=flat_weights) + split_bins_center = (s_bins[1:] + s_bins[:-1]) / 2 + counts.append(s_counts) + bins.append(split_bins_center) + log.info("split done") + # save output + data = xr.Dataset({ + "fits": xr.DataArray(fits, dims=["split", "param"], name="fit"), + "counts": xr.DataArray(counts, dims=["split", "bin"], name="counts"), + "bins": xr.DataArray(bins, dims=["split", "bin"], name="bins"), + "labels": xr.DataArray(labels, dims=["split"], name="labels") + }) + fname = get_diagnostic_filename(f"histogram_{group}", cfg) + output["fname"] = {"filename": fname, "plottype": "histogram", "group": group} + data.to_netcdf(fname) + return data + + +def load_histogram(cfg, group): + """Load histogram data from netcdf file.""" + fname = get_diagnostic_filename(f"histogram_{group}", cfg) + return xr.open_dataset(fname) + + +def plot_histogram(cfg, splits, output, group, fit=True): + """Plot one combined histogram of all given regions for each split.""" + plt.figure(**ut.sub_cfg(cfg, "histogram", "fig_kwargs")) + colors = list(get_split_colors(cfg, splits).values()) + if ut.sub_cfg(cfg, "histogram", "reuse"): + data = load_histogram(cfg, group) + else: + data = calculate_histogram(cfg, splits, output, group) + bins = data["bins"].values.T + counts = data["counts"].values.T + labels = data["labels"].values.T + plot_kwargs = { + "bins": np.arange(-8, 6.5, step=0.5), + "label": labels, + "density": True, + "alpha": 0.75, + } + log.info("plotting histogram (%s)", group) + _, bins, patches = plt.hist(bins, weights=counts, color=colors, **plot_kwargs, zorder=3) + legend = plt.legend() + for patch in legend.get_patches(): + patch.set_alpha(1) + plot_props = ut.sub_cfg(cfg, "histogram", "plot_properties") + plt.gca().set(**plot_props) + for line in cfg["grid_lines"]: + plt.axvline(line, **cfg["grid_line_style"]) + + meta = next(iter(splits.values()))[0].copy() # first meta from dict + meta.update({ + "plot_type": "histogram", + "group": group, + }) + filename = ut.get_plot_filename(cfg, cfg["basename"], meta, {"/": "_"}) + plt.savefig(filename) + log.info("saved %s", filename) + + # plot normal fit + plot_kwargs_fit = { + "linewidth": 2, + "linestyle": "-", + "alpha": 1, + } + for patch in patches: # type: ignore + for rect in patch: + rect.set_alpha(0.2) + for iii, fit in enumerate(data["fits"].values): + x = np.linspace(bins[0], bins[-1], 200) + p = norm.pdf(x, fit[0], fit[1]) + plot_kwargs_fit.update(cfg["histogram"].get("plot_kwargs", {})) + plt.plot(x, p, color=colors[iii], **plot_kwargs_fit) + # fit_line = Line2D([], [], color="black", linestyle="-", alpha=1) + # handles, labels = plt.gca().get_legend_handles_labels() + # handles.append(fit_line) + # plt.legend(handles=handles, labels=labels + ["normal fit"]) + # ax.legend(handles, labels) + meta["plot_type"] = "histogram_fit" + filename = ut.get_plot_filename(cfg, cfg["basename"], meta, {"/": "_"}) + log.info("saved %s", filename) + plt.savefig(filename) + plt.close() + + +def sort_regions(data, regions, by="ssp585", inplace=True): + """Sort regions by median value.""" + if not inplace: + data = copy.deepcopy(data) + medians = [np.nanmedian(np.array(reg).ravel()) for reg in data[by]] + order = np.argsort(medians) + regions = [regions[i] for i in order] + for split in data.keys(): + data[split] = [data[split][i] for i in order] + return data, regions + + +def calculate_regional_stats(cfg, splits, output, group): + """Calculate regional statistics for given metadata.""" + data = {} + stats = {} + regions = cfg["regions"] + if regions == []: + regions = list(ut.get_hex_positions().keys()) + for split, metas in splits.items(): # default each experiment + region_data = defaultdict(list) + for meta in metas: + cube = load_constrained(cfg, meta) + ut.guess_lat_lon_bounds(cube) + for region in regions: + ids = {"Acronym": [region]} + reg = pp.extract_shape(cube, shapefile="ar6", ids=ids) + flat = reg.data[~reg.data.mask] # cheaper # type: ignore + region_data[region].extend(flat) + data[split] = [region_data[r] for r in regions] + if cfg["sort_regions_by"]: + data, regions = sort_regions(data, regions, by=cfg["sort_regions_by"]) + for split, dat in data.items(): + stats[split] = cbook.boxplot_stats(dat, whis=(2.3, 97.7), labels=regions) + # save stats + fname = get_diagnostic_filename(f"regional_stats_{group}", cfg) + fname = fname.replace(".nc", ".yml") + output["fname"] = { + "filename": fname, "plottype": "regional_stats", "group": group + } + for split_stats in stats.values(): + for stat in split_stats: + del stat["fliers"] + for key in SER_KEYS: + stat[key] = stat[key].tolist() + with open(fname, "w") as fstream: + yaml.dump(stats, fstream) + log.info("saved: %s", fname) + return stats + + +def load_regional_stats(cfg, group): + """Load regional statistics from netcdf file.""" + fname = get_diagnostic_filename(f"regional_stats_{group}", cfg) + fname = fname.replace(".nc", ".yml") + with open(fname, "r") as f: + stats = yaml.load(f, Loader=yaml.SafeLoader) + for split in stats: + for stat in stats[split]: + for key in SER_KEYS: + stat[key] = np.array(stat[key]) + return stats + + +def plot_regional_stats(cfg, splits, output, group): + if ut.sub_cfg(cfg, "regional_stats", "reuse"): + log.info("loading boxplot data from file") + stats = load_regional_stats(cfg, group) + else: + stats = calculate_regional_stats(cfg, splits, output, group) + regions = [sdata["label"] for sdata in list(stats.values())[0]] + fig = plt.figure(**ut.sub_cfg(cfg, "regional_stats", "fig_kwargs")) + positions = np.array(np.arange(len(regions))) + colors = get_split_colors(cfg, splits) + for line in cfg["grid_lines"]: + plt.hlines(line, -1, len(regions), **cfg["grid_line_style"]) + for i, (split, stat) in enumerate(stats.items()): + n = len(stats) + group_width = 0.9 + width = group_width / (n + 1) + offset = ((i + 1) / (n + 1) - 0.5) * group_width + plot_kwargs_box = { + "widths": width, + "positions": positions + offset, + "showfliers": False, + "patch_artist": True, + "medianprops": {"color": "white", "linewidth": 1}, + "boxprops": { + "facecolor": colors[split], + "edgecolor": "white", + "linewidth": 1, + }, + "whiskerprops": {"color": colors[split], "linewidth": 0.8}, + "capprops": {"color": colors[split], "linewidth": 1}, + } + # plt.boxplot(sdata, **plot_kwargs_box) + plt.gca().bxp(stats[split], **plot_kwargs_box) + plt.plot([], c=colors[split], label=split) # dummy for legend + fig.legend(loc="center left", bbox_to_anchor=(1, 0.5)) + plt.xticks(positions, regions, rotation=90) + plt.tight_layout() + plt.xlim(-1, len(regions)) + # save plot + fname = ut.get_plot_filename(cfg, f"regional_stats_{group}") + plt.savefig(fname) + log.info("saved %s", fname) + + +def get_split_colors(cfg, splits): + """adds colors for each split to the config if not yet present. + ipcc colors are used if available, matplotlib defaults otherwise. + TODO: add ipcc model colors. + """ + colors = cfg.get("colors", {}).copy() # new instance for each group + mpl_default = plt.rcParams["axes.prop_cycle"].by_key()['color'] + cycle = iter(cycler(color=mpl_default)) + missing_splits = [s for s in splits if s not in colors] + for split in missing_splits: + colors[split] = next(cycle) + if cfg["split_by"] == "exp" and hasattr(ipcc_colors, split): + colors[split] = getattr(ipcc_colors, split) + return colors + + +def set_defaults(cfg): + cfg.setdefault("group_by", "short_name") + cfg.setdefault("split_by", "exp") + cfg.setdefault("intervals", []) + cfg["intervals"] = [ut.fix_interval(i) for i in cfg["intervals"]] + cfg.setdefault("basename", "{plot_type}_{group}") + cfg.setdefault("regions", []) + cfg.setdefault("sort_regions_by", "") + cfg.setdefault("plot_models", False) + cfg.setdefault("plot_mmm", True) + cfg.setdefault("plot_global", True) + cfg.setdefault("plot_properties", {}) + cfg.setdefault("plot_kwargs", {}) + cfg.setdefault("histogram", {}) + cfg.setdefault("regional_stats", {}) + cfg["regional_stats"].setdefault("fig_kwargs", + {"figsize": (15, 3), "dpi": 300}) + cfg.setdefault("reuse", False) + cfg.setdefault("strip_plots", False) + cfg.setdefault("grid_lines", []) + cfg.setdefault("grid_line_style", { + "color": "gray", "linestyle": "--", "linewidth": 0.5 + }) + + +def main(cfg): + """main function. executing all plots for each group.""" + set_defaults(cfg) + groups = group_metadata(cfg["input_data"].values(), cfg["group_by"]) + output = {} + for group, gmetas in groups.items(): + log.info("running diagnostic for: %s", group) + if cfg["split_by"] == "exp": + splits = group_by_exp(cfg, gmetas) + elif cfg["split_by"] == "interval": + splits = group_by_interval(cfg, gmetas) + else: + splits = group_metadata(gmetas, cfg["split_by"]) + cfg["split_colors"] = get_split_colors(cfg, splits) + if cfg["plot_global"]: + log.info("plotting global histogram") + plot_histogram(cfg, splits, output, group) + plt.close() + if cfg["plot_regions"]: + log.info("plotting regional statistics") + plot_regional_stats(cfg, splits, output, group) + plt.close() + ut.save_metadata(cfg, output) + + +if __name__ == "__main__": + with run_diagnostic() as config: + main(config) diff --git a/esmvaltool/diag_scripts/droughts/event_area_timeseries.py b/esmvaltool/diag_scripts/droughts/event_area_timeseries.py new file mode 100644 index 0000000000..e347acca12 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/event_area_timeseries.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Calculate and plot relative area of drought events. + +Creates timeseries of the spatial extend of all drought events. Different types +of events can be configured for specific ranges of drought indices. A +multimodel mean is calculated by averaging over all datasets event area ratios. +The datasets are required to be preprocessed accordingly to the same shape and +time axis. + +The default parameters for this diagnostic are defined in +event_area_timeseries.yml. + + +Configuration options in recipe +------------------------------- +interval: int, optional (default: 240) + Number of months per plot, for regular intervals. Set to 0 to create + one plot for the full period. +intervals: list of tuples, optional (default: None) + List of tuples with start and end time indices for each plot. If not set, + `interval` will be used to generate regular ranges. +events: list of dicts + List of event types with min and max index values, colors and labels. + See event_area_timeseries.yml for an example. +fig_kwargs: dict, optional + Additional keyword arguments for the figure creation. This can be set for + specific plottypes as ``fullperiod.fig_kwargs`` or ``overview.fig_kwargs``. +overview: dict, optional + Setup for a figure with multiple plots for selected intervals. The first + interval is expected to be plotted once, all others are plotted for each + scenario. plot_kwargs and fig_kwargs can be set as for this figure. + Set ``overview.skip: True`` to not create this figure. +fullperiod: dict, optional + Setup for a figure with one plot for each pair of scenario and region. + ``plot_kwargs`` and ``fig_kwargs`` can be set as for this figure. + Set ``fullperiod.skip: True`` to not create this figure. +regions: list of str, optional + List of regions (acronyms) for which the area ratios are plotted. + If not given, global data is used. +combine_regions: bool, optional (default: False) + If true the list of acronyms is combined into one region, + rather than plotting them individually. +basename: str, optional (default: "SPEI_{dataset}_{interval}") + Format string for plot file names. The string will be formatted with the + current meta data. "group", "interval" and "region" are be available + in certain cases. For multimodel mean plots "MMM" is used as dataset name. +reuse: bool, optional (default: False) + If True, the diagnostic will try to load existing data from the output of + previous runs. This should be only set to true in temporary settings during + development or adjusting plot parameters. Should not be changed in recipes. +""" + +import logging +import os +import xarray as xr +import iris +import iris.plot as iplt +import matplotlib.pyplot as plt +import numpy as np +import numpy.ma as ma +import yaml +from esmvalcore import preprocessor as pp +from matplotlib import gridspec +from matplotlib.dates import DateFormatter, YearLocator # MonthLocator +from iris.analysis.cartography import area_weights + +import esmvaltool.diag_scripts.droughtindex.utils as ut +from esmvaltool.diag_scripts.shared import ( + get_plot_filename, + get_diagnostic_filename, + group_metadata, + run_diagnostic, +) + +log = logging.getLogger(__file__) + + +def load_and_prepare(cfg, fname): + """apply mask and guess lat/lon bounds.""" + cube = iris.load_cube(fname) + ut.guess_lat_lon_bounds(cube) + old_mask = cube.data.mask + new_mask = get_2d_mask(cube, tile=True) + diff_mask = np.logical_xor(old_mask, new_mask) + cube.data.mask = new_mask + cube.data.data[diff_mask] = np.NaN + return cube + + +def get_intervals(cube, interval): + if interval > 0: + months = len(cube.coord("time").points) + steps = int(months / interval) + intervals = [(i * interval, (i + 1) * interval) for i in range(steps)] + if months % interval > 0: # plot last (incomplete) interval + intervals += [(intervals[-1][-1], None)] + else: + intervals = [(0, None)] + return intervals + + +def calc_ratio(cube, event, weights): + """Calculates a timeseries of area ratio for specific index range. + + The fraction of area (or cells) with index values between min and max is + calculated for each timestep. NaN values are ignored for each event. + If imin and imax are set to "nan" the ratio of cells with nan/inf values is + returned. + + Parameters + ---------- + cube : iris.cube + 3D drought index, with area + event : dict + must contain floats or "nan" for keys: "min", "max" + weights : np.ndarray + weights array with the same shape as the cube + nan_area: bool + return the ratio of nan values ignoring imin and imax. + """ + imin = event["min"] + imax = event["max"] + if imin == "nan" and imax == "nan": + mask = np.isfinite(cube.data) + else: + mask = ~np.logical_and(cube.data >= imin, cube.data < imax) + event_areas = ma.masked_array(weights, mask=mask) # still 3D + return np.sum(event_areas, axis=(1, 2)) # collapse lat/lon + + +def get_2d_mask(cube, mask_any=False, tile=False): + """return a 2d (lat/lon) mask where any or all entries are masked. + + Parameters + ---------- + cube : iris.cube + 3d cube with masked data + mask_any: bool + return true for any masked entrie along time dim, instead of all + tile : bool + return a 3d mask (matching cube) repeated along the time dim + """ + if mask_any: + mask2d = np.any(cube.data.mask, axis=0) + else: + mask2d = np.all(cube.data.mask, axis=0) + if tile: + mask2d = np.tile(mask2d, (cube.shape[0], 1, 1)) + return mask2d + + +def plot_area_ratios(cfg, meta, cube): + """plot area ratio of given event types for a cube of index values + + The area weights are normalized on the masked cube data, resulting in the + ratio between area with index values in a given range and the area of all + grid cells, that are not masked (usually not glaciated land). + + Parameters + ---------- + cube : iris.Cube + 3D index values. + interval : int + number of months per figure. negative values disable the split. + Optional, -1 by default + weighted : boolean + calculate area weights based on grid boundaries. Masked cells are + ignored in normalization. + """ + # if cfg["weighted"]: + # # NOTE: area_weights does not apply cubes mask, so normalize manually + # weights = area_weights(cube, normalize=True) + # else: + # weights = np.ones(cube.data.shape) / (cube.shape[1] * cube.shape[2]) + # # mask and normalize weights to sum up to 1 for unmasked (land/region) + # mask = get_2d_mask(cube, tile=True) + # weights = ma.masked_array(weights, mask=mask) + # unmasked_area = np.sum(weights) / cube.shape[0] + # weights = weights / unmasked_area + # # calculate areas for each event + # for event in cfg["events"]: + # event["area"] = calc_ratio(cube, event, weights=weights) + # mm_key = f'mm_{meta["region"]}' if "region" in meta else "mm" + # if mm_key not in event: + # event[mm_key] = [] + # event[mm_key].append(event["area"]) + y = np.vstack([e["area"] for e in cfg["events"]]) + if cfg.get("intervals", None) is None: + cfg["intervals"] = get_intervals(cube, cfg["interval"]) + if cfg.get("plot_models", True): + for i in cfg["intervals"]: + ftemp = f"{cube.name()}_{meta['dataset']}_{i[0]}-{i[1]}" + if "region" in meta: + ftemp += f"_{meta['region']}" + fname = get_plot_filename(ftemp, cfg) + plot(cfg, fname, i, y) + + +def plot(cfg, i, y, fname=None, fig=None, ax=None, label=None, full=False): + """plot area ratios for given interval and events. + pass either fname to save single plots, or fig and ax to plot one axis into + existing figure. + """ + log.debug("TIMES: %s", cfg["times"]) + if i is None: + i = (0, len(cfg["times"])) + t = cfg["times"][i[0] : i[1]] + # dt = t[1] - t[0] + if not fig: + fig, ax = plt.subplots(figsize=cfg["figsize"], dpi=300) + ax.xaxis.set_major_locator(YearLocator(5)) + ax.xaxis.set_major_formatter(DateFormatter("%Y")) + ax.xaxis.set_minor_locator(YearLocator(1)) + ax.set_ylabel(label) + plot_kwargs = dict(step="mid", colors=cfg["colors"], labels=cfg["labels"]) + plot_kwargs.update(cfg.get("plot_kwargs", {})) + ax.stackplot(t, y[:, i[0] : i[1]], **plot_kwargs) + ax.set_ylim(*cfg["ylim"]) + ax.set(**cfg.get("axes_properties", {})) + ax.tick_params(direction="in", which="both") + import datetime as dt + # ax.set_xlim(t[0]-dt.timedelta(days=20), t[-1]) # show first tick + ax.set_xlim(t[0], t[-1]) + ax.set_xticklabels(ax.get_xticklabels(), rotation=00, ha="center") + print(t[0], t[-1]) + # if cfg["interval"] > 0 and not full: + # log.info("setting xlim for interval") + # ax.set_xlim(t[0] - dt, t[-1]) + # if len(t) < cfg["interval"]: + # ax.set_xlim(t[0], t[0] + cfg["interval"] * dt) + # else: + # log.info("setting xlim for full period %s %s", t[0], t[-1]) + # ax.set_xlim(t[0], t[-1]) + if cfg.get("strip_plot", False): + # standalone legend in seperate figure + fig2, ax2 = plt.subplots(figsize=(5, 1), dpi=300) + ax2.stackplot(t, y[:, i[0] : i[1]], **plot_kwargs) + ax2.axis("off") + lfname = get_plot_filename(f"{cfg['index']}_MMM_legend", cfg) + fig2.legend(mode="expand", ncol=2, fancybox=False, framealpha=1) + fig2.savefig(lfname) + # elif not cfg.get("legend_latest", True) or i[1] is None: + # add legend last or every figure + # if not cfg.get("subplots") and not cfg.get("subplots_full"): + # fig.legend( + # loc="center right", + # fancybox=False, + # framealpha=1, + # labelspacing=0.3, + # ) # 'center right' + if fname: + fig.savefig(fname) + plt.close() + + +def plot_mmm(cfg, region=None, fig=None, axs=None, label=None, meta={}): + """plot multi model mean of area ratios for given interval and events.""" + mmm_key = f"mmm_{region}" if region else "mmm" + mm_key = f"mm_{region}" if region else "mm" + for event in cfg["events"]: + event[mmm_key] = np.mean(np.array(event[mm_key]), axis=(0)) + y = np.vstack([e[mmm_key] for e in cfg["events"]]) + if cfg.get("intervals", None) is not None: + for n, i in enumerate(cfg["intervals"]): + meta["interval"] = f"{i[0]}-{i[1]}" + basename = cfg["basename"].format(**meta) + if cfg["subplots"]: + plot(cfg, i, y, fig=fig, ax=axs[n], label=label) + # else: + # fname = get_plot_filename(basename, cfg) + # plot(cfg, i, y, fname=fname) + if cfg.get("subplots_full", False): + plot(cfg, i, y, fig=fig, ax=axs, label=label) + + +def set_defaults(cfg): + """update cfg with default values from diffmap.yml + TODO: This could be a shared function reused in other diagnostics. + """ + config_file = os.path.realpath(__file__)[:-3] + ".yml" + with open(config_file, "r", encoding="utf-8") as f: + defaults = yaml.safe_load(f) + for key, val in defaults.items(): + cfg.setdefault(key, val) + cfg["colors"] = [e["color"] for e in cfg["events"]] + cfg["labels"] = [e["label"] for e in cfg["events"]] + cfg["mirror"] = len(cfg.get("mirror_events", [])) > 0 + cfg.setdefault("combine_regions", False) + cfg.setdefault("basename", "SPEI_MMM_{interval}") + cfg["overview"].setdefault("skip", False) + cfg["fullperiod"].setdefault("skip", False) + + +def set_timecoords(cfg): + """Read time coordinate points from reference dataset and store it in cfg. + This is required to ensure that all datasets have the same time axis. + TODO: maybe new regrid_time with common calendar could replace this? + """ + meta = ut.select_single_meta( + cfg["input_data"].values(), + short_name=cfg["index"], + # experiment="historical-ssp585", # TODO: + dataset=cfg["reference_dataset"], + strict=False, + ) + tc = iris.load_cube(meta["filename"]).coord("time") + cfg["times"] = iplt._fixup_dates(tc, tc.points) + print(cfg["times"]) + + +def plot_overview(cfg, data, group="unnamed"): + """prepare a figure with 1 histogrical and 3 future scenario intervals.""" + fig = plt.figure(**ut.sub_cfg(cfg, "overview", "fig_kwargs"), dpi=300) + gs = gridspec.GridSpec(3, 2) + scenario_axs = [ + fig.add_subplot(gs[0, 1]), + fig.add_subplot(gs[1, 1]), + fig.add_subplot(gs[2, 1]), + ] + hist_ax = fig.add_subplot(gs[0, 0], sharey=scenario_axs[0]) + # NOTE: sharey hides second yticklabels, use twin to show it + twin = scenario_axs[0].twinx() + twin.tick_params(axis="y", which="both", right=True, direction="in") + # TODO: can this be moved to ax properties? + twin.set_yticks(cfg.get("yticks", None)) + leg_ax = fig.add_subplot(gs[1:, 0]) + hist_plotted = False + for n, ((exp), e_data) in enumerate(data.groupby(["exp"])): + dat = e_data.squeeze() + # pick first and last interval: + if not hist_plotted: + # first = dat.isel(time=slice(0, cfg["intervals"][0][1])) + # print(first["event_ratio"]) + plot(cfg, cfg["intervals"][0], dat["event_ratio"].data, fig=fig, ax=hist_ax) + # last = dat.isel(time=slice(cfg["intervals"][-1][0], None)) + plot(cfg, cfg["intervals"][-1], dat["event_ratio"].data, fig=fig, ax=scenario_axs[n]) + for ax in [hist_ax, *scenario_axs]: # all plots + ax.set_yticks(cfg.get("yticks", None)) + ax.grid(True, which="major", linestyle="--", linewidth=0.5) + ax.tick_params(axis="x", which="both", top=True, bottom=True) + ax.yaxis.tick_right() + for ax in scenario_axs: # right plots + ax.tick_params(axis="y", which="both", left=True, right=True) + for ax in scenario_axs[:-1]: # disable xicks + ax.set_xticklabels([]) + hist_ax.set_ylabel("Historical") + hist_ax.yaxis.tick_right() + hist_ax.set_yticklabels([]) + # axs[1][0].tick_params(axis='x', which='both', pad=10) + leg_ax.axis("off") + hands, labs = scenario_axs[0].get_legend_handles_labels() + legend = leg_ax.legend( + hands[0:8], + labs[0:8], + ncol=2, + loc="center", + bbox_to_anchor=(0.5, 0.34), + fancybox=False, + labelspacing=0, + framealpha=0, + ) + for txt in legend.get_texts(): + txt.set_linespacing(1.3) + leg_ax.axis("off") + fig.tight_layout() + fig.subplots_adjust(hspace=0.2) + fig.savefig(get_plot_filename(f"overview_{cfg['index']}_MMM_{group}", cfg)) + return + + +def plot_full_periods(cfg, data): + """prepare a figure with full time series for each scenario/region.""" + # setup figure with 1 row for legend and 1 for each scenario/region pair + fig = plt.figure(**ut.sub_cfg(cfg, "fullperiod", "fig_kwargs"), dpi=300) + rows = len(data["exp"]) * len(data["region"]) + gs = gridspec.GridSpec(rows+1, 1, height_ratios=[0.3] + [1]*(rows)) + leg_ax = fig.add_subplot(gs[0, 0]) + axs = [] + # loop through data slices and plot to axis + for n, ((exp, reg), dat) in enumerate(data.groupby(["exp", "region"])): + ax = fig.add_subplot(gs[n+1, 0]) + dat = dat.squeeze() + y = dat["event_ratio"].data + # hardcode full interval for this plot + i = [0, None] + fname = get_plot_filename(f"event_area_{exp}_{reg}", cfg) + plot(cfg, i, y, fname=fname, fig=fig, ax=ax) + axs.append(ax) + # if cfg.get("intervals", None) is None: + # cfg["intervals"] = get_intervals(cube, cfg["interval"]) + # if cfg.get("plot_models", True): + # for i in cfg["intervals"]: + # ftemp = f"{cube.name()}_{meta['dataset']}_{i[0]}-{i[1]}" + # if "region" in meta: + # ftemp += f"_{meta['region']}" + # fname = get_plot_filename(ftemp, cfg) + # plot(cfg, fname, i, y) + # for i, (exp, emetas) in enumerate(exp_metas.items()): + # exp_axs = [scenario_axs[i]] + # process_datasets(cfg, emetas, fig=fig, axs=exp_axs) + + for ax in axs: # all plots + ax.set_yticks(cfg.get("yticks", None)) + ax.grid(True, which="both", linestyle="--", linewidth=0.5) + ax.tick_params(axis="x", which="both", top=True, bottom=True) + # ax.yaxis.tick_right() + ax.tick_params(axis="y", which="both", left=True, right=True) + for ax in axs[:-1]: # disable xicks + ax.set_xticklabels([]) + leg_ax.axis("off") + hands, labs = axs[0].get_legend_handles_labels() + labs = [lab.replace("\n", " ") for lab in labs] + ncol = 4 + legend = leg_ax.legend( + hands[0:8], + labs[0:8], + ncol=ncol, + loc="center", + bbox_to_anchor=(0.5, 0.34), + fancybox=False, + labelspacing=0, + framealpha=0, + ) + for txt in legend.get_texts(): + txt.set_linespacing(1.3) + leg_ax.axis("off") + fig.tight_layout() + # fig.subplots_adjust(hspace=0.2) + fig.subplots_adjust(left=0.05, right=0.95, top=0.95, bottom=0.05, hspace=0.2) + fig.savefig(get_plot_filename(f"{cfg['index']}_MMM", cfg)) + return + + +def plot_each_interval(cfg, exp_metas): + """create an individual figure for each interval and each scenario.""" + for i, (exp, emetas) in enumerate(exp_metas): + process_datasets(cfg, emetas, fig=None, axs=None) + + +def process_datasets(cfg, metas, fig=None, axs=None): + """load all models and call event area calculation for each.""" + last_meta = None + for meta in metas: + fname = meta["filename"] + if not meta["short_name"].lower() == cfg["index"]: + log.info("Not matching index (skipped): %s", cfg["index"]) + continue + cube = load_and_prepare(cfg, fname) + if cfg.get("global", True): + plot_area_ratios(cfg, meta, cube) + if cfg.get("regions", False) and not cfg["combine_regions"]: + for region in cfg["regions"]: + log.info("-- region %s", region) + meta["region"] = region + extracted = pp.extract_shape( + cube, shapefile='ar6', ids={"Acronym": [region]} + ) + plot_area_ratios(cfg, meta, extracted) + elif cfg.get("regions", False): + log.info("-- combined region") + extracted = pp.extract_shape( + cube, shapefile='ar6', ids={"Acronym": cfg["regions"]} + ) + if "region" in meta: + del meta["region"] + plot_area_ratios(cfg, meta, extracted) + last_meta = meta + # multi model mean + if cfg.get("plot_mmm", True): + ylabel = cfg.get("ylabels", {}).get(meta["exp"], meta["exp"]) + if cfg.get("global", True) or cfg["combine_regions"]: + print("plotting mmm global/combined") + plot_mmm(cfg, fig=fig, axs=axs, label=ylabel, meta=last_meta) + else: + for region in cfg.get("regions", [None]): + print("plotting mmm each region") + plot_mmm( + cfg, + region=region, + fig=fig, + axs=axs, + label=ylabel, + meta=last_meta, + ) + +def extract_regions(cfg, cube): + """extract regions and return a list of cubes.""" + extracted = {} + params = {"shapefile": "ar6", "ids": {"Acronym": cfg['regions']}} + if cfg["regions"] and cfg["combine_regions"]: + log.info("extracting combined region") + extracted["combined"] = pp.extract_shape(cube, **params) + elif cfg["regions"]: + for region in cfg["regions"]: + log.info("extracting region %s", region) + params["ids"]["Acronym"] = [region] + extracted[region] = pp.extract_shape(cube, **params) + else: + extracted["global"] = cube + return extracted + + +def regional_weights(cfg, cube): + """calculate area weights normalized to the total unmasked area.""" + # NOTE: area_weights does not apply cubes mask, normalize manually + if cfg["weighted"]: + weights = area_weights(cube, normalize=True) + else: + weights = np.ones(cube.data.shape) / (cube.shape[1] * cube.shape[2]) + # mask and normalize weights to sum up to 1 for unmasked (land/region) + mask = get_2d_mask(cube, tile=True) + weights = ma.masked_array(weights, mask=mask) + unmasked_area = np.sum(weights) / cube.shape[0] + weights = weights / unmasked_area + return weights + + +def calculate_event_ratios(cfg, metas, output): + """load data and save calculated event ratio timelines.""" + # data: dataset x exp x region x event + # data_mmm: exp x region x event + if cfg["regions"] and cfg["combine_regions"]: + regions = ["combined"] + elif cfg["regions"]: + regions = cfg["regions"] + else: + regions = ["global"] + coords = { + "dataset": list(group_metadata(metas.values(), "dataset").keys()), + "region": regions, + "exp": list(group_metadata(metas.values(), "exp").keys()), + "event": [e["label"] for e in cfg["events"]], + "time": cfg["times"] + } + dims = list(coords.keys()) + nans = np.full([len(c) for c in coords.values()], np.NaN) + data = xr.Dataset({"event_ratio": (dims, nans)}, coords=coords) + for meta in metas.values(): + cube = load_and_prepare(cfg, meta["filename"]) + extracted = extract_regions(cfg, cube) + loc = {"dataset": meta["dataset"], "exp": meta["exp"]} + for region, r_cube in extracted.items(): + loc["region"] = region + log.info("calculating %s", region) + weights = regional_weights(cfg, r_cube) + for event in cfg["events"]: + loc["event"] = event["label"] + ratios = calc_ratio(r_cube, event, weights=weights) + data["event_ratio"].loc[loc] = ratios + fname = get_diagnostic_filename("event_area", cfg) + output[fname] = {"filename": fname, "plottype": "event_area"} + data.to_netcdf(fname) + data_mmm = data.mean("dataset") + # data_mmm = data_mmm.drop_vars("dataset") + fname = get_diagnostic_filename("event_area_mmm", cfg) + output[fname] = {"filename": fname, "plottype": "event_area_mmm"} + data_mmm.to_netcdf(fname) + return data, data_mmm + + +def load_event_ratios(cfg): + """load calculated event ratios for datasets and MMM from files.""" + names = ["event_area", "event_area_mmm"] + fnames = [get_diagnostic_filename(f, cfg) for f in names] + datas = {} + for fname in fnames: + try: + datas[fname] = xr.open_dataset(fname) + except FileNotFoundError: + log.error("File not found: %s", fname) + datas[fname] = None + return datas[fnames[0]], datas[fnames[1]] + + +def main(cfg): + """get common time coordinates, execute the diagnostic code. + Loop over experiments, than datasets. + """ + set_defaults(cfg) + set_timecoords(cfg) + metas = cfg["input_data"] + output = {} + if cfg["reuse"]: + data, data_mmm = load_event_ratios(cfg) + else: + data, data_mmm = calculate_event_ratios(cfg, metas, output) + if not cfg["overview"]["skip"]: + for (reg), reg_data in data_mmm.groupby("region"): + plot_overview(cfg, data_mmm, group=reg) + if not cfg["fullperiod"]["skip"]: + plot_full_periods(cfg, data_mmm) + # if cfg.get("plot_intervals", False): + # exp_metas = group_metadata(metas.values(), "exp") + # plot_each_interval(cfg, exp_metas) + # TODO: when data is loaded its not written to metadata rn. + ut.save_metadata(cfg, output) + + +if __name__ == "__main__": + with run_diagnostic() as cfg: + main(cfg) diff --git a/esmvaltool/diag_scripts/droughts/event_area_timeseries.yml b/esmvaltool/diag_scripts/droughts/event_area_timeseries.yml new file mode 100644 index 0000000000..41958c515f --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/event_area_timeseries.yml @@ -0,0 +1,71 @@ +# Default configuration for event_area_timeseries diag script +# Each key can be overwritten by the user in the recipes diagnostic block +index: spei +weighted: True +interval: 240 +reuse: False +regions: Null + +ylim: [0, 1] +fig_kwargs: + figsize: [10, 2] +plot_overview: True +overview: + skip: False + fig_kwargs: + figsize: [9, 4] +fullperiod: + skip: False + fig_kwargs: + figsize: [16, 6] +# axes_properties: +# xticklabels: [] +events: + - label: | + extreme wet spell + $SPEI \geq 2$ + color: "#00094e" + min: 2 + max: 100 + - label: | + severe wet spell + $1.5 \leq SPEI < 2$ + color: "#2b97a1" + min: 1.5 + max: 2 + - label: | + moderate wet spell + $1.5 \leq SPEI < 1$ + color: "#9bc193" + min: 1 + max: 1.5 + - label: | + near normal + $-1 \leq SPEI < 1$ + color: "#dadab7" + min: -1 + max: 1 + - label: | + moderate drought + $-1.5 \leq SPEI < -1$ + color: "#daba5f" # "#d98d4e" + min: -1.5 + max: -1 + - label: | + severe drought + $-2 \leq SPEI < -1.5$ + color: "#d96e20" + min: -2 + max: -1.5 + - label: | + extreme drought + $SPEI < -2$ + color: "#c33800" + min: -100 + max: -2 + - label: | + invalid data + $SPEI = nan$ + color: "gray" + min: "nan" + max: "nan" diff --git a/esmvaltool/diag_scripts/droughts/pattern_correlation.py b/esmvaltool/diag_scripts/droughts/pattern_correlation.py new file mode 100644 index 0000000000..b26af7a34e --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/pattern_correlation.py @@ -0,0 +1,293 @@ +"""Overview plots for pattern correlations of multiple variables. +================================================================= + +This diagnostic calculates the pattern correlation between pairs of datasets +over several datasets and plots them for all variables and datasets in one +overview plot. In each figure the correlations are grouped by variable and +project. Only projects listed in the recipe are used everything else is added +with individual markers and labels if `extra_datasets` is True. +A single reference dataset needs to be specified in the recipe. + +The diagnostic requires 2d cubes as input. They can be prepared using the +preprocessor or or by any ancestor diagnostic, that provide a metadata.yml. +The additional `group_by` option allows to apply this diagnostic to each entry +of a meta key seperatly including extra facets. For example this can be used to +plot different aggregations of the same variable, like mean and trend in a +single run. + +For example: +.. code-block:: yaml + script: droughtindex/pattern_correlation.py + reference: ERA5 + group_by: diffmap_metric # first, last, diff, total + ancestors: [obs/diffmaps, models/diffmaps] + + +Configuration options in recipe +------------------------------- +reference: str, required + Dataset name used to correlate all other datasets against. +group_by: str, optional (default: None) + Plot figures for each entry of the given key. +project_plot_kwargs: dict, optional (default: {}) + Kwargs passed to the patches for specific projects the plot function. +plot_kwargs: dict, optional (default: {}) + Kwargs passed to the plot function. +projects: list, optional (default: ["CMIP6"]) + List of projects to include as individual colors in the plot. +extra_datasets: bool, optional (default: True) + Add datasets not belonging to any of the projects as individual markers. +relative_change: bool, optional (default: False) + Creates an additional plot with global relative changes fo each variable + in all datasets. +labels: dict, optional + Mapping of variable names to custom xtick labels. Falls back to variable + name if not present. +""" + +import iris +from pprint import PrettyPrinter +import iris.analysis +import iris.analysis.cartography +import iris.plot as iplt +import matplotlib.pyplot as plt +import numpy as np +import numpy.ma as ma +from pathlib import Path +from collections import defaultdict +from iris.analysis.stats import pearsonr +from esmvaltool.diag_scripts.droughtindex import utils as ut +from esmvaltool.diag_scripts import shared +from iris.analysis import MEAN + +# from esmvalcore import preprocessor as pp +import logging + +logger = logging.getLogger(__file__) +p = PrettyPrinter(indent=4) + + +def pattern_correlation(cube1, cube2, centered=False, weighted=False): + """Calculate pattern correlation between two 2d-cubes. + uses pearsonr from scipy.stats to calculate the correlation coefficient + along latitude and longitude coordinates. Returns area weighted + coefficient. + weighted applies only to the centering (weighted mean subtraction) and not + to the correlation itself. + """ + weights = None + if weighted: + weights = iris.analysis.cartography.area_weights(cube1) + if centered: + dims = ["latitude", "longitude"] + cube1 -= cube1.collapsed(dims, MEAN, weights=weights) + cube2 -= cube2.collapsed(dims, MEAN, weights=weights) + result = pearsonr(cube1, cube2, corr_coords=["latitude", "longitude"]) + return result.data + + +def ds_markers(extra_labels): + datasets = set() + for labels in extra_labels.values(): + datasets.update(labels) + markers = { + "ERA5": "o", + "ERA5-Land": "s", + "CRU": "x", + } + more_markers = iter("v^<>1sp*hH+xDd|_") + for dataset in datasets: + if dataset not in markers: + markers[dataset] = next(more_markers) + return markers + + +def process(metas, cfg, key=None): + results = defaultdict(dict) + # reference = shared.select_metadata(metas, dataset=cfg["reference"]) + cfg["variables"] = shared.group_metadata(metas, "short_name").keys() + extra_labels = defaultdict(list) + # print( + # [ + # m["short_name"] + # for m in shared.group_metadata(metas, "dataset")["ERA5"] + # ] + # ) + for var, var_metas in shared.group_metadata(metas, "short_name").items(): + logger.info("Processing %s (%s datasets)", var, len(var_metas)) + reference = ut.select_single_metadata( + var_metas, dataset=cfg["reference"], short_name=var + ) + ref_cube = iris.load_cube(reference["filename"]) + results[var] = defaultdict(list) + for ds_meta in var_metas: + if ds_meta["dataset"] in ["MMM", cfg["reference"]]: + continue + ds_meta["project"] = ds_meta.get("project", "unknown") + # TODO: this need to be fixed in diag_pet.R: + if var == "evspsblpot" and ds_meta["project"] == "unknown": + ds_meta["project"] = "CMIP6" + cube = iris.load_cube(ds_meta["filename"]) + # print(ds_meta['filename']) + # print(cube) + ds_result = float(pattern_correlation(ref_cube, cube)) + if ds_meta["project"] in cfg.get("projects", ["CMIP6"]): + results[var][ds_meta["project"]].append(ds_result) + elif cfg.get("extra_datasets", True): + results[var]["extra"].append(ds_result) + extra_labels[var].append(ds_meta["dataset"]) + # print("RSULTS") + # print(ds_result) + title = None + if cfg.get("plot_title", False): + title = f"Pattern Correlation with {cfg['reference']} ({key})" + plot( + cfg, + results, + f"pattern_correlation_{key}", + extra_labels=extra_labels, + ylims=(0, 1), + title=title, + ) + + +def process_relative_change(metas, cfg): + results = defaultdict(dict) + cfg["variables"] = shared.group_metadata(metas, "short_name").keys() + extra_labels = defaultdict(list) + for var, var_metas in shared.group_metadata(metas, "short_name").items(): + logger.info("Processing %s (%s datasets)", var, len(var_metas)) + results[var] = defaultdict(list) + for ds_meta in var_metas: + ds_meta["project"] = ds_meta.get("project", "unknown") + # TODO: this need to be fixed in diag_pet.R: + if var == "evspsblpot" and ds_meta["project"] == "unknown": + ds_meta["project"] = "CMIP6" + cube = iris.load_cube(ds_meta["filename"]) + iris.analysis.maths.abs(cube, in_place=True) + rel = cube.collapsed(["latitude", "longitude"], iris.analysis.MEAN) + print(rel) + ds_result = float(rel.data) + if ds_meta["project"] in cfg.get("projects", ["CMIP6"]): + results[var][ds_meta["project"]].append(ds_result) + elif cfg.get("extra_datasets", True): + results[var]["extra"].append(ds_result) + extra_labels[var].append(ds_meta["dataset"]) + title = None + if cfg.get("plot_title", False): + title = "Relative Change over 10 Years" + plot( + cfg, + results, + "relative_change", + extra_labels=extra_labels, + ylims=(0, 10), + title=title, + ) + + +def plot( + cfg, + results, + fname, + extra_labels=None, + title="Pattern Correlation", + ylims=None, +): + if "order" in cfg: + if sorted(cfg["order"]) != sorted(results.keys()): + logger.warning( + "Order does not match result keys: %s, %s", + cfg["order"], + results.keys(), + ) + else: + results = {key: results[key] for key in cfg["order"]} + + fig, axes = plt.subplots(figsize=(4, 3)) + plot_kwargs = dict(marker="_", linestyle="", markersize="5") + plot_kwargs.update(cfg.get("plot_kwargs", {})) + var_count = len(results.keys()) + # TODO: Start with simple case for 2 groups (add multiple groups later) + projects = cfg.get("projects", ["CMIP6"]) + # proj_count = len(groups) + # shift = 0.8 / group_count + + axes.set_title(title) + axes.plot([], **plot_kwargs) + axes.set_xticks(range(var_count)) + labels = results.keys() + if "labels" in cfg: + labels = [cfg["labels"].get(lab, lab) for lab in labels] + axes.set_xticklabels(labels, rotation=90, ha="right") + axes.set_xlim(-0.5, var_count - 0.5) + if ylims is not None: + axes.set_ylim(*ylims) + columns = len(projects) + if cfg.get("extra_datasets", True): + columns += 1 + # single iteration yet + for j, project in enumerate(projects): + # NOTE: dict needs to be complete (each pair of var/proj required) + # TODO: make results a xarray dataset instead? + y_pos = [] + x_pos = [] + shift = -0.2 + 0.4 * j / columns + for i, var in enumerate(results.keys()): + y_pos.extend(results[var][project]) + x_pos.extend([i + shift] * len(results[var][project])) + label = project + axes.plot(x_pos, y_pos, **plot_kwargs, label=label) + # Add extra datasets + if cfg.get("extra_datasets", True): + markers = ds_markers(extra_labels) + scatters = defaultdict(list) + print("plotting extra data") + for i, var in enumerate(results.keys()): + # TODO add label here manually? + y_pos = results[var]["extra"] + print(var) + print(y_pos) + for j, value in enumerate(y_pos): + scatters[extra_labels[var][j]].append((i + 0.2, value)) + for ds, values in scatters.items(): + label = ds + if label.startswith("CDS"): + label = "CDS-SM" + x_pos, y_pos = zip(*values) + axes.scatter( + x_pos, y_pos, marker=markers[ds], color="gray", label=label + ) + axes.legend() + if cfg.get("plot_properties"): + axes.set(**cfg["plot_properties"]) + # fig.legend(loc="center left", bbox_to_anchor=(1, 0.5)) + # fig.tight_layout() + axes.grid(axis="y") + shared.save_figure(fname, {}, cfg, figure=fig, bbox_inches="tight") + + +def main(cfg): + metas = cfg["input_data"].values() + # print(shared.group_metadata(metas, "short_name")["evspsblpot"]) + if not cfg.get("group_by"): + process(metas, cfg) + if cfg.get("relative_change", False): + process_relative_change(metas, cfg) + else: + grouped = shared.group_metadata(metas, cfg["group_by"]) + groups = cfg.get("groups", list(grouped.keys())) + for key, group in grouped.items(): + if key is None or key not in groups: + continue + if cfg.get("groups", None) is not None: + if key not in cfg["groups"]: + continue + process(group, cfg, key=key) + if key == "percent" and cfg.get("relative_change", False): + process_relative_change(group, cfg) + + +if __name__ == "__main__": + with shared.run_diagnostic() as cfg: + main(cfg) diff --git a/esmvaltool/diag_scripts/droughts/regional_hexagons.py b/esmvaltool/diag_scripts/droughts/regional_hexagons.py new file mode 100644 index 0000000000..4829fc2c59 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/regional_hexagons.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Hexagonal overview plot for IPCC AR6 WG1 reference regions. + +Configuration options in recipe +------------------------------- +group_by: str, optional (default: "dataset") + The `group_by` config parameter can be used to create individual figures + for input data which differs in values of the given key. +split_by: str, optional (default: "exp") + Metadata key to split the data into different tiles of the hexagon. + This is ignored for `split_by_statistic: True`. + Only keys with 6 or less different values are supported + (1,2,3,6 can be distributed symmetrically). +split_by_statistic: bool, optional (default: False) + Split the hexagons into different tiles for each statistic, + rather than different metadata. +statistics: list, optional (default: ['mean']) + Any of the operators valid for `esmvalcore.preprocessor.area_statistics` + can be used: 'mean', 'median', 'min', 'max', 'std_dev', 'sum', 'variance' + or 'rms'. The regions are collapsed using the given operator. + If not `split_by_statistic: True`, a figure is created for each operator. +plot_mmm: bool, optional (default: True) + wether to plot multi-model mean + TODO: support this and plot_models +cmap: string, optional (default: 'YlOrRd') + colormap to use +vmin: float, optional (default: None) + minimum value for colormap +vmax: float, optional (default: None) + maximum value for colormap +cbar: bool, optional (default: True) + wether to plot colorbar +labels: dict, optional + dictionary with labels for each split. Dict keys must match the values + corresponding to the split_by key. Set it to False to disable legend. + Default: generated from statistics or split. + TODO: implement dicts (rn only list works) +strip_plot: bool, optional (default: False) + wether to plot the colorbar to a seperate file +cb_label: string, optional (default: 'Decadal change of SPEI') + colorbar label +select_metadata: dict, optional + limit the metadata to use for the plot. Keys must match metadata keys. + Default: {'short_name': 'spei', 'diffmap_metric': 'diff'} +shapefile: string, optional (default: None) + For IPCC WGI reference regions use + `ar6_regions/IPCC-WGI-reference-regions-v4.shp`. + TODO: move to preprocessor +exclude_regions: list, optional (default: []) + regions that are excluded from the plot. + TODO: move to preprocessor as well? +filename: string, optional (default: {group}_{split}_{operator}.png) + Filename template for plot files. `group`, `split` and `operator` + and all metadata keys can be used as placeholders. + TODO: Replace suffix by filename (Not implemented yet). +show_values: bool, optional (default: False) + Add corresponding value in a new line to each regions label. For multiple + splits only the value of the first split is shown. +""" + +import logging +import iris +from matplotlib.patches import Polygon +from matplotlib import colors as mplcolors +from matplotlib import cm as mplcm +import numpy as np +import matplotlib.pyplot as plt +import esmvaltool.diag_scripts.droughtindex.utils as ut +import esmvaltool.diag_scripts.shared as e +from esmvaltool.diag_scripts.shared import ( + # ProvenanceLogger, + group_metadata, + select_metadata, +) + +logger = logging.getLogger(__file__) + + +def plot_colorbar( + cfg: dict, + plotfile: str, + plot_kwargs: dict, + orientation="vertical", + mappable=None, +) -> None: + """creates a colorbar in its own figure. Usefull for multi panel plots.""" + fig, ax = plt.subplots(figsize=(1, 4), layout="constrained") + if mappable is None: + cmap = plot_kwargs.get("cmap", "RdYlBu") + norm = mplcolors.Normalize( + vmin=plot_kwargs.get("vmin"), vmax=plot_kwargs.get("vmax") + ) + mappable = mplcm.ScalarMappable(norm=norm, cmap=cmap) + fig.colorbar( + mappable, + cax=ax, + orientation=orientation, + label=plot_kwargs["cbar_label"], + ) + if plotfile.endswith(".png"): + plotfile = plotfile[:-4] + fig.savefig(plotfile + "_cb.png", bbox_inches="tight") + + +def hexmap( + cfg, + regions, + values, + labels=None, + suffix="", + r=0.8, + bg=False, + filename=None, + draw_nans=True, +): + """Plot hexagons for IPPC WG1 reference regions for data pairs + + Parameters + ---------- + cfg + config with indexname and optional plot parameters: + cmap: string, vmin: float, vmax: float, cbar: bool, + regions + list of IDs (strings) for the reference regions + values + list (each split) of list (each region) of values (floats) + labels, optional + names of the splits to create legend. Same length as values + texts, optional + array (same length as regions) of strings which are added to each cell, + by default None + suffix, optional + string to include in filename. TODO: replace by filename format str. + r, optional + scaling parameter of a hexagon, by default 1 + bg, optional + draw white background instead of transparent, by default False + draw_nans, optional + draw gray polygons for nan values, by default True. + filename, optional + filename to save the plot. If not provided the filename is generated + automatically using shared.get_plot_filename(). By default None + TODO: format with metadata. + + Raises + ------ + ValueError + For missmatching input array lengths + """ + if not len(regions) == len(values[0]): + raise ValueError("regions and values must have the same length") + if labels and not len(labels) == len(values): + raise ValueError("values and labels must have the same length") + values = np.array(values) # np array makes it easier to deal with inf/nan + figsize = (12, 6) if cfg["cbar"] and not cfg["strip_plot"] else (10, 6) + fig, axx = plt.subplots(figsize=figsize, dpi=300, frameon=False) + axx.tick_params( + bottom=False, left=False, labelbottom=False, labelleft=False) + axx.set_xlim(-0.5, 19.5) + axx.set_ylim(0, 12) + cmap = plt.get_cmap(cfg.get("cmap", "YlOrRd")) + cmap.set_bad(cfg.get("cmap_nan", "lightgray"), 1.0) + cmap.set_over(cfg.get("cmap_inf", "dimgray"), 1.0) + if not bg: + plt.axis("off") + # calculate hexagon positions based on figure and scale + rx = np.sqrt(3) / 2 * r # 0.8660254037844386 + corners = np.array([ + [0, r], + [rx, r / 2], + [rx, -r / 2], + [0, -r], + [-rx, -r / 2], + [-rx, r / 2], + ]) + cells = ut.get_hex_positions() # dict of coordinates for hexagons + cells = { + a: [c[0] * rx, (8 - c[1]) * (3 / 2 * r)] for a, c in cells.items() + } + vmin = cfg.get("vmin", values[np.isfinite(values)].min()) + vmax = cfg.get("vmax", values[np.isfinite(values)].max()) + norm = mplcolors.Normalize(vmin=vmin, vmax=vmax) + print(vmin) + print(vmax) + if "levels" in cfg: + lvls = cfg["levels"] + # cmap_colors = [cmap(i/(vmax-vmin)) for i in lvls] + cmap_colors = [cmap(norm(l)) for l in lvls] + cmap = mplcolors.ListedColormap(cmap_colors) + norm = mplcolors.BoundaryNorm(lvls, cmap.N) + cmap.set_bad(cfg.get("cmap_nan", "lightgray"), 1.0) + cmap.set_over(cfg.get("cmap_inf", "lightgray"), 1.0) + abbrs = ut.get_region_abbrs() + # draw hexagon for each region + for iii, name in enumerate(regions): + if name not in abbrs: + logger.warning("No hex cells for %s. Skipping.", name) + continue + abbr = abbrs[name] + exclude = cfg.get("exclude_regions", []) + if abbr in exclude or name in exclude: + logger.info("Region %s excluded. Skipping hexagon.", abbr) + continue + if abbr not in cells: + logger.warning("Region %s not found. Skipping hexagon.", abbr) + continue + if len(cfg["regions"]) > 1 and abbr not in cfg["regions"]: + logger.info("Region %s not in regions. Skipping hexagon.", abbr) + # continue + values[0][iii] = np.nan + c = cells[abbr] # center + hex_corners = np.array([c, c, c, c, c, c]) + corners + hc = list(hex_corners) + hc.append(hc[0]) # last corner = first corner for closed polies + # create polygon vertices depending on the number of values per hex + if len(values) > 3: # 6 pieces + verts = [(hc[i], hc[i + 1], c) for i in range(6)] + elif len(values) > 2: + verts = [ + (hc[2 * i], hc[2 * i + 1], hc[2 * i + 2], c) for i in range(3) + ] + elif len(values) > 1: + verts = np.array([hc[0:4], hc[3:]]) + else: + verts = [hc] + color = "black" + for vvv in range(len(values[:])): + val = values[vvv][iii] + color = cmap(norm(val)) + if not draw_nans and np.isnan(val): + continue + poly = Polygon(verts[vvv], ec="white", fc=color, lw=1) + axx.add_artist(poly) + base = Polygon(hex_corners, ec="white", fc=None, lw=2, fill=False) + axx.add_artist(base) + tval = values[0][iii] # first split for text label + text = abbr + if cfg["show_values"] and not np.isnan(tval): + text = f"{abbr}\n{tval:.2f}" + text_style = {"ha": "center", "va": "center", "fontsize": 10} + axx.text(c[0], c[1], text, **text_style, color=ut.font_color(color)) + + if filename is None: + filename = ut.get_plot_filename(cfg, "hexmap_regions" + suffix) + + mappable = mplcm.ScalarMappable(norm=norm, cmap=cmap) + if cfg.get("cbar", True) and not cfg.get("strip_plot", False): + plt.colorbar(mappable, ax=axx) + if cfg.get("strip_plot", False): + cb_kwargs = {"cbar_label": cfg.get("cb_label", "")} + plot_colorbar(cfg, filename, cb_kwargs, mappable=mappable) + if labels: + pos = (1, 2) + c = pos + anchors = [ # corner, ha, va + [(rx, rx), "bottom", "left"], + [(1.5 * rx, 0), "center", "left"], + [(rx, -rx), "top", "left"], + [(-rx, -rx), "top", "right"], + [(-1.5 * rx, 0), "center", "right"], + [(-rx, rx), "bottom", "right"], + ] + # TODO: repeated below.. make function + hex_corners = np.array([c, c, c, c, c, c]) + corners + hc = list(hex_corners) + hc.append(hc[0]) # last corner = first corner for closed polies + # create polygon vertices depending on the number of values per hex + if len(values) > 3: # 6 pieces + verts = [(hc[i], hc[i + 1], c) for i in range(6)] + elif len(values) > 2: + verts = [ + (hc[2 * i], hc[2 * i + 1], hc[2 * i + 2], c) for i in range(3) + ] + elif len(values) > 1: + verts = np.array([hc[0:4], hc[3:]]) + else: + verts = [hc] + for vvv in range(len(values[:])): + color = ["#fff", "#aaa", "#777", "#555", "#333", "#000"][vvv] + poly = Polygon(verts[vvv], ec="white", fc=color, lw=1) + axx.add_artist(poly) + anc = anchors[vvv] + lpos = (anc[0][0] + pos[0], anc[0][1] + pos[1]) + lab = labels[vvv] + axx.text(lpos[0], lpos[1], lab, fontsize=10, va=anc[1], ha=anc[2]) + fig.savefig(filename, bbox_inches="tight") + + +def ensure_single_meta(meta, txt): + """raise error if there is not exactly one entry in meta list""" + if len(meta) == 0: + raise ValueError(f"No files for {txt}.") + elif len(meta) > 1: + raise ValueError(f"Too many files for {txt}.") + return meta[0] + + +def load_and_plot_splits(cfg, splits, group, operator): + """create hexmap with tiles belonging to different meta data / files""" + if len(splits) > 6: + raise ValueError("Too many inputs for {group}. Max: 6.") + labels = cfg.get("labels", list(splits.keys())) + values, regions, meta = [], [], {} + # extract regional average from each cube + for split, meta in splits.items(): + meta = ensure_single_meta(meta, f"{group}/{split}") + cube = iris.load_cube(meta["filename"]) + collapsed = ut.regional_stats(cfg, cube, operator) + values.append(collapsed.data) + regions = collapsed.coord("shape_id").points + basename = cfg.get("basename", f"hexmap_regions_{group}") + fmeta = meta.copy() + fmeta["group"] = group + filename = ut.get_plot_filename(cfg, basename, meta) + hexmap(cfg, regions, values, labels=labels, filename=filename) + + +def load_and_plot_stats(cfg, metas, group, split, statistics): + """create hexmap with tiles for different operators for the same data""" + meta = ensure_single_meta(metas, f"{group}") + cube = iris.load_cube(meta["filename"]) + values, regions = [], [] + for operator in statistics: + collapsed = ut.regional_stats(cfg, cube, operator) + values.append(collapsed.data) + regions = collapsed.coord("shape_id").points + hexmap( + cfg, + regions, + values, + labels=statistics, + suffix=f"_{group}_{split}_statistics", + ) + + +def set_defaults(cfg): + """ensure all config parameters are set.""" + cfg.setdefault("group_by", "dataset") + cfg.setdefault("statistics", ["mean"]) + cfg.setdefault("cmap", "YlOrRd") + cfg.setdefault("vmin", None) + cfg.setdefault("vmax", None) + cfg.setdefault("cbar", True) + cfg.setdefault("labels", None) + cfg.setdefault("show_values", False) + cfg.setdefault("cb_label", "Decadal change of SPEI") + cfg.setdefault("strip_plot", False) + cfg.setdefault("split_by", "exp") + cfg.setdefault("split_by_statistic", False) + cfg.setdefault("plot_mmm", True) + cfg.setdefault("exclude_regions", []) + cfg.setdefault("regions", []) + cfg.setdefault("filename", "{group}_{split}_{operator}.png") + cfg.setdefault("select_metadata", + {"short_name": "spei", "diffmap_metric": "diff"}) + + +def main(cfg): + set_defaults(cfg) + # select metadata + metas = cfg["input_data"].values() + metas = select_metadata(metas, **cfg.get("select_metadata", {})) + groups = group_metadata(metas, cfg["group_by"]) + statistics = cfg["statistics"] + for group, metas in groups.items(): + # plot splits (segments in hex) for each group (figures) + splits = group_metadata(metas, cfg["split_by"]) + if cfg.get("split_by_statistic"): + for split, split_metas in splits.items(): + load_and_plot_stats(cfg, split_metas, group, split, statistics) + else: + for operator in statistics: + load_and_plot_splits(cfg, splits, group, operator) + + +if __name__ == "__main__": + with e.run_diagnostic() as config: + main(config) diff --git a/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py new file mode 100644 index 0000000000..7355460bb6 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py @@ -0,0 +1,294 @@ +""" Plot timeseries of historical period and different ssps for each variable. +Observations will be shown as reference when given. Shaded area shows MM stddv. +Works with combined or individual inputs for historical/ssp experiments. + +Configuration options in recipe +------------------------------- +plot_mmm: bool, optional (default: True) +smooth: bool, optional (default: True) + Yearly averages for each variable, before mm operations and plot. +combined_split_years: int, optional (default: 65) + If experiments are already combined, this is the number of full years that + is used to split the data into two seperated experiments. Historical data + is only plotted once. +plot_properties: dict, optional + Additional properties to set on the plot. Passed to ax.set(). +figsize: tuple, optional (default: (9, 2)) +reuse_mm: bool, optional (default: False) +subplots: bool, optional (default: False) + Plot all time series as subplots in one figure with shared x-axis. +legend: dict, optional (default: {}) + Names to rename the default legend labels. Keys are the original labels. +""" + +from cProfile import label +import logging +from copy import deepcopy +from pathlib import Path +from esmvalcore import preprocessor as pp +from esmvaltool.diag_scripts.droughtindex import utils as ut +from esmvaltool.diag_scripts.droughtindex import styles +import numpy as np +import os +import matplotlib as mpl +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +from matplotlib.axes import Axes +import iris +from iris import plot as iplt +from iris.iterate import izip +from iris.analysis import MEAN, STD_DEV, cartography +from iris.plot import _fixup_dates +from iris.coord_categorisation import add_year +import datetime as dt +from esmvaltool.diag_scripts.shared import ( + # ProvenanceLogger, + get_plot_filename, + group_metadata, + run_diagnostic, + select_metadata, +) + +# import nc_time_axis # noqa allow cftime axis to be plotted by mpl +# nc_time_axis works but seems to show wrong days on axis when using cftime +logger = logging.getLogger(Path(__file__).stem) + + +def convert_units(cube): + """Convert units of some variables for display""" + if cube.var_name in ["tas", "tasmax", "tasmin"]: + cube.convert_units("Celsius") + if cube.var_name == "pr": + logger.info("Converting pr units to mm/day") + cube.units = "mm s-1" + cube.convert_units("mm day-1") + if cube.var_name == "evspsblpot": + # cube.units = 'mm mon-1' + ut.monthly2daily(cube) + cube.long_name = "Potential Evapotranspiration" # NOTE: not working? + cube.rename("Potential Evapotranspiration") + return None + + +def global_mean(cfg, cube): + """Calculate global mean.""" + ut.guess_lat_lon_bounds(cube) + if "regions" in cfg: + print("Extracting regions") + cube = pp.extract_shape(cube, shapefile='ar6', ids={"Acronym": cfg["regions"]}) + area_weights = cartography.area_weights(cube) + mean = cube.collapsed( + ["latitude", "longitude"], MEAN, weights=area_weights + ) + return mean + + +def yearly_average(cube): + add_year(cube, "time") + return cube.aggregated_by("year", MEAN) + + +def plot_experiment(cfg, mean, std_dev, experiment, ax): + time = mean.coord("time") + # times = time.units.num2date(time.points) + exp_color = getattr(styles, experiment) + iplt.fill_between( + time, + mean - std_dev, + mean + std_dev, + color=exp_color, + alpha=0.2, + axes=ax, + ) + iplt.plot(time, mean, color=exp_color, label=experiment, axes=ax) + y_label = f"{mean.var_name} [{mean.units}]" + if mean.long_name: + y_label = f"{mean.long_name}\n[{mean.units}]" + y_label = cfg.get("ylabels", {}).get(mean.var_name, y_label) + y_label = cfg.get("ylabels", {}).get(mean.long_name, y_label) + ax.set_ylabel(y_label) + + +def plot_each_model(cubes, metas, cfg, experiment, smooth=False): + fig, ax = plt.subplots(figsize=cfg["figsize"], dpi=150) + ax.grid(axis="y", color="0.95") + time = cubes[0].coord("time") + for cube, meta in zip(cubes, metas): + iplt.plot(time, cube, label=meta["dataset"]) + if not cfg.get("strip_plots", False): + ax.legend() + basename = f"timeseries_scenarios_{meta['short_name']}_{experiment}" + fig.savefig(get_plot_filename(basename, cfg), bbox_inches="tight") + + +def plot_models(cfg, metas, ax, smooth=False): + historical_plotted = False + for experiment, models in group_metadata(metas, "exp").items(): + if experiment == "historical" and historical_plotted: + continue + + basename = f"{experiment}_{metas[0]['short_name']}" + fname = os.path.join(cfg["work_dir"], f"{basename}") + recalc = not cfg.get("reuse_mm", False) + if cfg.get("reuse_mm", False): + try: + std_dev = iris.load_cube(fname + "_stddev.nc") + mean = iris.load_cube(fname + "_mean.nc") + except FileNotFoundError: + recalc = True + if recalc or cfg.get("plot_models", False): + cubes = [ + global_mean(cfg, iris.load_cube(meta["filename"])) + for meta in models + ] + if smooth: + cubes = [yearly_average(cube) for cube in cubes] + if cfg.get("plot_models", False): + # TODO: convert units for single models? + plot_each_model(cubes, models, cfg, experiment, smooth=smooth) + # mean = global_mean(mm["mean"]) + if recalc: + mm = pp.multi_model_statistics( + cubes, "overlap", ["mean", "std_dev"] + ) + std_dev = mm["std_dev"] # global_mean(mm["std_dev"]) + mean = mm["mean"] + if recalc and cfg.get("save_mm", True): + iris.save(mm["mean"], fname + "_mean.nc") + iris.save(mm["std_dev"], fname + "_stddev.nc") + convert_units(mean) + convert_units(std_dev) + + if experiment.startswith("historical-"): + experiment = experiment.split("-")[1] + steps = 65 if smooth else 65 * 12 + plot_experiment( + cfg, + mean[(steps - 1) :], + std_dev[(steps - 1) :], + experiment, + ax, + ) + if not historical_plotted: + plot_experiment( + cfg, mean[:steps], std_dev[:steps], "historical", ax + ) + historical_plotted = True + continue + if experiment == "historical": + historical_plotted = True + plot_experiment(cfg, mean, std_dev, experiment, ax) + + +def plot_obs(cfg, metas, ax, smooth=False): + for meta in metas: + cube = iris.load_cube(meta["filename"]) + if smooth: + cube = yearly_average(cube) + mean = global_mean(cfg, cube) + convert_units(mean) + time = mean.coord("time") + iplt.plot(time, mean, linestyle="--", label=meta["dataset"], axes=ax) + + +def process_variable(cfg, metas, short_name, fig=None, ax: Axes = None): + """Process variable.""" + project = cfg.get("project", "CMIP6") + model_metas = select_metadata(metas, project=project) + obs_metas = [meta for meta in metas if meta["project"] != project] + if not cfg.get("subplots", False): + fig, ax = plt.subplots(figsize=cfg.get("figsize", (9, 2)), dpi=300) + plot_models(cfg, model_metas, ax, smooth=cfg.get("smooth", False)) + plot_obs(cfg, obs_metas, ax, smooth=cfg.get("smooth", False)) + basename = f"timeseries_scenarios_{short_name}" + if not cfg.get("subplots", False): + ax.set_xlabel("Time") + ax.legend() + else: + ax.set_xticklabels([]) + ax.set_xticks([]) + ax.grid( + axis="both", color="0.6", which="major", linestyle="--", linewidth=0.5 + ) + # ax.set_frame_on(False) + ax.set_xlim([dt.datetime(1950, 1, 1), dt.datetime(2100, 1, 1)]) + ax.xaxis.set_major_locator(mdates.YearLocator(10)) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) + for label in ax.get_xticklabels(which="major"): + label.set(rotation=40, horizontalalignment="right") + ax.xaxis.set_minor_locator(mdates.YearLocator()) + if "plot_properties" in cfg.keys(): + ax.set(**cfg["plot_properties"]) + if not cfg.get("subplots", False): + fig.savefig(get_plot_filename(basename, cfg), bbox_inches="tight") + + +def main(cfg): + """Run diagnostic.""" + cfg = deepcopy(cfg) + groups = group_metadata(cfg["input_data"].values(), "short_name") + fig, axs = None, [None] * len(groups) + if cfg["subplots"]: + figsize = cfg.get("figsize", (9, 2)) + height = len(groups) * (figsize[1] + 0.5) + fig, axs = plt.subplots( + len(groups), 1, figsize=(figsize[0], height), dpi=300 + ) + for i, (short_name, metas) in enumerate(groups.items()): + process_variable(cfg, metas, short_name, fig=fig, ax=axs[i]) + if cfg["subplots"]: + basename = "timeseries_scenarios" + for ax in axs: + # ax.set_xticklabels([]) + ax.tick_params( + axis="x", + which="both", + bottom=False, + top=False, + labelbottom=False, + ) + ax.tick_params( + axis="y", + which="both", + left=True, + right=True, + labelleft=False, + labelright=True, + ) + for spine in ["top", "bottom"]: + ax.spines[spine].set_visible(False) + axs[-1].tick_params( + axis="x", which="both", bottom=True, top=False, labelbottom=True + ) + axs[-1].spines["bottom"].set_visible(True) + axs[0].spines["top"].set_visible(True) + lines, labels = axs[-1].get_legend_handles_labels() + if cfg.get( + "legend", cfg.get("subplots", False) + ): # rename and reorder handles and labels + leg_dict = dict(zip(labels, lines)) + print(labels) + labels = list(cfg["legend"].values()) + handles = [leg_dict[lab] for lab in cfg["legend"].keys()] + axs[-1].legend(handles, labels) + fig.subplots_adjust(hspace=0.02) + fig.tight_layout() + fig.savefig(get_plot_filename(basename, cfg), bbox_inches="tight") + + +def set_defaults(cfg): + cfg.setdefault("plot_mmm", True) + cfg.setdefault("smooth", True) + cfg.setdefault("combined_split_years", 65) + cfg.setdefault("plot_properties", {}) + cfg.setdefault("figsize", (9, 2)) + cfg.setdefault("reuse_mm", False) + cfg.setdefault("subplots", False) + cfg.setdefault("legend", {}) + cfg.setdefault("ylabels", {}) + + +if __name__ == "__main__": + with run_diagnostic() as cfg: + set_defaults(cfg) + main(cfg) From 3b9ad9fb17f9abd79efa3c27b67be2d32ed173c1 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 7 Mar 2025 11:48:51 +0100 Subject: [PATCH 53/66] modify recipe --- esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml index 35d8944128..18a131bc86 100644 --- a/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml +++ b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml @@ -303,7 +303,7 @@ diagnostics: - validate_models/diffmaps - validate_obs/diffmaps perfmetric: - script: perfmetrics/portrait_plot.py + script: portrait_plot.py distance_metric: rmse nan_color: null y_labels: From 041a57a3f45200bb80a53fb138770c42c0ea02f1 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Fri, 7 Mar 2025 11:57:41 +0100 Subject: [PATCH 54/66] remove prints, set defaults crop, method --- esmvaltool/diag_scripts/droughts/pet.R | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/pet.R b/esmvaltool/diag_scripts/droughts/pet.R index 8e858e2064..56345fe154 100644 --- a/esmvaltool/diag_scripts/droughts/pet.R +++ b/esmvaltool/diag_scripts/droughts/pet.R @@ -66,8 +66,6 @@ calculate_hargreaves <- function(metas, xprov, use_pr=FALSE) { } dpet <- data$tasmin * NA for (i in 1:dim(dpet)[2]) { - print("IS TS?") - print(is.ts(t(data$tasmin[, i, ]))) pet_tmp <- hargreaves( t(data$tasmin[, i, ]), t(data$tasmax[, i, ]), @@ -123,7 +121,6 @@ calculate_penman <- function(metas, xprov, method="ICID", crop="tall") { # load relevant variables for(meta in metas){ if (meta$short_name %in% names(data)){ - print(meta$filename) data[[meta$short_name]] <- get_var_from_nc(meta) xprov$ancestors <- append(xprov$ancestors, meta$filename) if(meta$short_name == "tasmin"){ @@ -150,7 +147,7 @@ calculate_penman <- function(metas, xprov, method="ICID", crop="tall") { Rs = t_or_null(data$rsds[, i, ]), na.rm = TRUE, method = method, - crop = "tall" + crop = crop ) d2 <- dim(pet_tmp) pet_tmp <- as.numeric(pet_tmp) @@ -166,8 +163,8 @@ calculate_penman <- function(metas, xprov, method="ICID", crop="tall") { # ---------------------------------------------------------------------------- # params <- read_yaml(commandArgs(trailingOnly = TRUE)[1]) -ifelse(!is.null(params$method), params$method, "ICID") -ifelse(!is.null(params$crop), params$crop, "tall") +params$method = ifelse(!is.null(params$method), params$method, "ICID") +params$crop = ifelse(!is.null(params$crop), params$crop, "tall") dir.create(params$work_dir, recursive = TRUE) dir.create(params$plot_dir, recursive = TRUE) fillfloat <- 1.e+20 From c91f40e0091aaf0269de643c35e244a7afff1d6f Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 10 Mar 2025 11:47:10 +0100 Subject: [PATCH 55/66] formatted by new pre-commit --- .../collect_drought.rst | 2 +- .../diffmap.rst | 2 +- .../recipes/droughts/recipe_consecdrydays.rst | 8 +- .../recipes/droughts/recipe_martin18grl.rst | 2 - .../source/recipes/droughts/recipe_spei.rst | 10 +- doc/sphinx/source/recipes/recipe_droughts.rst | 3 +- .../diag_scripts/droughts/collect_drought.py | 220 ++++++++++-------- esmvaltool/diag_scripts/droughts/constants.py | 1 + esmvaltool/diag_scripts/droughts/diffmap.yml | 6 +- esmvaltool/diag_scripts/droughts/pet.R | 40 ++-- esmvaltool/diag_scripts/droughts/spei.R | 32 +-- esmvaltool/diag_scripts/droughts/utils.R | 38 +-- esmvaltool/diag_scripts/droughts/utils.py | 4 +- esmvaltool/recipes/droughts/recipe_spei.yml | 4 +- 14 files changed, 197 insertions(+), 175 deletions(-) diff --git a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst index 270e0619f4..7fc173e186 100644 --- a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst +++ b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/collect_drought.rst @@ -7,4 +7,4 @@ Drought Metrics following Martin (2018) .. automodule:: esmvaltool.diag_scripts.droughts.collect_drought :no-members: :no-inherited-members: - :no-show-inheritance: \ No newline at end of file + :no-show-inheritance: diff --git a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst index dc2840485e..c6f6f90378 100644 --- a/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst +++ b/doc/sphinx/source/api/esmvaltool.diag_scripts.droughts/diffmap.rst @@ -7,4 +7,4 @@ Difference Maps .. automodule:: esmvaltool.diag_scripts.droughts.diffmap :no-members: :no-inherited-members: - :no-show-inheritance: \ No newline at end of file + :no-show-inheritance: diff --git a/doc/sphinx/source/recipes/droughts/recipe_consecdrydays.rst b/doc/sphinx/source/recipes/droughts/recipe_consecdrydays.rst index 26a2b7ea11..94c8482266 100644 --- a/doc/sphinx/source/recipes/droughts/recipe_consecdrydays.rst +++ b/doc/sphinx/source/recipes/droughts/recipe_consecdrydays.rst @@ -39,7 +39,7 @@ plim: float limit for a day to be considered dry [mm/day] frlim: int - the shortest number of consecutive dry days for entering statistic on + the shortest number of consecutive dry days for entering statistic on frequency of dry periods. Under ``plot``: @@ -47,8 +47,8 @@ Under ``plot``: cmap: str, optional the name of a colormap. cmocean colormaps are also supported. -other keyword arguments to -:func:`esmvaltool.diag_scripts.shared.plot.global_pcolormesh` can also be +other keyword arguments to +:func:`esmvaltool.diag_scripts.shared.plot.global_pcolormesh` can also be supplied. Variables @@ -64,5 +64,5 @@ Example plots .. figure:: /recipes/figures/consecdrydays/consec_example_freq.png :align: center :width: 14cm - + Example of the number of occurrences with consecutive dry days of more than five days in the period 2001 to 2002 for the CMIP5 model bcc-csm1-1-m. diff --git a/doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst b/doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst index 8a00646317..f32486606d 100644 --- a/doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst +++ b/doc/sphinx/source/recipes/droughts/recipe_martin18grl.rst @@ -84,5 +84,3 @@ Example plots :width: 80% Global map of the percentage difference between multi-model mean for RCP8.5 scenarios (2050-2100) runs and historical data (1950-2000) for 15 CMIP models for the number of drought events [%] based on SPI. - - diff --git a/doc/sphinx/source/recipes/droughts/recipe_spei.rst b/doc/sphinx/source/recipes/droughts/recipe_spei.rst index 05eca0420e..60056ba178 100644 --- a/doc/sphinx/source/recipes/droughts/recipe_spei.rst +++ b/doc/sphinx/source/recipes/droughts/recipe_spei.rst @@ -14,8 +14,8 @@ Meteorological droughts are often described using the standardized precipitation A hydrological drought occurs when low water supply becomes evident, especially in streams, reservoirs, and groundwater levels, usually after extended periods of meteorological drought. GCMs normally do not simulate hydrological processes in sufficient detail to give deeper insights into hydrological drought processes. Neither do they properly describe agricultural droughts, when crops become affected by the hydrological drought. However, hydrological drought can be estimated by accounting for evapotranspiration, and thereby estimate the surface retention of water. The standardized precipitation-evapotranspiration index (SPEI; Vicente-Serrano et al., 2010) has been developed to also account for temperature effects on the surface water fluxes. Evapotranspiration is not normally calculated in GCMs, so SPEI often takes other inputs to estimate the evapotranspiration. Here, the Thornthwaite (Thornthwaite, 1948) method based on temperature is applied. This page documents a set of R diagnostics based on the -`SPEI.R library `_. -``recipes/roughts/recipe_spei.yml`` is an example how to calculate and plot +`SPEI.R library `_. +``recipes/roughts/recipe_spei.yml`` is an example how to calculate and plot SPEI using ``diag_scripts/droughts/pet.R`` and ``diag_scripts/droughts/spei.R``. @@ -39,7 +39,7 @@ set explicitly as ancestors. The Thornthwaite equation (Thornthwaite, 1948) is the simplest one based solely on temperature. Hargreaves (1994) provides an equation based on daily minimum (tasmin) and maximum temperature (tasmax) and external radiation (rsdt). -If precipitation data (pr) is provided and `use_pr: TRUE` it will be used as a +If precipitation data (pr) is provided and `use_pr: TRUE` it will be used as a proxy for irradation to correct PET following Droogers and Allen (2002). The Penman-Monteith formular additionally considers surface windspeed (sfcWind), pressure (ps), and relative humidity (hurs). Some of these variables can be @@ -100,7 +100,7 @@ short_name_pet: string, optional By default "evspsblpot" distributionn: string, optional - Type of distribution used for SPEI calibration. + Type of distribution used for SPEI calibration. Possible options are: "Gamma", "log-Logistic", "Pearson III". By default "log-Logistic". @@ -168,4 +168,4 @@ Example plots :width: 80% Example plot of SPEI averaged over the year 2005. The reference period for - index calibration is 2000-2005. \ No newline at end of file + index calibration is 2000-2005. diff --git a/doc/sphinx/source/recipes/recipe_droughts.rst b/doc/sphinx/source/recipes/recipe_droughts.rst index 49fa73e91a..53418ff40e 100644 --- a/doc/sphinx/source/recipes/recipe_droughts.rst +++ b/doc/sphinx/source/recipes/recipe_droughts.rst @@ -28,7 +28,7 @@ require different input variables for example: - Hargreaves: tas, tasmin, tasmax, (rsds, pr) - Thornthwaite: tas -A complete list and more details can be found in +A complete list and more details can be found in :ref:`SPEI recipe documentation `. @@ -101,4 +101,3 @@ References * McKee, T. B., Doesken, N. J., & Kleist, J. (1993). The relationship of drought frequency and duration to time scales. In Proceedings of the 8th Conference on Applied Climatology (Vol. 17, No. 22, pp. 179-183). Boston, MA: American Meteorological Society. * Vicente-Serrano, S. M., Beguería, S., & López-Moreno, J. I. (2010). A multiscalar drought index sensitive to global warming: the standardized precipitation evapotranspiration index. Journal of climate, 23(7), 1696-1718. - diff --git a/esmvaltool/diag_scripts/droughts/collect_drought.py b/esmvaltool/diag_scripts/droughts/collect_drought.py index 07d728e43e..084c58fc4e 100644 --- a/esmvaltool/diag_scripts/droughts/collect_drought.py +++ b/esmvaltool/diag_scripts/droughts/collect_drought.py @@ -1,4 +1,4 @@ -"""Compares SPI/SPEI data from models with observations/reanalysis. +"""Compares SPI/SPEI data from models with observations/reanalysis. Description ----------- @@ -238,15 +238,17 @@ def _plot_multi_model_maps( } if tstype == "Difference": # RCP85 Percentage difference - data_dict.update({ - "data": all_drought_mean[:, :, 0], - "var": "diffnumber", - "datasetname": "Percentage", - "drought_char": "Number of drought events", - "unit": "%", - "filename": "Percentage_difference_of_No_of_Events", - "drought_numbers_level": np.arange(-100, 110, 10), - }) + data_dict.update( + { + "data": all_drought_mean[:, :, 0], + "var": "diffnumber", + "datasetname": "Percentage", + "drought_char": "Number of drought events", + "unit": "%", + "filename": "Percentage_difference_of_No_of_Events", + "drought_numbers_level": np.arange(-100, 110, 10), + } + ) plot_map_spei_multi( cfg, data_dict, @@ -254,13 +256,15 @@ def _plot_multi_model_maps( colormap="rainbow", ) - data_dict.update({ - "data": all_drought_mean[:, :, 1], - "var": "diffduration", - "drought_char": "Duration of drought events", - "filename": "Percentage_difference_of_Dur_of_Events", - "drought_numbers_level": np.arange(-100, 110, 10), - }) + data_dict.update( + { + "data": all_drought_mean[:, :, 1], + "var": "diffduration", + "drought_char": "Duration of drought events", + "filename": "Percentage_difference_of_Dur_of_Events", + "drought_numbers_level": np.arange(-100, 110, 10), + } + ) plot_map_spei_multi( cfg, data_dict, @@ -268,13 +272,15 @@ def _plot_multi_model_maps( colormap="rainbow", ) - data_dict.update({ - "data": all_drought_mean[:, :, 2], - "var": "diffseverity", - "drought_char": "Severity Index of drought events", - "filename": "Percentage_difference_of_Sev_of_Events", - "drought_numbers_level": np.arange(-50, 60, 10), - }) + data_dict.update( + { + "data": all_drought_mean[:, :, 2], + "var": "diffseverity", + "drought_char": "Severity Index of drought events", + "filename": "Percentage_difference_of_Sev_of_Events", + "drought_numbers_level": np.arange(-50, 60, 10), + } + ) plot_map_spei_multi( cfg, data_dict, @@ -282,15 +288,17 @@ def _plot_multi_model_maps( colormap="rainbow", ) - data_dict.update({ - "data": all_drought_mean[:, :, 3], - "var": "diff" + (cfg["indexname"]).lower(), - "drought_char": "Average " - + cfg["indexname"] - + " of drought events", - "filename": "Percentage_difference_of_Avr_of_Events", - "drought_numbers_level": np.arange(-50, 60, 10), - }) + data_dict.update( + { + "data": all_drought_mean[:, :, 3], + "var": "diff" + (cfg["indexname"]).lower(), + "drought_char": "Average " + + cfg["indexname"] + + " of drought events", + "filename": "Percentage_difference_of_Avr_of_Events", + "drought_numbers_level": np.arange(-50, 60, 10), + } + ) plot_map_spei_multi( cfg, data_dict, @@ -298,14 +306,16 @@ def _plot_multi_model_maps( colormap="rainbow", ) else: - data_dict.update({ - "data": all_drought_mean[:, :, 0], - "var": "frequency", - "unit": "year-1", - "drought_char": "Number of drought events per year", - "filename": tstype + "_No_of_Events_per_year", - "drought_numbers_level": np.arange(0, 0.4, 0.05), - }) + data_dict.update( + { + "data": all_drought_mean[:, :, 0], + "var": "frequency", + "unit": "year-1", + "drought_char": "Number of drought events per year", + "filename": tstype + "_No_of_Events_per_year", + "drought_numbers_level": np.arange(0, 0.4, 0.05), + } + ) if tstype == "Observations": data_dict["datasetname"] = "Mean" else: @@ -317,14 +327,16 @@ def _plot_multi_model_maps( colormap="gnuplot", ) - data_dict.update({ - "data": all_drought_mean[:, :, 1], - "var": "duration", - "unit": "month", - "drought_char": "Duration of drought events [month]", - "filename": tstype + "_Dur_of_Events", - "drought_numbers_level": np.arange(0, 6, 1), - }) + data_dict.update( + { + "data": all_drought_mean[:, :, 1], + "var": "duration", + "unit": "month", + "drought_char": "Duration of drought events [month]", + "filename": tstype + "_Dur_of_Events", + "drought_numbers_level": np.arange(0, 6, 1), + } + ) plot_map_spei_multi( cfg, data_dict, @@ -332,14 +344,16 @@ def _plot_multi_model_maps( colormap="gnuplot", ) - data_dict.update({ - "data": all_drought_mean[:, :, 2], - "var": "severity", - "unit": "1", - "drought_char": "Severity Index of drought events", - "filename": tstype + "_Sev_index_of_Events", - "drought_numbers_level": np.arange(0, 9, 1), - }) + data_dict.update( + { + "data": all_drought_mean[:, :, 2], + "var": "severity", + "unit": "1", + "drought_char": "Severity Index of drought events", + "filename": tstype + "_Sev_index_of_Events", + "drought_numbers_level": np.arange(0, 9, 1), + } + ) plot_map_spei_multi( cfg, data_dict, @@ -348,14 +362,16 @@ def _plot_multi_model_maps( ) namehlp = "Average " + cfg["indexname"] + " of drought events" namehlp2 = tstype + "_Average_" + cfg["indexname"] + "_of_Events" - data_dict.update({ - "data": all_drought_mean[:, :, 3], - "var": (cfg["indexname"]).lower(), - "unit": "1", - "drought_char": namehlp, - "filename": namehlp2, - "drought_numbers_level": np.arange(-2.8, -1.8, 0.2), - }) + data_dict.update( + { + "data": all_drought_mean[:, :, 3], + "var": (cfg["indexname"]).lower(), + "unit": "1", + "drought_char": namehlp, + "filename": namehlp2, + "drought_numbers_level": np.arange(-2.8, -1.8, 0.2), + } + ) plot_map_spei_multi( cfg, data_dict, @@ -378,38 +394,44 @@ def _plot_single_maps(cfg, cube2, drought_show, tstype, input_filenames): plot_map_spei(cfg, cube2, np.arange(0, 0.4, 0.05), name_dict) # plot the average duration of drought events cube2.data = drought_show.data[:, :, 1] - name_dict.update({ - "add_to_filename": tstype + "_Dur_of_Events", - "name": tstype + " Duration of drought events(month)", - "var": "duration", - "unit": "month", - "drought_char": "Number of drought events per year", - "input_filenames": input_filenames, - }) + name_dict.update( + { + "add_to_filename": tstype + "_Dur_of_Events", + "name": tstype + " Duration of drought events(month)", + "var": "duration", + "unit": "month", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + } + ) plot_map_spei(cfg, cube2, np.arange(0, 6, 1), name_dict) # plot the average severity index of drought events cube2.data = drought_show.data[:, :, 2] - name_dict.update({ - "add_to_filename": tstype + "_Sev_index_of_Events", - "name": tstype + " Severity Index of drought events", - "var": "severity", - "unit": "1", - "drought_char": "Number of drought events per year", - "input_filenames": input_filenames, - }) + name_dict.update( + { + "add_to_filename": tstype + "_Sev_index_of_Events", + "name": tstype + " Severity Index of drought events", + "var": "severity", + "unit": "1", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + } + ) plot_map_spei(cfg, cube2, np.arange(0, 9, 1), name_dict) # plot the average spei of drought events cube2.data = drought_show.data[:, :, 3] namehlp = tstype + "_Avr_" + cfg["indexname"] + "_of_Events" namehlp2 = tstype + "_Average_" + cfg["indexname"] + "_of_Events" - name_dict.update({ - "add_to_filename": namehlp, - "name": namehlp2, - "var": "severity", - "unit": "1", - "drought_char": "Number of drought events per year", - "input_filenames": input_filenames, - }) + name_dict.update( + { + "add_to_filename": namehlp, + "name": namehlp2, + "var": "severity", + "unit": "1", + "drought_char": "Number of drought events per year", + "input_filenames": input_filenames, + } + ) plot_map_spei(cfg, cube2, np.arange(-2.8, -1.8, 0.2), name_dict) @@ -467,15 +489,17 @@ def plot_map_spei_multi( ) # set ticks axx.set_xticks(np.linspace(-180, 180, 7)) - axx.set_xticklabels([ - "180°W", - "120°W", - "60°W", - "0°", - "60°E", - "120°E", - "180°E", - ]) + axx.set_xticklabels( + [ + "180°W", + "120°W", + "60°W", + "0°", + "60°E", + "120°E", + "180°E", + ] + ) axx.set_yticks(np.linspace(-90, 90, 7)) axx.set_yticklabels(["90°S", "60°S", "30°S", "0°", "30°N", "60°N", "90°N"]) diff --git a/esmvaltool/diag_scripts/droughts/constants.py b/esmvaltool/diag_scripts/droughts/constants.py index 5ea3e28e63..b2fe432bc4 100644 --- a/esmvaltool/diag_scripts/droughts/constants.py +++ b/esmvaltool/diag_scripts/droughts/constants.py @@ -1,4 +1,5 @@ """Constants that might be usefull by any drought diagnostic.""" + from iris.coords import AuxCoord # fmt: off diff --git a/esmvaltool/diag_scripts/droughts/diffmap.yml b/esmvaltool/diag_scripts/droughts/diffmap.yml index 9f25e97331..30cc210953 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.yml +++ b/esmvaltool/diag_scripts/droughts/diffmap.yml @@ -1,5 +1,5 @@ -# this file contains default setup for the diffmap map plots i.e. -# limits, colormaps, etc. every root level key will be overwritten by +# this file contains default setup for the diffmap map plots i.e. +# limits, colormaps, etc. every root level key will be overwritten by # optional script parameters in the recipe file. # The plot_kwargs_overwrite list however will get merged with the script # parameters to allow modifications only for specific variables/metrics. @@ -42,4 +42,4 @@ metrics: - last - percent -plot_kwargs_overwrite: {} \ No newline at end of file +plot_kwargs_overwrite: {} diff --git a/esmvaltool/diag_scripts/droughts/pet.R b/esmvaltool/diag_scripts/droughts/pet.R index 56345fe154..a1c9dea32b 100644 --- a/esmvaltool/diag_scripts/droughts/pet.R +++ b/esmvaltool/diag_scripts/droughts/pet.R @@ -8,33 +8,33 @@ # the calculated PET with correct units and metadata to be used as an ancestor # for diag_spei.R or diag_spei.py, but also allows to use pet as a variable from # a preprocessed dataset in the same way. -# +# # Some functions that are used by diag_spei.R too are moved to a shared utils.R # # NOTE: Masking is done for each dataset instead of using the mask from the # reference dataset everytime. -# -# NOTE: renamed variables to use shortnames/named lists instead of var1, +# +# NOTE: renamed variables to use shortnames/named lists instead of var1, # tmp1, tmp2, tmp3.. which differ for pet_types # -# NOTE: added pet_type Penman, which use best available data and let SPEI +# NOTE: added pet_type Penman, which use best available data and let SPEI # library approximate everything else # # NOTE: Loop over datasets and use params and meta dicts whenever possible to # save some loops, switches and many extra variables. -# +# # NOTE: add latitude and longitude as valid dim coords in addition to lat/lon -# +# # NOTE: added weigel_katja, lindenlaub_lukas to authors # # NOTE: Provenance is written for each output file within the loop over datasets # instead of afterwards (was it a bug?) # -# NOTE: Precipitation is removed from input data for hargreaves method, since it +# NOTE: Precipitation is removed from input data for hargreaves method, since it # cause failures within SPEI functions. Hargreaves.R line 494 wrong shapes # # NOTE: Remove pr as reference, since pr is not mandatory input for all methods. -# +# # NOTE: all PET methods pass data as t() and do therefore not account for leap # years. # @@ -67,7 +67,7 @@ calculate_hargreaves <- function(metas, xprov, use_pr=FALSE) { dpet <- data$tasmin * NA for (i in 1:dim(dpet)[2]) { pet_tmp <- hargreaves( - t(data$tasmin[, i, ]), + t(data$tasmin[, i, ]), t(data$tasmax[, i, ]), lat = rep(data$lat[i], dim(dpet)[1]), Pre = t_or_null(data$pr[, i, ]), @@ -109,14 +109,14 @@ calculate_thornthwaite <- function(metas, xprov) { calculate_penman <- function(metas, xprov, method="ICID", crop="tall") { data <- list( - tasmin = NULL, - tasmax = NULL, - clt = NULL, + tasmin = NULL, + tasmax = NULL, + clt = NULL, sfcWind = NULL, ps = NULL, - psl = NULL, - hurs = NULL, - rsds = NULL, + psl = NULL, + hurs = NULL, + rsds = NULL, rsdt=NULL) # load relevant variables for(meta in metas){ @@ -186,7 +186,7 @@ print("--- Process each dataset") for (dataset in names(grouped_meta)){ metas <- grouped_meta[[dataset]] # list of files for this dataset xprov$ancestors <- list() - switch(params$pet_type, + switch(params$pet_type, Penman = {pet <- calculate_penman(metas, xprov, method=params$method, crop=params$crop)}, Thornthwaite = {pet <- calculate_thornthwaite(metas, xprov)}, @@ -204,13 +204,13 @@ for (dataset in names(grouped_meta)){ # write PET to file first_meta = metas[[names(metas)[1]]] filename <- write_nc_file_like( - params, first_meta, pet, fillfloat, - short_name="evspsblpot", + params, first_meta, pet, fillfloat, + short_name="evspsblpot", long_name="Potential Evapotranspiration", units="mm day-1") input_meta <- select_var(metas, "tasmin") # TODO: create duplicate()? input_meta$filename <- filename - input_meta$short_name <- "evspsblpot" + input_meta$short_name <- "evspsblpot" input_meta$long_name <- "Potential Evapotranspiration" input_meta$units <- "mm day-1" meta[[filename]] <- input_meta @@ -220,4 +220,4 @@ for (dataset in names(grouped_meta)){ } write_yaml(provenance, provenance_file) -write_yaml(meta, meta_file) \ No newline at end of file +write_yaml(meta, meta_file) diff --git a/esmvaltool/diag_scripts/droughts/spei.R b/esmvaltool/diag_scripts/droughts/spei.R index df843931a1..9d45b97cec 100644 --- a/esmvaltool/diag_scripts/droughts/spei.R +++ b/esmvaltool/diag_scripts/droughts/spei.R @@ -16,39 +16,39 @@ # settings.yml. Variables from ancestors can be added in recipe (i.e. pet/pr) # # NOTE: In the output folder are now (ESMValTool update) additinal xml and yml -# files which need to be skipped when manually loaded. -# UPDATE: reduced this diag to work only with ancestors producing metadata.yml +# files which need to be skipped when manually loaded. +# UPDATE: reduced this diag to work only with ancestors producing metadata.yml # or preprocessed variables. -# +# # NOTE: Is the reference dataset used to apply the mask (NaNs) to all other # datasets? What should be chosen for reference? and why? # UPDATE: refrence_dataset will be read from script settings instead of dataset # UPDATE2: individual masks and refperiods should be fine. Reference dataset # completly removed. -# -# NOTE: All metadata is kept in a named list with model keys. Removed some loops +# +# NOTE: All metadata is kept in a named list with model keys. Removed some loops # and variables. In most functions meta just replaces yml[m][1]. # # NOTE: A similar correction for time unit was hardcoded for each # variable. Changed it to a function that is called multiple times. #DRY -# -# UPDATE: cleaned up getnc/getpetnc -> get_var_from_nc() getpetnc +# +# UPDATE: cleaned up getnc/getpetnc -> get_var_from_nc() getpetnc # UPDATE: gettimenc is another special case of get_var_from_nc() #DRY # # NOTE: latitude is taken from reference dataset, but also for each ds during -# calculation. -# -# NOTE: Common functions moved to utils.R this includes general read and write +# calculation. +# +# NOTE: Common functions moved to utils.R this includes general read and write # functions for nc files that replace ncwrite, ncwritespei, ncwritepet, getpetnc # gettimenc... and general utility functions like default values for lists -# +# # NOTE: move all if conditions for each refperiod param into a seperate function # fill_refperiod. #DRY # -# NOTE: added optional parameter `short_name_pet` to use variables other than +# NOTE: added optional parameter `short_name_pet` to use variables other than # evspsblpot from recipe. -# -# NOTE: set log-Logistic as default distribution if nothing is given in the +# +# NOTE: set log-Logistic as default distribution if nothing is given in the # recipe. Missing distribution raised an unclear error before. # # NOTE: forced to write netcdf-4 format (v4) to ensure compatibility with @@ -64,7 +64,7 @@ # short_name_pet: string, default "evspsblpot" # short name of the variable to use as PET (i.e. ET) # distributionn: string, default "log-Logistic" -# type of distribution used for SPEI calibration. +# type of distribution used for SPEI calibration. # Options: "Gamma", "log-Logistic", "Pearson III" # refstart_year: integer, default first year of time series # refstart_month: integer, default 1 @@ -187,4 +187,4 @@ for (dataset in names(grouped_meta)){ } # end of dataset loop write_yaml(provenance, provenance_file) -write_yaml(meta, meta_file) \ No newline at end of file +write_yaml(meta, meta_file) diff --git a/esmvaltool/diag_scripts/droughts/utils.R b/esmvaltool/diag_scripts/droughts/utils.R index c630483e75..36359edb3a 100644 --- a/esmvaltool/diag_scripts/droughts/utils.R +++ b/esmvaltool/diag_scripts/droughts/utils.R @@ -1,8 +1,8 @@ -# This file contains utility functions that are available in all +# This file contains utility functions that are available in all # R diagnostics related to drought indices. # NOTE: Several codeblocks applying masks to combinations of variables are -# replaced by reusable get_merged_mask function. +# replaced by reusable get_merged_mask function. # # NOTE: Variables are converted to Units required by SPEI library when loaded # time -> start_year, start_month, end_year, end_month @@ -12,14 +12,14 @@ # sfcWind -> U10 to U2 # psl -> hPa to kPa # rsdt/rsds -> Wm-2 to MJm-2d-1 -# +# # TODO: The PET produced by R diag seems to be in the "correct" unit for SPEI, # but does not match the units of pr and evspsbl(pot), which are loaded from -# native data. PET should be converted before saved to nc files and +# native data. PET should be converted before saved to nc files and # converted back at loading time. # # TODO: same files are opened multiple times. Does keeping the file open -# improve performance? +# improve performance? # # NOTE: `fillfloat` and `fillvalue` are variables that are used both for the # same purpose but can have different values? fillvalue is read from reference @@ -123,8 +123,8 @@ monthly_to_daily <- function(v, dim=1){ convert_to_cf <- function(id, v) { # NOTE: use mm day-1 converted by preprocessor if possible and convert # it to/from month using daily_to_monthly and monthly_to_daily instead. - # - # converts mm/mon back to kg/m2/s assuming the input ncfile to have the + # + # converts mm/mon back to kg/m2/s assuming the input ncfile to have the # correct calendar set. (inverse of `convert_to_monthly`) tcal <- ncatt_get(id, "time", attname = "calendar") if (tcal$value == "360_day") return(v / 30 / 24 / 3600.) @@ -225,13 +225,13 @@ get_var_from_nc <- function(meta, custom_var=FALSE) { } else if (var %in% list("tas", "tasmin", "tasmax")) { data <- data - 273.15 } else if (var %in% list("rsdt", "rsds")) { - data <- data * (86400.0 / 1e6) # W/(m2) to MJ/(m2 d) - } else if (var %in% list("pr", "evspsbl", "evspsblpot")) { + data <- data * (86400.0 / 1e6) # W/(m2) to MJ/(m2 d) + } else if (var %in% list("pr", "evspsbl", "evspsblpot")) { if (meta$units == "mm day-1") { data <- daily_to_monthly(data, dim=time_dim) } else if (meta$units == "mm month-1") { # do nothing } else { - stop(paste(var, + stop(paste(var, " is expected to be in mm day-1 or mm month-1 not in ", meta$units)) } } else if (var == "sfcWind") { @@ -264,8 +264,8 @@ group_meta <- function(metadata) { leap_year <- function(year) { return( - ifelse( - (year %% 4 == 0 & year %% 100 != 0) | year %% 400 == 0, + ifelse( + (year %% 4 == 0 & year %% 100 != 0) | year %% 400 == 0, TRUE, FALSE ) ) @@ -284,7 +284,7 @@ list_default <- function(list, key, default) { } select_var <- function(metas, short_name, strict=TRUE) { - # NOTE: metas should be a list of metadata entries, but R seems to handle it + # NOTE: metas should be a list of metadata entries, but R seems to handle it # differently if this list ist of length 1. for (meta in metas) { if (meta$short_name == short_name) { @@ -302,14 +302,14 @@ select_var <- function(metas, short_name, strict=TRUE) { t_or_null <- function(arr) { if (is.null(arr)) { - return(NULL) + return(NULL) } else { return(t(arr)) } } update_short_name <- function(params, meta, short_name) { - # NOTE: this expects meta from reference data and replaces short_name and + # NOTE: this expects meta from reference data and replaces short_name and # its occurance in the filename by string replacement. # A more general get_output_filename(meta) that reconstruct fname # would be better. @@ -330,8 +330,8 @@ whfcn <- function(x, ilow, ihigh){ # TODO: rename function write_nc_file_like <- function( - params, meta, data, fillfloat, - short_name="spei", + params, meta, data, fillfloat, + short_name="spei", units="1", long_name="Standardized Precipitation-Evapotranspiration Index", moty=FALSE @@ -342,7 +342,7 @@ write_nc_file_like <- function( # NOTE: cf convert skipped for debugging new_meta <- update_short_name(params, meta, short_name) print(paste("create new file:", new_meta$filename)) - ncid_in <- nc_open(meta$filename) + ncid_in <- nc_open(meta$filename) xdim <- ncid_in$dim[["lon"]] if (length(xdim)==0) xdim <- ncid_in$dim[["longitude"]] ydim <- ncid_in$dim[["lat"]] @@ -371,4 +371,4 @@ write_nc_file_like <- function( nc_close(idw) nc_close(ncid_in) return(new_meta$filename) -} \ No newline at end of file +} diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 4647137239..01e5414993 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -435,7 +435,7 @@ def runs_of_ones_array_spei(bits, spei) -> list: (run_ends,) = np.where(difs < 0) spei_sum = np.full(len(run_starts), 0.5) for iii, indexs in enumerate(run_starts): - spei_sum[iii] = np.sum(spei[indexs: run_ends[iii]]) + spei_sum[iii] = np.sum(spei[indexs : run_ends[iii]]) return [run_ends - run_starts, spei_sum] @@ -495,7 +495,7 @@ def slice_cube_interval(cube: Cube, interval: list) -> Cube: For 3D cubes time needs to be first dim. """ if isinstance(interval[0], int) and isinstance(interval[1], int): - return cube[interval[0]: interval[1], :, :] + return cube[interval[0] : interval[1], :, :] dt_start = dt.datetime.strptime(interval[0], "%Y-%m") dt_end = dt.datetime.strptime(interval[1], "%Y-%m") time = cube.coord("time") diff --git a/esmvaltool/recipes/droughts/recipe_spei.yml b/esmvaltool/recipes/droughts/recipe_spei.yml index 5cf0e8471a..ee9a969e65 100644 --- a/esmvaltool/recipes/droughts/recipe_spei.yml +++ b/esmvaltool/recipes/droughts/recipe_spei.yml @@ -51,7 +51,7 @@ diagnostics: mip: Amon exp: [historical] tasmax: *var - pr: + pr: <<: *var preprocessor: perday scripts: @@ -86,4 +86,4 @@ diagnostics: vmax: 2 titles: last: "{dataset} Yearly Average 2005" - ancestors: [diagnostic/spi, diagnostic/spei] \ No newline at end of file + ancestors: [diagnostic/spi, diagnostic/spei] From 6bef479c0d8242f3fcd34d02342ea04d30493924 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 10 Mar 2025 12:26:15 +0100 Subject: [PATCH 56/66] safe fixes --- .../recipes/droughts/recipe_lindenlaub25.rst | 4 +- .../droughts/event_area_timeseries.py | 70 +++++++++++-------- .../droughts/pattern_correlation.py | 21 +++--- .../droughts/regional_hexagons.py | 41 ++++++----- .../droughts/timeseries_scenarios.py | 33 +++++---- .../recipe_lindenlaub25_historical.yml | 32 ++++----- .../recipe_lindenlaub25_scenarios.yml | 38 +++++----- 7 files changed, 127 insertions(+), 112 deletions(-) diff --git a/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst index 8cba657f1a..bdb0bec553 100644 --- a/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst +++ b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst @@ -21,9 +21,9 @@ Recipes are stored in ``recipes/droughts/`` Diagnostics used by this recipes: * :ref:`droughts/diffmap.py ` - + References ---------- -* Lindenlaub, L. (2025). Agricultural Droughts in CMIP6 Future Projections. Journal of Climate, 38(1), 1-15. https://doi.org/10.1029/2025JC012345 \ No newline at end of file +* Lindenlaub, L. (2025). Agricultural Droughts in CMIP6 Future Projections. Journal of Climate, 38(1), 1-15. https://doi.org/10.1029/2025JC012345 diff --git a/esmvaltool/diag_scripts/droughts/event_area_timeseries.py b/esmvaltool/diag_scripts/droughts/event_area_timeseries.py index e347acca12..1a1b166d08 100644 --- a/esmvaltool/diag_scripts/droughts/event_area_timeseries.py +++ b/esmvaltool/diag_scripts/droughts/event_area_timeseries.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Calculate and plot relative area of drought events. Creates timeseries of the spatial extend of all drought events. Different types @@ -25,16 +24,16 @@ See event_area_timeseries.yml for an example. fig_kwargs: dict, optional Additional keyword arguments for the figure creation. This can be set for - specific plottypes as ``fullperiod.fig_kwargs`` or ``overview.fig_kwargs``. + specific plottypes as ``fullperiod.fig_kwargs`` or ``overview.fig_kwargs``. overview: dict, optional Setup for a figure with multiple plots for selected intervals. The first interval is expected to be plotted once, all others are plotted for each scenario. plot_kwargs and fig_kwargs can be set as for this figure. - Set ``overview.skip: True`` to not create this figure. + Set ``overview.skip: True`` to not create this figure. fullperiod: dict, optional - Setup for a figure with one plot for each pair of scenario and region. + Setup for a figure with one plot for each pair of scenario and region. ``plot_kwargs`` and ``fig_kwargs`` can be set as for this figure. - Set ``fullperiod.skip: True`` to not create this figure. + Set ``fullperiod.skip: True`` to not create this figure. regions: list of str, optional List of regions (acronyms) for which the area ratios are plotted. If not given, global data is used. @@ -53,22 +52,23 @@ import logging import os -import xarray as xr + import iris import iris.plot as iplt import matplotlib.pyplot as plt import numpy as np import numpy.ma as ma +import xarray as xr import yaml from esmvalcore import preprocessor as pp +from iris.analysis.cartography import area_weights from matplotlib import gridspec from matplotlib.dates import DateFormatter, YearLocator # MonthLocator -from iris.analysis.cartography import area_weights import esmvaltool.diag_scripts.droughtindex.utils as ut from esmvaltool.diag_scripts.shared import ( - get_plot_filename, get_diagnostic_filename, + get_plot_filename, group_metadata, run_diagnostic, ) @@ -219,7 +219,6 @@ def plot(cfg, i, y, fname=None, fig=None, ax=None, label=None, full=False): ax.set_ylim(*cfg["ylim"]) ax.set(**cfg.get("axes_properties", {})) ax.tick_params(direction="in", which="both") - import datetime as dt # ax.set_xlim(t[0]-dt.timedelta(days=20), t[-1]) # show first tick ax.set_xlim(t[0], t[-1]) ax.set_xticklabels(ax.get_xticklabels(), rotation=00, ha="center") @@ -241,14 +240,14 @@ def plot(cfg, i, y, fname=None, fig=None, ax=None, label=None, full=False): fig2.legend(mode="expand", ncol=2, fancybox=False, framealpha=1) fig2.savefig(lfname) # elif not cfg.get("legend_latest", True) or i[1] is None: - # add legend last or every figure - # if not cfg.get("subplots") and not cfg.get("subplots_full"): - # fig.legend( - # loc="center right", - # fancybox=False, - # framealpha=1, - # labelspacing=0.3, - # ) # 'center right' + # add legend last or every figure + # if not cfg.get("subplots") and not cfg.get("subplots_full"): + # fig.legend( + # loc="center right", + # fancybox=False, + # framealpha=1, + # labelspacing=0.3, + # ) # 'center right' if fname: fig.savefig(fname) plt.close() @@ -279,7 +278,7 @@ def set_defaults(cfg): TODO: This could be a shared function reused in other diagnostics. """ config_file = os.path.realpath(__file__)[:-3] + ".yml" - with open(config_file, "r", encoding="utf-8") as f: + with open(config_file, encoding="utf-8") as f: defaults = yaml.safe_load(f) for key, val in defaults.items(): cfg.setdefault(key, val) @@ -332,9 +331,21 @@ def plot_overview(cfg, data, group="unnamed"): if not hist_plotted: # first = dat.isel(time=slice(0, cfg["intervals"][0][1])) # print(first["event_ratio"]) - plot(cfg, cfg["intervals"][0], dat["event_ratio"].data, fig=fig, ax=hist_ax) + plot( + cfg, + cfg["intervals"][0], + dat["event_ratio"].data, + fig=fig, + ax=hist_ax, + ) # last = dat.isel(time=slice(cfg["intervals"][-1][0], None)) - plot(cfg, cfg["intervals"][-1], dat["event_ratio"].data, fig=fig, ax=scenario_axs[n]) + plot( + cfg, + cfg["intervals"][-1], + dat["event_ratio"].data, + fig=fig, + ax=scenario_axs[n], + ) for ax in [hist_ax, *scenario_axs]: # all plots ax.set_yticks(cfg.get("yticks", None)) ax.grid(True, which="major", linestyle="--", linewidth=0.5) @@ -374,12 +385,12 @@ def plot_full_periods(cfg, data): # setup figure with 1 row for legend and 1 for each scenario/region pair fig = plt.figure(**ut.sub_cfg(cfg, "fullperiod", "fig_kwargs"), dpi=300) rows = len(data["exp"]) * len(data["region"]) - gs = gridspec.GridSpec(rows+1, 1, height_ratios=[0.3] + [1]*(rows)) + gs = gridspec.GridSpec(rows + 1, 1, height_ratios=[0.3] + [1] * (rows)) leg_ax = fig.add_subplot(gs[0, 0]) axs = [] # loop through data slices and plot to axis for n, ((exp, reg), dat) in enumerate(data.groupby(["exp", "region"])): - ax = fig.add_subplot(gs[n+1, 0]) + ax = fig.add_subplot(gs[n + 1, 0]) dat = dat.squeeze() y = dat["event_ratio"].data # hardcode full interval for this plot @@ -399,7 +410,7 @@ def plot_full_periods(cfg, data): # for i, (exp, emetas) in enumerate(exp_metas.items()): # exp_axs = [scenario_axs[i]] # process_datasets(cfg, emetas, fig=fig, axs=exp_axs) - + for ax in axs: # all plots ax.set_yticks(cfg.get("yticks", None)) ax.grid(True, which="both", linestyle="--", linewidth=0.5) @@ -427,7 +438,9 @@ def plot_full_periods(cfg, data): leg_ax.axis("off") fig.tight_layout() # fig.subplots_adjust(hspace=0.2) - fig.subplots_adjust(left=0.05, right=0.95, top=0.95, bottom=0.05, hspace=0.2) + fig.subplots_adjust( + left=0.05, right=0.95, top=0.95, bottom=0.05, hspace=0.2 + ) fig.savefig(get_plot_filename(f"{cfg['index']}_MMM", cfg)) return @@ -454,13 +467,13 @@ def process_datasets(cfg, metas, fig=None, axs=None): log.info("-- region %s", region) meta["region"] = region extracted = pp.extract_shape( - cube, shapefile='ar6', ids={"Acronym": [region]} + cube, shapefile="ar6", ids={"Acronym": [region]} ) plot_area_ratios(cfg, meta, extracted) elif cfg.get("regions", False): log.info("-- combined region") extracted = pp.extract_shape( - cube, shapefile='ar6', ids={"Acronym": cfg["regions"]} + cube, shapefile="ar6", ids={"Acronym": cfg["regions"]} ) if "region" in meta: del meta["region"] @@ -484,10 +497,11 @@ def process_datasets(cfg, metas, fig=None, axs=None): meta=last_meta, ) + def extract_regions(cfg, cube): """extract regions and return a list of cubes.""" extracted = {} - params = {"shapefile": "ar6", "ids": {"Acronym": cfg['regions']}} + params = {"shapefile": "ar6", "ids": {"Acronym": cfg["regions"]}} if cfg["regions"] and cfg["combine_regions"]: log.info("extracting combined region") extracted["combined"] = pp.extract_shape(cube, **params) @@ -531,7 +545,7 @@ def calculate_event_ratios(cfg, metas, output): "region": regions, "exp": list(group_metadata(metas.values(), "exp").keys()), "event": [e["label"] for e in cfg["events"]], - "time": cfg["times"] + "time": cfg["times"], } dims = list(coords.keys()) nans = np.full([len(c) for c in coords.values()], np.NaN) diff --git a/esmvaltool/diag_scripts/droughts/pattern_correlation.py b/esmvaltool/diag_scripts/droughts/pattern_correlation.py index b26af7a34e..07aaebbae8 100644 --- a/esmvaltool/diag_scripts/droughts/pattern_correlation.py +++ b/esmvaltool/diag_scripts/droughts/pattern_correlation.py @@ -4,7 +4,7 @@ This diagnostic calculates the pattern correlation between pairs of datasets over several datasets and plots them for all variables and datasets in one overview plot. In each figure the correlations are grouped by variable and -project. Only projects listed in the recipe are used everything else is added +project. Only projects listed in the recipe are used everything else is added with individual markers and labels if `extra_datasets` is True. A single reference dataset needs to be specified in the recipe. @@ -45,23 +45,20 @@ name if not present. """ -import iris +# from esmvalcore import preprocessor as pp +import logging +from collections import defaultdict from pprint import PrettyPrinter + +import iris import iris.analysis import iris.analysis.cartography -import iris.plot as iplt import matplotlib.pyplot as plt -import numpy as np -import numpy.ma as ma -from pathlib import Path -from collections import defaultdict -from iris.analysis.stats import pearsonr -from esmvaltool.diag_scripts.droughtindex import utils as ut -from esmvaltool.diag_scripts import shared from iris.analysis import MEAN +from iris.analysis.stats import pearsonr -# from esmvalcore import preprocessor as pp -import logging +from esmvaltool.diag_scripts import shared +from esmvaltool.diag_scripts.droughtindex import utils as ut logger = logging.getLogger(__file__) p = PrettyPrinter(indent=4) diff --git a/esmvaltool/diag_scripts/droughts/regional_hexagons.py b/esmvaltool/diag_scripts/droughts/regional_hexagons.py index 4829fc2c59..4a9bd5e9c5 100644 --- a/esmvaltool/diag_scripts/droughts/regional_hexagons.py +++ b/esmvaltool/diag_scripts/droughts/regional_hexagons.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Hexagonal overview plot for IPCC AR6 WG1 reference regions. Configuration options in recipe @@ -10,7 +9,7 @@ split_by: str, optional (default: "exp") Metadata key to split the data into different tiles of the hexagon. This is ignored for `split_by_statistic: True`. - Only keys with 6 or less different values are supported + Only keys with 6 or less different values are supported (1,2,3,6 can be distributed symmetrically). split_by_statistic: bool, optional (default: False) Split the hexagons into different tiles for each statistic, @@ -60,12 +59,14 @@ """ import logging + import iris -from matplotlib.patches import Polygon -from matplotlib import colors as mplcolors -from matplotlib import cm as mplcm -import numpy as np import matplotlib.pyplot as plt +import numpy as np +from matplotlib import cm as mplcm +from matplotlib import colors as mplcolors +from matplotlib.patches import Polygon + import esmvaltool.diag_scripts.droughtindex.utils as ut import esmvaltool.diag_scripts.shared as e from esmvaltool.diag_scripts.shared import ( @@ -153,10 +154,11 @@ def hexmap( if labels and not len(labels) == len(values): raise ValueError("values and labels must have the same length") values = np.array(values) # np array makes it easier to deal with inf/nan - figsize = (12, 6) if cfg["cbar"] and not cfg["strip_plot"] else (10, 6) + figsize = (12, 6) if cfg["cbar"] and not cfg["strip_plot"] else (10, 6) fig, axx = plt.subplots(figsize=figsize, dpi=300, frameon=False) axx.tick_params( - bottom=False, left=False, labelbottom=False, labelleft=False) + bottom=False, left=False, labelbottom=False, labelleft=False + ) axx.set_xlim(-0.5, 19.5) axx.set_ylim(0, 12) cmap = plt.get_cmap(cfg.get("cmap", "YlOrRd")) @@ -166,14 +168,16 @@ def hexmap( plt.axis("off") # calculate hexagon positions based on figure and scale rx = np.sqrt(3) / 2 * r # 0.8660254037844386 - corners = np.array([ - [0, r], - [rx, r / 2], - [rx, -r / 2], - [0, -r], - [-rx, -r / 2], - [-rx, r / 2], - ]) + corners = np.array( + [ + [0, r], + [rx, r / 2], + [rx, -r / 2], + [0, -r], + [-rx, -r / 2], + [-rx, r / 2], + ] + ) cells = ut.get_hex_positions() # dict of coordinates for hexagons cells = { a: [c[0] * rx, (8 - c[1]) * (3 / 2 * r)] for a, c in cells.items() @@ -352,8 +356,9 @@ def set_defaults(cfg): cfg.setdefault("exclude_regions", []) cfg.setdefault("regions", []) cfg.setdefault("filename", "{group}_{split}_{operator}.png") - cfg.setdefault("select_metadata", - {"short_name": "spei", "diffmap_metric": "diff"}) + cfg.setdefault( + "select_metadata", {"short_name": "spei", "diffmap_metric": "diff"} + ) def main(cfg): diff --git a/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py index 7355460bb6..8971494410 100644 --- a/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py +++ b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py @@ -1,4 +1,4 @@ -""" Plot timeseries of historical period and different ssps for each variable. +"""Plot timeseries of historical period and different ssps for each variable. Observations will be shown as reference when given. Shaded area shows MM stddv. Works with combined or individual inputs for historical/ssp experiments. @@ -9,7 +9,7 @@ Yearly averages for each variable, before mm operations and plot. combined_split_years: int, optional (default: 65) If experiments are already combined, this is the number of full years that - is used to split the data into two seperated experiments. Historical data + is used to split the data into two seperated experiments. Historical data is only plotted once. plot_properties: dict, optional Additional properties to set on the plot. Passed to ax.set(). @@ -21,26 +21,23 @@ Names to rename the default legend labels. Keys are the original labels. """ -from cProfile import label +import datetime as dt import logging +import os from copy import deepcopy from pathlib import Path -from esmvalcore import preprocessor as pp -from esmvaltool.diag_scripts.droughtindex import utils as ut -from esmvaltool.diag_scripts.droughtindex import styles -import numpy as np -import os -import matplotlib as mpl -import matplotlib.pyplot as plt -import matplotlib.dates as mdates -from matplotlib.axes import Axes + import iris +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +from esmvalcore import preprocessor as pp from iris import plot as iplt -from iris.iterate import izip -from iris.analysis import MEAN, STD_DEV, cartography -from iris.plot import _fixup_dates +from iris.analysis import MEAN, cartography from iris.coord_categorisation import add_year -import datetime as dt +from matplotlib.axes import Axes + +from esmvaltool.diag_scripts.droughtindex import styles +from esmvaltool.diag_scripts.droughtindex import utils as ut from esmvaltool.diag_scripts.shared import ( # ProvenanceLogger, get_plot_filename, @@ -75,7 +72,9 @@ def global_mean(cfg, cube): ut.guess_lat_lon_bounds(cube) if "regions" in cfg: print("Extracting regions") - cube = pp.extract_shape(cube, shapefile='ar6', ids={"Acronym": cfg["regions"]}) + cube = pp.extract_shape( + cube, shapefile="ar6", ids={"Acronym": cfg["regions"]} + ) area_weights = cartography.area_weights(cube) mean = cube.collapsed( ["latitude", "longitude"], MEAN, weights=area_weights diff --git a/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml index 18a131bc86..907133ce45 100644 --- a/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml +++ b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml @@ -2,16 +2,16 @@ documentation: title: "Validation of drought related CMIP6 variables with ERA5" description: | - This recipe calculates PET for ERA5 and a subset of CMIP6 models and - compares the results and each individual input variable and derived - soil moisture. For the comparison averages and change rates are plotted as - maps and pattern correlation between ERA5 and CMIP6 multi-model mean are - calculated. The normalized centered root mean square error vs ERA5 is + This recipe calculates PET for ERA5 and a subset of CMIP6 models and + compares the results and each individual input variable and derived + soil moisture. For the comparison averages and change rates are plotted as + maps and pattern correlation between ERA5 and CMIP6 multi-model mean are + calculated. The normalized centered root mean square error vs ERA5 is calculated for each individual variable and CMIP6 model and displayed as portrait plot. - The recipe is split into blocks that can be run individually by using the - `--diagnostics` argument, e.g. `pet_obs`, `validate_models/diffmaps` or + The recipe is split into blocks that can be run individually by using the + `--diagnostics` argument, e.g. `pet_obs`, `validate_models/diffmaps` or `validate_models/pattern_correlation` on the esmvaltool run command: `esmvaltool run recipes/droughts/recipe_lindenlaub25_validation.yml` authors: @@ -119,9 +119,9 @@ CDS: &cds end_year: 2014 reference_for_metric: true -# ERA5 and CRU data is available for most variables. PET is derived only for +# ERA5 and CRU data is available for most variables. PET is derived only for # ERA5 and soil moisture is derived from ERA5 and CDS-SATELLITE-SOIL-MOISTURE -# for this variables datasets need to set manually and/or added. +# for this variables datasets need to set manually and/or added. # Also note that both variables are found in different mips. OBS_DATA: &obs_data - <<: *era5 @@ -137,7 +137,7 @@ VAR_DEFAULT: &var_default project: CMIP6 exp: historical <<: *obs_period - + # RECIPE STARTS HERE preprocessors: @@ -170,7 +170,7 @@ diagnostics: pet_pm: script: droughts/pet.R pet_type: "Penman" - + spei_historical: &spei_historical scripts: spei: @@ -185,7 +185,7 @@ diagnostics: refend_year: 2014 refstart_month: 1 refend_month: 12 - + pet_obs: &pet_obs variables: pr: &pet_default_obs @@ -222,7 +222,7 @@ diagnostics: tasmin: *add_cru tasmax: *add_cru # clt: *add_cru - evspsblpot: + evspsblpot: additional_datasets: - <<: *cru mip: Emon @@ -242,7 +242,7 @@ diagnostics: - validate_obs/tasmax - pet_obs/pet_pm - validate_obs/evspsblpot - + validate_models: &validate_models # additional_datasets: *cmip6_data variables: @@ -265,7 +265,7 @@ diagnostics: additional_datasets: *cmip6_data_lmon # sfcWind: # <<: *var_default_historical - # ps: + # ps: # <<: *var_default_historical scripts: diffmaps: @@ -344,4 +344,4 @@ diagnostics: - validate_obs/tasmax - validate_obs/evspsblpot - validate_obs/sm - - validate_models/sm \ No newline at end of file + - validate_models/sm diff --git a/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml b/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml index 36fe11eb4f..772d41febd 100644 --- a/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml +++ b/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml @@ -71,7 +71,7 @@ CMIP6_DATA: &cmip6_data # trying to add: # - {<<: *cmip_default, dataset: HadGEM3-GC31-LL, ensemble: r1i1p1f3} # only -MM in picontrol but only -LL in ssp245 - {<<: *cmip_default, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS } - - {<<: *cmip_default, dataset: AWI-CM-1-1-MR, institute: AWI } + - {<<: *cmip_default, dataset: AWI-CM-1-1-MR, institute: AWI } - {<<: *cmip_default, dataset: BCC-CSM2-MR, institute: BCC } - {<<: *cmip_default, dataset: CanESM5, institute: CCCma } - {<<: *cmip_default, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name @@ -103,7 +103,7 @@ VAR_SM: &var_sm mip: Lmon project: CMIP6 additional_datasets: *cmip6_data_lmon - + preprocessors: default: regrid: @@ -130,7 +130,7 @@ diagnostics: scripts: diffmaps: &pr_plot <<: *diffmaps_default - + sm_ssp585: &sm_ssp585 variables: sm: &sm_default @@ -179,7 +179,7 @@ diagnostics: # clt: *pet_585 rsds: *pet_585 ps: *pet_585 - scripts: + scripts: pet_pm: script: droughtindex/diag_pet.R pet_type: "Penman" # "Penman_clt" @@ -196,7 +196,7 @@ diagnostics: # clt: *pet_245 rsds: *pet_245 ps: *pet_245 - scripts: + scripts: pet_pm: script: droughtindex/diag_pet.R pet_type: "Penman" # "Penman_clt" @@ -213,7 +213,7 @@ diagnostics: # clt: *pet_126 rsds: *pet_126 ps: *pet_126 - scripts: + scripts: pet_pm: script: droughtindex/diag_pet.R pet_type: "Penman" # "Penman_clt" @@ -239,20 +239,20 @@ diagnostics: spei: <<: *script_spei_585 ancestors: [pet_ssp126/pet_pm, pet_ssp126/pr] - + # --- EXTRA WB --- # wb_ssp126: &wb_ssp126 - scripts: + scripts: wb: script: droughtindex/diag_wb.py ancestors: [pet_ssp126/pet_pm, pet_ssp126/pr] wb_ssp245: &wb_ssp245 - scripts: + scripts: wb: script: droughtindex/diag_wb.py ancestors: [pet_ssp245/pet_pm, pet_ssp245/pr] wb_ssp585: &wb_ssp585 - scripts: + scripts: wb: script: droughtindex/diag_wb.py ancestors: [pet_ssp585/pet_pm, pet_ssp585/pr] @@ -265,7 +265,7 @@ diagnostics: # or allow multiple group_by keys as combination.. (or based on basename?) # until than use diffmap/spei_ssp126.. runs script: droughtindex/diffmap.py - ancestors: + ancestors: - pet_ssp126/pr - pet_ssp245/pr - pet_ssp585/pr @@ -286,7 +286,7 @@ diagnostics: vmin: 0 ssp585: &diff_pet_default script: droughtindex/diffmap.py - ancestors: + ancestors: - "pet_ssp585/pet_pm" - "spei_ssp585/spei" save_models: True @@ -342,11 +342,11 @@ diagnostics: subplots: True figsize: [9, 1.3] yticks: [0, 0.2, 0.4, 0.6, 0.8, 1] - ylabels: + ylabels: historical-ssp126: SSP1-2.6 historical-ssp245: SSP2-4.5 historical-ssp585: SSP5-8.5 - intervals: + intervals: - [240, 481] # - [780, 1021] - [1560, null] @@ -367,7 +367,7 @@ diagnostics: ancestors: [diffmap/spei_ssp585] select_metadata: experiment: ssp585 - short_name: spei + short_name: spei dataset: MMM diffmap_metric: diff script: droughtindex/regional_hexagons.py @@ -381,7 +381,7 @@ diagnostics: cmap: "RdYlBu" strip_plot: true trend_spei_scenarios: - ancestors: + ancestors: - diffmap/spei_ssp126 - diffmap/spei_ssp245 - diffmap/spei_ssp585 @@ -421,7 +421,7 @@ diagnostics: scripts: scenarios: script: droughtindex/timeseries.py - ancestors: + ancestors: - pet_ssp126/pr - pet_ssp245/pr - pet_ssp585/pr @@ -437,7 +437,7 @@ diagnostics: reuse_mm: True smooth: True figsize: [9, 2] - y_labels: + y_labels: pr: $PR$ [mm/day] evspsblpot: $ET_0$ [mm/day] - spei: $SPEI$ \ No newline at end of file + spei: $SPEI$ From 268a4b2fac7a8c1f1e0e135ceeff1cd20b6d6357 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 10 Mar 2025 13:19:10 +0100 Subject: [PATCH 57/66] unsafe ruff fixes --- .../diag_scripts/droughts/distribution.py | 153 ++++++++++++------ .../droughts/event_area_timeseries.py | 64 ++++---- .../droughts/pattern_correlation.py | 10 +- .../droughts/regional_hexagons.py | 30 ++-- .../droughts/timeseries_scenarios.py | 39 +++-- 5 files changed, 191 insertions(+), 105 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/distribution.py b/esmvaltool/diag_scripts/droughts/distribution.py index ff0f5214ba..effbe9f11d 100644 --- a/esmvaltool/diag_scripts/droughts/distribution.py +++ b/esmvaltool/diag_scripts/droughts/distribution.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Creates histograms and regional boxplots for given timeperiods. Global histograms are plotted to compare distributions of any variable in given intervals and/or by experiment. Combined experiments (historical-ssp*) will -be splitted into individual ones. +be splitted into individual ones. NOTE: This diagnostic loads all values from all datasets into memory. If you provide a lot of data make sure enough memory is available. @@ -26,8 +25,8 @@ parameter must be given. TODO: For 'exp' consider changing split_experiments. intervals: list of dicts, optional (default: []) - List of dicts containing a `label` (optional) and a `range` - (timerange in ISO 8601 format. For example YYYY/YYYY) or + List of dicts containing a `label` (optional) and a `range` + (timerange in ISO 8601 format. For example YYYY/YYYY) or `start` and `end` (ISO 8601). In the diagnostics `interval_label` and `interval_range` will be added to metadata and can be used as split_by option and in basename. @@ -55,9 +54,9 @@ Use `histogram.plot_properties` or `regional_stats.plot_properties` to specify kwargs for corresponding plots. plot_kwargs: dict, optional (default: {}) - Kwargs to all plot functions. Use `histogram.plot_kwargs` or + Kwargs to all plot functions. Use `histogram.plot_kwargs` or `regional_stats.plot_kwargs` to specify kwargs for corresponding plots. -regional_stats.fig_kwargs: dict, optional (default: +regional_stats.fig_kwargs: dict, optional (default: {'figsize': (15, 3), 'dpi': 300}) Kwargs passed to the figure function for the scenarios plot. The best size for the figure depends on the number of splits and regions and the wanted @@ -67,58 +66,79 @@ dataset splits colors are used from ipcc colors, if available. TODO: add ipcc dataset colors. labels: dict, optional (default: {}) - Define labels as dict. Keys are the split_by values. Values are strings + Define labels as dict. Keys are the split_by values. Values are strings shown in the legend. strip_plots: bool, optional (default: False) Removes titles, margins and colorbars from plots (to use them in panels). grid_lines: list of float, optional (default: []) Draw helper lines to the plots to indicate threshholds or ranges. At given locations hlines are drawn in boxplots and vlines in histograms. -grid_line_style: dict, optional (default: +grid_line_style: dict, optional (default: {'color': 'gray', 'linestyle': '--', 'linewidth': 0.5}) Style of the grid lines. See matplotlib documentation for all options. """ -import iris -import yaml import copy +import logging +from collections import defaultdict + +import iris +import matplotlib.pyplot as plt import numpy as np import xarray as xr +import yaml from cycler import cycler from esmvalcore import preprocessor as pp -# from matplotlib.lines import Line2D -from matplotlib import cbook from iris.analysis.cartography import area_weights from iris.time import PartialDateTime -import logging -from collections import defaultdict -import matplotlib.pyplot as plt -from esmvaltool.diag_scripts.shared import ( - # get_plot_filename, - group_metadata, - run_diagnostic, - get_diagnostic_filename, -) + +# from matplotlib.lines import Line2D +from matplotlib import cbook from scipy.stats import norm + from esmvaltool.diag_scripts.droughtindex import ( colors as ipcc_colors, +) +from esmvaltool.diag_scripts.droughtindex import ( utils as ut, ) +from esmvaltool.diag_scripts.shared import ( + get_diagnostic_filename, + # get_plot_filename, + group_metadata, + run_diagnostic, +) log = logging.getLogger(__file__) -SER_KEYS = ["mean", "med", "q1", "q3", "iqr", - "whislo", "whishi", "cihi", "cilo"] +SER_KEYS = [ + "mean", + "med", + "q1", + "q3", + "iqr", + "whislo", + "whishi", + "cihi", + "cilo", +] BOX_PLOT_KWARGS = {} -def load_constrained(cfg, meta, regions=[]): + +def load_constrained(cfg, meta, regions=None): """Load cube with constraints in meta.""" + if regions is None: + regions = [] cube = iris.load_cube(meta["filename"], constraint=meta.get("cons", None)) ut.guess_lat_lon_bounds(cube) if regions != []: log.debug("Extracting regions: %s", regions) - cube = pp.extract_shape(cube, shapefile="ar6", ids={"Acronym": regions}) + cube = pp.extract_shape( + cube, + shapefile="ar6", + ids={"Acronym": regions}, + ) if "interval_range" in meta: log.debug("clipping timerange to %s", meta["interval_range"]) cube = pp.clip_timerange(cube, meta["interval_range"]) @@ -138,7 +158,7 @@ def group_by_interval(cfg, metas: list): def group_by_exp(cfg, metas, historical_first=False): - """similar to shared.group_metadata but splits combined experiments. + """Similar to shared.group_metadata but splits combined experiments. meta data will be added to individual experiments. To keep this function lazy an iris constraint is added (`meta["cons"]`), to be applied when loading the file. @@ -178,9 +198,8 @@ def group_by_exp(cfg, metas, historical_first=False): return groups - def calculate_histogram(cfg, splits, output, group): - """load data for each split and calculate counts, bins and fit parameters. + """Load data for each split and calculate counts, bins and fit parameters. Safe parameters to netcdf file, to optionally skip this part on rerun. """ labels = [] @@ -195,6 +214,7 @@ def calculate_histogram(cfg, splits, output, group): split_weights = [] for meta in metas: # merge everything else import warnings + with warnings.catch_warnings(): warnings.simplefilter("ignore") cube = load_constrained(cfg, meta, regions=cfg["regions"]) @@ -211,14 +231,24 @@ def calculate_histogram(cfg, splits, output, group): bins.append(split_bins_center) log.info("split done") # save output - data = xr.Dataset({ - "fits": xr.DataArray(fits, dims=["split", "param"], name="fit"), - "counts": xr.DataArray(counts, dims=["split", "bin"], name="counts"), - "bins": xr.DataArray(bins, dims=["split", "bin"], name="bins"), - "labels": xr.DataArray(labels, dims=["split"], name="labels") - }) + data = xr.Dataset( + { + "fits": xr.DataArray(fits, dims=["split", "param"], name="fit"), + "counts": xr.DataArray( + counts, + dims=["split", "bin"], + name="counts", + ), + "bins": xr.DataArray(bins, dims=["split", "bin"], name="bins"), + "labels": xr.DataArray(labels, dims=["split"], name="labels"), + }, + ) fname = get_diagnostic_filename(f"histogram_{group}", cfg) - output["fname"] = {"filename": fname, "plottype": "histogram", "group": group} + output["fname"] = { + "filename": fname, + "plottype": "histogram", + "group": group, + } data.to_netcdf(fname) return data @@ -247,7 +277,13 @@ def plot_histogram(cfg, splits, output, group, fit=True): "alpha": 0.75, } log.info("plotting histogram (%s)", group) - _, bins, patches = plt.hist(bins, weights=counts, color=colors, **plot_kwargs, zorder=3) + _, bins, patches = plt.hist( + bins, + weights=counts, + color=colors, + **plot_kwargs, + zorder=3, + ) legend = plt.legend() for patch in legend.get_patches(): patch.set_alpha(1) @@ -257,10 +293,12 @@ def plot_histogram(cfg, splits, output, group, fit=True): plt.axvline(line, **cfg["grid_line_style"]) meta = next(iter(splits.values()))[0].copy() # first meta from dict - meta.update({ - "plot_type": "histogram", - "group": group, - }) + meta.update( + { + "plot_type": "histogram", + "group": group, + }, + ) filename = ut.get_plot_filename(cfg, cfg["basename"], meta, {"/": "_"}) plt.savefig(filename) log.info("saved %s", filename) @@ -324,12 +362,18 @@ def calculate_regional_stats(cfg, splits, output, group): if cfg["sort_regions_by"]: data, regions = sort_regions(data, regions, by=cfg["sort_regions_by"]) for split, dat in data.items(): - stats[split] = cbook.boxplot_stats(dat, whis=(2.3, 97.7), labels=regions) + stats[split] = cbook.boxplot_stats( + dat, + whis=(2.3, 97.7), + labels=regions, + ) # save stats fname = get_diagnostic_filename(f"regional_stats_{group}", cfg) fname = fname.replace(".nc", ".yml") output["fname"] = { - "filename": fname, "plottype": "regional_stats", "group": group + "filename": fname, + "plottype": "regional_stats", + "group": group, } for split_stats in stats.values(): for stat in split_stats: @@ -346,7 +390,7 @@ def load_regional_stats(cfg, group): """Load regional statistics from netcdf file.""" fname = get_diagnostic_filename(f"regional_stats_{group}", cfg) fname = fname.replace(".nc", ".yml") - with open(fname, "r") as f: + with open(fname) as f: stats = yaml.load(f, Loader=yaml.SafeLoader) for split in stats: for stat in stats[split]: @@ -367,7 +411,7 @@ def plot_regional_stats(cfg, splits, output, group): colors = get_split_colors(cfg, splits) for line in cfg["grid_lines"]: plt.hlines(line, -1, len(regions), **cfg["grid_line_style"]) - for i, (split, stat) in enumerate(stats.items()): + for i, (split, _stat) in enumerate(stats.items()): n = len(stats) group_width = 0.9 width = group_width / (n + 1) @@ -400,12 +444,12 @@ def plot_regional_stats(cfg, splits, output, group): def get_split_colors(cfg, splits): - """adds colors for each split to the config if not yet present. + """Adds colors for each split to the config if not yet present. ipcc colors are used if available, matplotlib defaults otherwise. TODO: add ipcc model colors. """ - colors = cfg.get("colors", {}).copy() # new instance for each group - mpl_default = plt.rcParams["axes.prop_cycle"].by_key()['color'] + colors = cfg.get("colors", {}).copy() # new instance for each group + mpl_default = plt.rcParams["axes.prop_cycle"].by_key()["color"] cycle = iter(cycler(color=mpl_default)) missing_splits = [s for s in splits if s not in colors] for split in missing_splits: @@ -430,18 +474,21 @@ def set_defaults(cfg): cfg.setdefault("plot_kwargs", {}) cfg.setdefault("histogram", {}) cfg.setdefault("regional_stats", {}) - cfg["regional_stats"].setdefault("fig_kwargs", - {"figsize": (15, 3), "dpi": 300}) + cfg["regional_stats"].setdefault( + "fig_kwargs", + {"figsize": (15, 3), "dpi": 300}, + ) cfg.setdefault("reuse", False) cfg.setdefault("strip_plots", False) cfg.setdefault("grid_lines", []) - cfg.setdefault("grid_line_style", { - "color": "gray", "linestyle": "--", "linewidth": 0.5 - }) + cfg.setdefault( + "grid_line_style", + {"color": "gray", "linestyle": "--", "linewidth": 0.5}, + ) def main(cfg): - """main function. executing all plots for each group.""" + """Main function. executing all plots for each group.""" set_defaults(cfg) groups = group_metadata(cfg["input_data"].values(), cfg["group_by"]) output = {} diff --git a/esmvaltool/diag_scripts/droughts/event_area_timeseries.py b/esmvaltool/diag_scripts/droughts/event_area_timeseries.py index 1a1b166d08..fe6d19b25a 100644 --- a/esmvaltool/diag_scripts/droughts/event_area_timeseries.py +++ b/esmvaltool/diag_scripts/droughts/event_area_timeseries.py @@ -57,13 +57,13 @@ import iris.plot as iplt import matplotlib.pyplot as plt import numpy as np -import numpy.ma as ma import xarray as xr import yaml from esmvalcore import preprocessor as pp from iris.analysis.cartography import area_weights from matplotlib import gridspec from matplotlib.dates import DateFormatter, YearLocator # MonthLocator +from numpy import ma import esmvaltool.diag_scripts.droughtindex.utils as ut from esmvaltool.diag_scripts.shared import ( @@ -77,14 +77,14 @@ def load_and_prepare(cfg, fname): - """apply mask and guess lat/lon bounds.""" + """Apply mask and guess lat/lon bounds.""" cube = iris.load_cube(fname) ut.guess_lat_lon_bounds(cube) old_mask = cube.data.mask new_mask = get_2d_mask(cube, tile=True) diff_mask = np.logical_xor(old_mask, new_mask) cube.data.mask = new_mask - cube.data.data[diff_mask] = np.NaN + cube.data.data[diff_mask] = np.nan return cube @@ -130,7 +130,7 @@ def calc_ratio(cube, event, weights): def get_2d_mask(cube, mask_any=False, tile=False): - """return a 2d (lat/lon) mask where any or all entries are masked. + """Return a 2d (lat/lon) mask where any or all entries are masked. Parameters ---------- @@ -151,7 +151,7 @@ def get_2d_mask(cube, mask_any=False, tile=False): def plot_area_ratios(cfg, meta, cube): - """plot area ratio of given event types for a cube of index values + """Plot area ratio of given event types for a cube of index values The area weights are normalized on the masked cube data, resulting in the ratio between area with index values in a given range and the area of all @@ -198,7 +198,7 @@ def plot_area_ratios(cfg, meta, cube): def plot(cfg, i, y, fname=None, fig=None, ax=None, label=None, full=False): - """plot area ratios for given interval and events. + """Plot area ratios for given interval and events. pass either fname to save single plots, or fig and ax to plot one axis into existing figure. """ @@ -253,8 +253,10 @@ def plot(cfg, i, y, fname=None, fig=None, ax=None, label=None, full=False): plt.close() -def plot_mmm(cfg, region=None, fig=None, axs=None, label=None, meta={}): - """plot multi model mean of area ratios for given interval and events.""" +def plot_mmm(cfg, region=None, fig=None, axs=None, label=None, meta=None): + """Plot multi model mean of area ratios for given interval and events.""" + if meta is None: + meta = {} mmm_key = f"mmm_{region}" if region else "mmm" mm_key = f"mm_{region}" if region else "mm" for event in cfg["events"]: @@ -263,7 +265,7 @@ def plot_mmm(cfg, region=None, fig=None, axs=None, label=None, meta={}): if cfg.get("intervals", None) is not None: for n, i in enumerate(cfg["intervals"]): meta["interval"] = f"{i[0]}-{i[1]}" - basename = cfg["basename"].format(**meta) + cfg["basename"].format(**meta) if cfg["subplots"]: plot(cfg, i, y, fig=fig, ax=axs[n], label=label) # else: @@ -274,7 +276,7 @@ def plot_mmm(cfg, region=None, fig=None, axs=None, label=None, meta={}): def set_defaults(cfg): - """update cfg with default values from diffmap.yml + """Update cfg with default values from diffmap.yml TODO: This could be a shared function reused in other diagnostics. """ config_file = os.path.realpath(__file__)[:-3] + ".yml" @@ -309,7 +311,7 @@ def set_timecoords(cfg): def plot_overview(cfg, data, group="unnamed"): - """prepare a figure with 1 histogrical and 3 future scenario intervals.""" + """Prepare a figure with 1 histogrical and 3 future scenario intervals.""" fig = plt.figure(**ut.sub_cfg(cfg, "overview", "fig_kwargs"), dpi=300) gs = gridspec.GridSpec(3, 2) scenario_axs = [ @@ -325,7 +327,7 @@ def plot_overview(cfg, data, group="unnamed"): twin.set_yticks(cfg.get("yticks", None)) leg_ax = fig.add_subplot(gs[1:, 0]) hist_plotted = False - for n, ((exp), e_data) in enumerate(data.groupby(["exp"])): + for n, ((_exp), e_data) in enumerate(data.groupby(["exp"])): dat = e_data.squeeze() # pick first and last interval: if not hist_plotted: @@ -377,11 +379,10 @@ def plot_overview(cfg, data, group="unnamed"): fig.tight_layout() fig.subplots_adjust(hspace=0.2) fig.savefig(get_plot_filename(f"overview_{cfg['index']}_MMM_{group}", cfg)) - return def plot_full_periods(cfg, data): - """prepare a figure with full time series for each scenario/region.""" + """Prepare a figure with full time series for each scenario/region.""" # setup figure with 1 row for legend and 1 for each scenario/region pair fig = plt.figure(**ut.sub_cfg(cfg, "fullperiod", "fig_kwargs"), dpi=300) rows = len(data["exp"]) * len(data["region"]) @@ -439,20 +440,23 @@ def plot_full_periods(cfg, data): fig.tight_layout() # fig.subplots_adjust(hspace=0.2) fig.subplots_adjust( - left=0.05, right=0.95, top=0.95, bottom=0.05, hspace=0.2 + left=0.05, + right=0.95, + top=0.95, + bottom=0.05, + hspace=0.2, ) fig.savefig(get_plot_filename(f"{cfg['index']}_MMM", cfg)) - return def plot_each_interval(cfg, exp_metas): - """create an individual figure for each interval and each scenario.""" - for i, (exp, emetas) in enumerate(exp_metas): + """Create an individual figure for each interval and each scenario.""" + for _i, (_exp, emetas) in enumerate(exp_metas): process_datasets(cfg, emetas, fig=None, axs=None) def process_datasets(cfg, metas, fig=None, axs=None): - """load all models and call event area calculation for each.""" + """Load all models and call event area calculation for each.""" last_meta = None for meta in metas: fname = meta["filename"] @@ -467,13 +471,17 @@ def process_datasets(cfg, metas, fig=None, axs=None): log.info("-- region %s", region) meta["region"] = region extracted = pp.extract_shape( - cube, shapefile="ar6", ids={"Acronym": [region]} + cube, + shapefile="ar6", + ids={"Acronym": [region]}, ) plot_area_ratios(cfg, meta, extracted) elif cfg.get("regions", False): log.info("-- combined region") extracted = pp.extract_shape( - cube, shapefile="ar6", ids={"Acronym": cfg["regions"]} + cube, + shapefile="ar6", + ids={"Acronym": cfg["regions"]}, ) if "region" in meta: del meta["region"] @@ -499,7 +507,7 @@ def process_datasets(cfg, metas, fig=None, axs=None): def extract_regions(cfg, cube): - """extract regions and return a list of cubes.""" + """Extract regions and return a list of cubes.""" extracted = {} params = {"shapefile": "ar6", "ids": {"Acronym": cfg["regions"]}} if cfg["regions"] and cfg["combine_regions"]: @@ -516,7 +524,7 @@ def extract_regions(cfg, cube): def regional_weights(cfg, cube): - """calculate area weights normalized to the total unmasked area.""" + """Calculate area weights normalized to the total unmasked area.""" # NOTE: area_weights does not apply cubes mask, normalize manually if cfg["weighted"]: weights = area_weights(cube, normalize=True) @@ -531,7 +539,7 @@ def regional_weights(cfg, cube): def calculate_event_ratios(cfg, metas, output): - """load data and save calculated event ratio timelines.""" + """Load data and save calculated event ratio timelines.""" # data: dataset x exp x region x event # data_mmm: exp x region x event if cfg["regions"] and cfg["combine_regions"]: @@ -548,7 +556,7 @@ def calculate_event_ratios(cfg, metas, output): "time": cfg["times"], } dims = list(coords.keys()) - nans = np.full([len(c) for c in coords.values()], np.NaN) + nans = np.full([len(c) for c in coords.values()], np.nan) data = xr.Dataset({"event_ratio": (dims, nans)}, coords=coords) for meta in metas.values(): cube = load_and_prepare(cfg, meta["filename"]) @@ -574,7 +582,7 @@ def calculate_event_ratios(cfg, metas, output): def load_event_ratios(cfg): - """load calculated event ratios for datasets and MMM from files.""" + """Load calculated event ratios for datasets and MMM from files.""" names = ["event_area", "event_area_mmm"] fnames = [get_diagnostic_filename(f, cfg) for f in names] datas = {} @@ -588,7 +596,7 @@ def load_event_ratios(cfg): def main(cfg): - """get common time coordinates, execute the diagnostic code. + """Get common time coordinates, execute the diagnostic code. Loop over experiments, than datasets. """ set_defaults(cfg) @@ -600,7 +608,7 @@ def main(cfg): else: data, data_mmm = calculate_event_ratios(cfg, metas, output) if not cfg["overview"]["skip"]: - for (reg), reg_data in data_mmm.groupby("region"): + for (reg), _reg_data in data_mmm.groupby("region"): plot_overview(cfg, data_mmm, group=reg) if not cfg["fullperiod"]["skip"]: plot_full_periods(cfg, data_mmm) diff --git a/esmvaltool/diag_scripts/droughts/pattern_correlation.py b/esmvaltool/diag_scripts/droughts/pattern_correlation.py index 07aaebbae8..55e5ce9051 100644 --- a/esmvaltool/diag_scripts/droughts/pattern_correlation.py +++ b/esmvaltool/diag_scripts/droughts/pattern_correlation.py @@ -113,7 +113,9 @@ def process(metas, cfg, key=None): for var, var_metas in shared.group_metadata(metas, "short_name").items(): logger.info("Processing %s (%s datasets)", var, len(var_metas)) reference = ut.select_single_metadata( - var_metas, dataset=cfg["reference"], short_name=var + var_metas, + dataset=cfg["reference"], + short_name=var, ) ref_cube = iris.load_cube(reference["filename"]) results[var] = defaultdict(list) @@ -253,7 +255,11 @@ def plot( label = "CDS-SM" x_pos, y_pos = zip(*values) axes.scatter( - x_pos, y_pos, marker=markers[ds], color="gray", label=label + x_pos, + y_pos, + marker=markers[ds], + color="gray", + label=label, ) axes.legend() if cfg.get("plot_properties"): diff --git a/esmvaltool/diag_scripts/droughts/regional_hexagons.py b/esmvaltool/diag_scripts/droughts/regional_hexagons.py index 4a9bd5e9c5..49c58f122c 100644 --- a/esmvaltool/diag_scripts/droughts/regional_hexagons.py +++ b/esmvaltool/diag_scripts/droughts/regional_hexagons.py @@ -85,12 +85,13 @@ def plot_colorbar( orientation="vertical", mappable=None, ) -> None: - """creates a colorbar in its own figure. Usefull for multi panel plots.""" + """Creates a colorbar in its own figure. Usefull for multi panel plots.""" fig, ax = plt.subplots(figsize=(1, 4), layout="constrained") if mappable is None: cmap = plot_kwargs.get("cmap", "RdYlBu") norm = mplcolors.Normalize( - vmin=plot_kwargs.get("vmin"), vmax=plot_kwargs.get("vmax") + vmin=plot_kwargs.get("vmin"), + vmax=plot_kwargs.get("vmax"), ) mappable = mplcm.ScalarMappable(norm=norm, cmap=cmap) fig.colorbar( @@ -99,8 +100,7 @@ def plot_colorbar( orientation=orientation, label=plot_kwargs["cbar_label"], ) - if plotfile.endswith(".png"): - plotfile = plotfile[:-4] + plotfile = plotfile.removesuffix(".png") fig.savefig(plotfile + "_cb.png", bbox_inches="tight") @@ -157,7 +157,10 @@ def hexmap( figsize = (12, 6) if cfg["cbar"] and not cfg["strip_plot"] else (10, 6) fig, axx = plt.subplots(figsize=figsize, dpi=300, frameon=False) axx.tick_params( - bottom=False, left=False, labelbottom=False, labelleft=False + bottom=False, + left=False, + labelbottom=False, + labelleft=False, ) axx.set_xlim(-0.5, 19.5) axx.set_ylim(0, 12) @@ -176,7 +179,7 @@ def hexmap( [0, -r], [-rx, -r / 2], [-rx, r / 2], - ] + ], ) cells = ut.get_hex_positions() # dict of coordinates for hexagons cells = { @@ -190,7 +193,7 @@ def hexmap( if "levels" in cfg: lvls = cfg["levels"] # cmap_colors = [cmap(i/(vmax-vmin)) for i in lvls] - cmap_colors = [cmap(norm(l)) for l in lvls] + cmap_colors = [cmap(norm(lvl)) for lvl in lvls] cmap = mplcolors.ListedColormap(cmap_colors) norm = mplcolors.BoundaryNorm(lvls, cmap.N) cmap.set_bad(cfg.get("cmap_nan", "lightgray"), 1.0) @@ -292,16 +295,16 @@ def hexmap( def ensure_single_meta(meta, txt): - """raise error if there is not exactly one entry in meta list""" + """Raise error if there is not exactly one entry in meta list.""" if len(meta) == 0: raise ValueError(f"No files for {txt}.") - elif len(meta) > 1: + if len(meta) > 1: raise ValueError(f"Too many files for {txt}.") return meta[0] def load_and_plot_splits(cfg, splits, group, operator): - """create hexmap with tiles belonging to different meta data / files""" + """Create hexmap with tiles belonging to different meta data / files.""" if len(splits) > 6: raise ValueError("Too many inputs for {group}. Max: 6.") labels = cfg.get("labels", list(splits.keys())) @@ -321,7 +324,7 @@ def load_and_plot_splits(cfg, splits, group, operator): def load_and_plot_stats(cfg, metas, group, split, statistics): - """create hexmap with tiles for different operators for the same data""" + """Create hexmap with tiles for different operators for the same data.""" meta = ensure_single_meta(metas, f"{group}") cube = iris.load_cube(meta["filename"]) values, regions = [], [] @@ -339,7 +342,7 @@ def load_and_plot_stats(cfg, metas, group, split, statistics): def set_defaults(cfg): - """ensure all config parameters are set.""" + """Ensure all config parameters are set.""" cfg.setdefault("group_by", "dataset") cfg.setdefault("statistics", ["mean"]) cfg.setdefault("cmap", "YlOrRd") @@ -357,7 +360,8 @@ def set_defaults(cfg): cfg.setdefault("regions", []) cfg.setdefault("filename", "{group}_{split}_{operator}.png") cfg.setdefault( - "select_metadata", {"short_name": "spei", "diffmap_metric": "diff"} + "select_metadata", + {"short_name": "spei", "diffmap_metric": "diff"}, ) diff --git a/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py index 8971494410..0163313270 100644 --- a/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py +++ b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py @@ -64,7 +64,6 @@ def convert_units(cube): ut.monthly2daily(cube) cube.long_name = "Potential Evapotranspiration" # NOTE: not working? cube.rename("Potential Evapotranspiration") - return None def global_mean(cfg, cube): @@ -73,11 +72,15 @@ def global_mean(cfg, cube): if "regions" in cfg: print("Extracting regions") cube = pp.extract_shape( - cube, shapefile="ar6", ids={"Acronym": cfg["regions"]} + cube, + shapefile="ar6", + ids={"Acronym": cfg["regions"]}, ) area_weights = cartography.area_weights(cube) mean = cube.collapsed( - ["latitude", "longitude"], MEAN, weights=area_weights + ["latitude", "longitude"], + MEAN, + weights=area_weights, ) return mean @@ -148,7 +151,9 @@ def plot_models(cfg, metas, ax, smooth=False): # mean = global_mean(mm["mean"]) if recalc: mm = pp.multi_model_statistics( - cubes, "overlap", ["mean", "std_dev"] + cubes, + "overlap", + ["mean", "std_dev"], ) std_dev = mm["std_dev"] # global_mean(mm["std_dev"]) mean = mm["mean"] @@ -170,7 +175,11 @@ def plot_models(cfg, metas, ax, smooth=False): ) if not historical_plotted: plot_experiment( - cfg, mean[:steps], std_dev[:steps], "historical", ax + cfg, + mean[:steps], + std_dev[:steps], + "historical", + ax, ) historical_plotted = True continue @@ -207,7 +216,11 @@ def process_variable(cfg, metas, short_name, fig=None, ax: Axes = None): ax.set_xticklabels([]) ax.set_xticks([]) ax.grid( - axis="both", color="0.6", which="major", linestyle="--", linewidth=0.5 + axis="both", + color="0.6", + which="major", + linestyle="--", + linewidth=0.5, ) # ax.set_frame_on(False) ax.set_xlim([dt.datetime(1950, 1, 1), dt.datetime(2100, 1, 1)]) @@ -231,7 +244,10 @@ def main(cfg): figsize = cfg.get("figsize", (9, 2)) height = len(groups) * (figsize[1] + 0.5) fig, axs = plt.subplots( - len(groups), 1, figsize=(figsize[0], height), dpi=300 + len(groups), + 1, + figsize=(figsize[0], height), + dpi=300, ) for i, (short_name, metas) in enumerate(groups.items()): process_variable(cfg, metas, short_name, fig=fig, ax=axs[i]) @@ -257,13 +273,18 @@ def main(cfg): for spine in ["top", "bottom"]: ax.spines[spine].set_visible(False) axs[-1].tick_params( - axis="x", which="both", bottom=True, top=False, labelbottom=True + axis="x", + which="both", + bottom=True, + top=False, + labelbottom=True, ) axs[-1].spines["bottom"].set_visible(True) axs[0].spines["top"].set_visible(True) lines, labels = axs[-1].get_legend_handles_labels() if cfg.get( - "legend", cfg.get("subplots", False) + "legend", + cfg.get("subplots", False), ): # rename and reorder handles and labels leg_dict = dict(zip(labels, lines)) print(labels) From 86887d890fef5b9f308c3d256a393dbe849ff417 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 12 Mar 2025 12:22:24 +0100 Subject: [PATCH 58/66] pattcorr plot --- .../droughts/pattern_correlation.py | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/pattern_correlation.py b/esmvaltool/diag_scripts/droughts/pattern_correlation.py index 55e5ce9051..48efd5d14a 100644 --- a/esmvaltool/diag_scripts/droughts/pattern_correlation.py +++ b/esmvaltool/diag_scripts/droughts/pattern_correlation.py @@ -1,5 +1,4 @@ """Overview plots for pattern correlations of multiple variables. -================================================================= This diagnostic calculates the pattern correlation between pairs of datasets over several datasets and plots them for all variables and datasets in one @@ -48,17 +47,21 @@ # from esmvalcore import preprocessor as pp import logging from collections import defaultdict +from pathlib import Path from pprint import PrettyPrinter import iris import iris.analysis import iris.analysis.cartography +import iris.plot as iplt import matplotlib.pyplot as plt +import numpy as np from iris.analysis import MEAN from iris.analysis.stats import pearsonr +from numpy import ma from esmvaltool.diag_scripts import shared -from esmvaltool.diag_scripts.droughtindex import utils as ut +from esmvaltool.diag_scripts.droughts import utils as ut logger = logging.getLogger(__file__) p = PrettyPrinter(indent=4) @@ -66,7 +69,8 @@ def pattern_correlation(cube1, cube2, centered=False, weighted=False): """Calculate pattern correlation between two 2d-cubes. - uses pearsonr from scipy.stats to calculate the correlation coefficient + + Uses pearsonr from scipy.stats to calculate the correlation coefficient along latitude and longitude coordinates. Returns area weighted coefficient. weighted applies only to the centering (weighted mean subtraction) and not @@ -104,18 +108,10 @@ def process(metas, cfg, key=None): # reference = shared.select_metadata(metas, dataset=cfg["reference"]) cfg["variables"] = shared.group_metadata(metas, "short_name").keys() extra_labels = defaultdict(list) - # print( - # [ - # m["short_name"] - # for m in shared.group_metadata(metas, "dataset")["ERA5"] - # ] - # ) for var, var_metas in shared.group_metadata(metas, "short_name").items(): logger.info("Processing %s (%s datasets)", var, len(var_metas)) reference = ut.select_single_metadata( - var_metas, - dataset=cfg["reference"], - short_name=var, + var_metas, dataset=cfg["reference"], short_name=var ) ref_cube = iris.load_cube(reference["filename"]) results[var] = defaultdict(list) @@ -123,20 +119,13 @@ def process(metas, cfg, key=None): if ds_meta["dataset"] in ["MMM", cfg["reference"]]: continue ds_meta["project"] = ds_meta.get("project", "unknown") - # TODO: this need to be fixed in diag_pet.R: - if var == "evspsblpot" and ds_meta["project"] == "unknown": - ds_meta["project"] = "CMIP6" cube = iris.load_cube(ds_meta["filename"]) - # print(ds_meta['filename']) - # print(cube) ds_result = float(pattern_correlation(ref_cube, cube)) if ds_meta["project"] in cfg.get("projects", ["CMIP6"]): results[var][ds_meta["project"]].append(ds_result) elif cfg.get("extra_datasets", True): results[var]["extra"].append(ds_result) extra_labels[var].append(ds_meta["dataset"]) - # print("RSULTS") - # print(ds_result) title = None if cfg.get("plot_title", False): title = f"Pattern Correlation with {cfg['reference']} ({key})" @@ -165,7 +154,6 @@ def process_relative_change(metas, cfg): cube = iris.load_cube(ds_meta["filename"]) iris.analysis.maths.abs(cube, in_place=True) rel = cube.collapsed(["latitude", "longitude"], iris.analysis.MEAN) - print(rel) ds_result = float(rel.data) if ds_meta["project"] in cfg.get("projects", ["CMIP6"]): results[var][ds_meta["project"]].append(ds_result) @@ -209,8 +197,6 @@ def plot( var_count = len(results.keys()) # TODO: Start with simple case for 2 groups (add multiple groups later) projects = cfg.get("projects", ["CMIP6"]) - # proj_count = len(groups) - # shift = 0.8 / group_count axes.set_title(title) axes.plot([], **plot_kwargs) @@ -241,12 +227,9 @@ def plot( if cfg.get("extra_datasets", True): markers = ds_markers(extra_labels) scatters = defaultdict(list) - print("plotting extra data") for i, var in enumerate(results.keys()): # TODO add label here manually? y_pos = results[var]["extra"] - print(var) - print(y_pos) for j, value in enumerate(y_pos): scatters[extra_labels[var][j]].append((i + 0.2, value)) for ds, values in scatters.items(): @@ -255,11 +238,7 @@ def plot( label = "CDS-SM" x_pos, y_pos = zip(*values) axes.scatter( - x_pos, - y_pos, - marker=markers[ds], - color="gray", - label=label, + x_pos, y_pos, marker=markers[ds], color="gray", label=label ) axes.legend() if cfg.get("plot_properties"): From 4bd10964de8f25f416e5ff06a14d0b846002a754 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 12 Mar 2025 17:06:37 +0100 Subject: [PATCH 59/66] single model provenance --- esmvaltool/diag_scripts/droughts/diffmap.py | 82 +++++++++++++++++--- esmvaltool/diag_scripts/droughts/diffmap.yml | 2 +- esmvaltool/diag_scripts/droughts/utils.py | 14 +++- 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index 92644b9753..e3b65f7e25 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -97,7 +97,11 @@ METRICS = ["first", "last", "diff", "total", "percent"] - +PROVENANCE = { + "authors": ["lindenlaub_lukas"], + "domains": ["global"], + "plot_types": ["map"], +} def plot_colorbar( cfg: dict, @@ -154,8 +158,11 @@ def plot( cube: Cube, basename: str, kwargs: dict | None = None, -) -> None: - """Plot map using diag_scripts.shared module.""" +) -> str: + """Plot map using diag_scripts.shared module. + + Returns the plot filename. + """ plotfile = e.get_plot_filename(basename, cfg) plot_kwargs = cfg.get("plot_kwargs", {}).copy() if kwargs is not None: @@ -189,6 +196,7 @@ def plot( fig.savefig(plotfile, bbox_inches="tight") plt.close() log.info("saved figure: %s", plotfile) + return plotfile def apply_plot_kwargs_overwrite( @@ -213,6 +221,51 @@ def apply_plot_kwargs_overwrite( kwargs.update(new_kwargs) return kwargs +def get_provenance(cfg: dict, meta: dict) -> dict: + """Create provenance dict for single model plots.""" + prov = PROVENANCE.copy() + prov["statistics"] = ["mean"] + dataset = meta.get("dataset", "unknown") + if dataset == "MMM": + dataset = "Multi-Model Mean" + if meta["diffmap_metric"] == "diff": + prov["statistics"] = ["diff", "mean"] + prov["caption"] = ( + f"Absolute difference in {meta['long_name']} between first and " + f"last {cfg['comparison_period']} years of the period " + f"{meta['start_year']}-{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "percent": + prov["statistics"] = ["diff", "mean"] + prov["caption"] = ( + f"Relative difference in {meta['long_name']} between first and " + f"last {cfg['comparison_period']} years of the period " + f"{meta['start_year']}-{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "first": + prov["caption"] = ( + f"Average {meta['long_name']} over the period " + f"{meta['start_year']}-" + f"{meta['start_year']+cfg['comparison_period']-1}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "last": + prov["caption"] = ( + f"Average {meta['long_name']} over the period " + f"{meta['end_year']-cfg['comparison_period']+1}-" + f"{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "total": + prov["caption"] = ( + f"Average {meta['long_name']} over the period " + f"{meta['start_year']}-{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + return prov + def calculate_diff(cfg, meta, mm_data, output_meta, group) -> None: """Absolute difference between first and last years of a cube. @@ -231,7 +284,7 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group) -> None: cube = pp.extract_time( cube, cfg["start_year"], 1, 1, cfg["end_year"], 12, 31 ) - dtime = cfg.get("comparison_period", 10) * 12 + dtime = cfg["comparison_period"] * 12 cubes = {} cubes["total"] = cube.collapsed("time", MEAN) do_metrics = cfg.get("metrics", METRICS) @@ -239,7 +292,7 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group) -> None: int(meta["end_year"]) - int(meta["start_year"]) + 1 # count full end year - - cfg.get("comparison_period", 10) # decades center to center + - cfg["comparison_period"] # decades center to center ) / 10 cubes["first"] = cube[0:dtime].collapsed("time", MEAN) cubes["last"] = cube[-dtime:].collapsed("time", MEAN) @@ -258,6 +311,8 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group) -> None: meta["exp"] = meta.get("exp", "exp") basename = cfg["basename"].format(**meta) meta["title"] = cfg["titles"][key].format(**meta) + prov = get_provenance(cfg, meta) + prov["ancestors"] = [meta["filename"]] if cfg.get("plot_models", True): plot_kwargs = cfg.get("plot_kwargs", {}).copy() apply_plot_kwargs_overwrite( @@ -266,23 +321,25 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group) -> None: key, group, ) - plot(cfg, meta, cube, basename, kwargs=plot_kwargs) + plotfile = plot(cfg, meta, cube, basename, kwargs=plot_kwargs) plt.close() + ut.log_provenance(cfg, plotfile, prov) if cfg.get("save_models", True): work_file = str(Path(cfg["work_dir"]) / f"{basename}.nc") iris.save(cube, work_file) meta["filename"] = work_file output_meta[work_file] = meta.copy() + ut.log_provenance(cfg, work_file, prov) def calculate_mmm(cfg, meta, mm_data, output_meta, group) -> None: """Calculate multi-model mean for a given metric.""" for metric in cfg.get("metrics", METRICS): - drop = cfg.get("dropcoords", ["time", "height"]) meta = meta.copy() # don't modify meta in place: - meta["dataset"] = "MMM" meta["diffmap_metric"] = metric basename = cfg["basename"].format(**meta) + drop = cfg.get("dropcoords", ["time", "height", "realization"]) + # TODO: use pp mean and stdv instead of iris? mmm, _ = ut.mmm( mm_data[metric], dropcoords=drop, @@ -298,7 +355,6 @@ def calculate_mmm(cfg, meta, mm_data, output_meta, group) -> None: if cfg.get("save_mmm", True): work_file = str(Path(cfg["work_dir"]) / f"{basename}.nc") meta["filename"] = work_file - meta["diffmap_metric"] = metric output_meta[work_file] = meta.copy() iris.save(mmm, work_file) @@ -341,13 +397,19 @@ def main(cfg) -> None: for meta in g_metas: if "end_year" not in meta: meta.update(ut.get_time_range(meta["filename"])) + print(meta['end_year']) # adjust norm for selected time period meta["end_year"] = cfg.get("end_year", meta["end_year"]) meta["start_year"] = cfg.get("start_year", meta["start_year"]) calculate_diff(cfg, meta, mm_data, output, group) do_mmm = cfg.get("plot_mmm", True) or cfg.get("save_mmm", True) if do_mmm and len(g_metas) > 1: - calculate_mmm(cfg, g_metas[0], mm_data, output, group) + # copy meta from first dataset and add all ancestors + meta = ut.get_common_meta(g_metas) + meta["ancestors"] = [met["filename"] for met in g_metas] + meta["dataset"] = "MMM" + del meta["institute"] + calculate_mmm(cfg, meta, mm_data, output, group) ut.save_metadata(cfg, output) diff --git a/esmvaltool/diag_scripts/droughts/diffmap.yml b/esmvaltool/diag_scripts/droughts/diffmap.yml index 30cc210953..4f2a71120e 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.yml +++ b/esmvaltool/diag_scripts/droughts/diffmap.yml @@ -7,7 +7,7 @@ group_by: short_name -comparison_period: 15 +comparison_period: 10 plot_kwargs: cmap: RdYlBu extend: both diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 01e5414993..481d119534 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -553,7 +553,7 @@ def mmm( mdtol: float = 0, dropcoords: list | None = None, *, - dropmethods=False, + dropmethods: bool = False, ) -> tuple: """Calculate mean and stdev along a cube list over all cubes. @@ -586,7 +586,7 @@ def mmm( merged = cube_list.merge_cube() except iris.exceptions.MergeError as err: iris.util.describe_diff(cube_list[0], cube_list[1]) - raise iris.exceptions.MergeError from err + raise err if mdtol > 0: log.info("performing MMM with tolerance: %s", mdtol) mean = merged.collapsed("dataset", iris.analysis.MEAN, mdtol=mdtol) @@ -698,6 +698,16 @@ def select_meta_from_combi(meta: list, combi: dict, groups: dict) -> tuple: return this_meta, this_cfg +def get_common_meta(metas: list) -> dict: + """Return a dictionary of meta data, that is common between all inputs.""" + common = {} + for key in metas[0]: + unique_values = {m[key] for m in metas} + if len(unique_values) == 1: + common[key] = unique_values.pop() + return common + + def list_meta_keys(meta: list, group: dict) -> list: """Return a list of all keys found for a group in the meta data.""" return list(group_metadata(meta, group).keys()) From 52a7c6b60d99ce4ab20726c6da50ebdc0d3456c8 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Thu, 13 Mar 2025 12:12:58 +0100 Subject: [PATCH 60/66] add plots, provenance for diffmap --- esmvaltool/diag_scripts/droughts/diffmap.py | 99 +-- .../droughts/pattern_correlation.py | 31 +- .../diag_scripts/droughts/portrait_plot.py | 562 ++++++++++++++++++ esmvaltool/diag_scripts/droughts/utils.py | 40 +- .../recipe_lindenlaub25_historical.yml | 33 +- .../recipe_lindenlaub25_scenarios.yml | 181 +++--- 6 files changed, 786 insertions(+), 160 deletions(-) create mode 100644 esmvaltool/diag_scripts/droughts/portrait_plot.py diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index e3b65f7e25..d0a649d568 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -103,6 +103,53 @@ "plot_types": ["map"], } + +def get_provenance(cfg: dict, meta: dict) -> dict: + """Create provenance dict for single model plots.""" + prov = PROVENANCE.copy() + prov["statistics"] = ["mean"] + dataset = meta.get("dataset", "unknown") + if dataset == "MMM": + dataset = "Multi-Model Mean" + if meta["diffmap_metric"] == "diff": + prov["statistics"] = ["diff", "mean"] + prov["caption"] = ( + f"Absolute difference in {meta['long_name']} between first and " + f"last {cfg['comparison_period']} years of the period " + f"{meta['start_year']}-{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "percent": + prov["statistics"] = ["diff", "mean"] + prov["caption"] = ( + f"Relative difference in {meta['long_name']} between first and " + f"last {cfg['comparison_period']} years of the period " + f"{meta['start_year']}-{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "first": + prov["caption"] = ( + f"Average {meta['long_name']} over the period " + f"{meta['start_year']}-" + f"{meta['start_year'] + cfg['comparison_period'] - 1}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "last": + prov["caption"] = ( + f"Average {meta['long_name']} over the period " + f"{meta['end_year'] - cfg['comparison_period'] + 1}-" + f"{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "total": + prov["caption"] = ( + f"Average {meta['long_name']} over the period " + f"{meta['start_year']}-{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + return prov + + def plot_colorbar( cfg: dict, plotfile: str, @@ -221,51 +268,6 @@ def apply_plot_kwargs_overwrite( kwargs.update(new_kwargs) return kwargs -def get_provenance(cfg: dict, meta: dict) -> dict: - """Create provenance dict for single model plots.""" - prov = PROVENANCE.copy() - prov["statistics"] = ["mean"] - dataset = meta.get("dataset", "unknown") - if dataset == "MMM": - dataset = "Multi-Model Mean" - if meta["diffmap_metric"] == "diff": - prov["statistics"] = ["diff", "mean"] - prov["caption"] = ( - f"Absolute difference in {meta['long_name']} between first and " - f"last {cfg['comparison_period']} years of the period " - f"{meta['start_year']}-{meta['end_year']}, based on " - f"{meta['dataset']}." - ) - elif meta["diffmap_metric"] == "percent": - prov["statistics"] = ["diff", "mean"] - prov["caption"] = ( - f"Relative difference in {meta['long_name']} between first and " - f"last {cfg['comparison_period']} years of the period " - f"{meta['start_year']}-{meta['end_year']}, based on " - f"{meta['dataset']}." - ) - elif meta["diffmap_metric"] == "first": - prov["caption"] = ( - f"Average {meta['long_name']} over the period " - f"{meta['start_year']}-" - f"{meta['start_year']+cfg['comparison_period']-1}, based on " - f"{meta['dataset']}." - ) - elif meta["diffmap_metric"] == "last": - prov["caption"] = ( - f"Average {meta['long_name']} over the period " - f"{meta['end_year']-cfg['comparison_period']+1}-" - f"{meta['end_year']}, based on " - f"{meta['dataset']}." - ) - elif meta["diffmap_metric"] == "total": - prov["caption"] = ( - f"Average {meta['long_name']} over the period " - f"{meta['start_year']}-{meta['end_year']}, based on " - f"{meta['dataset']}." - ) - return prov - def calculate_diff(cfg, meta, mm_data, output_meta, group) -> None: """Absolute difference between first and last years of a cube. @@ -351,7 +353,10 @@ def calculate_mmm(cfg, meta, mm_data, output_meta, group) -> None: plot_kwargs = cfg.get("plot_kwargs", {}).copy() overwrites = cfg.get("plot_kwargs_overwrite", []) apply_plot_kwargs_overwrite(plot_kwargs, overwrites, metric, group) - plot(cfg, meta, mmm, basename, kwargs=plot_kwargs) + plot_file = plot(cfg, meta, mmm, basename, kwargs=plot_kwargs) + prov = get_provenance(cfg, meta) + prov["ancestors"] = meta["ancestors"] + ut.log_provenance(cfg, plot_file, prov) if cfg.get("save_mmm", True): work_file = str(Path(cfg["work_dir"]) / f"{basename}.nc") meta["filename"] = work_file @@ -397,7 +402,6 @@ def main(cfg) -> None: for meta in g_metas: if "end_year" not in meta: meta.update(ut.get_time_range(meta["filename"])) - print(meta['end_year']) # adjust norm for selected time period meta["end_year"] = cfg.get("end_year", meta["end_year"]) meta["start_year"] = cfg.get("start_year", meta["start_year"]) @@ -408,7 +412,6 @@ def main(cfg) -> None: meta = ut.get_common_meta(g_metas) meta["ancestors"] = [met["filename"] for met in g_metas] meta["dataset"] = "MMM" - del meta["institute"] calculate_mmm(cfg, meta, mm_data, output, group) ut.save_metadata(cfg, output) diff --git a/esmvaltool/diag_scripts/droughts/pattern_correlation.py b/esmvaltool/diag_scripts/droughts/pattern_correlation.py index 48efd5d14a..bd63ec9c26 100644 --- a/esmvaltool/diag_scripts/droughts/pattern_correlation.py +++ b/esmvaltool/diag_scripts/droughts/pattern_correlation.py @@ -26,39 +26,38 @@ ------------------------------- reference: str, required Dataset name used to correlate all other datasets against. +extra_datasets: bool, optional + Add datasets not belonging to any of the ``projects`` as individual markers. + This can be used to add individual reanalysis. By default: True +fig_kwargs: dict, optional + Kwargs passed to the figure creation. By default: {"figsize": (4, 3)} group_by: str, optional (default: None) Plot figures for each entry of the given key. +labels: dict, optional + Mapping of variable names to custom xtick labels. Falls back to variable + name if not present. project_plot_kwargs: dict, optional (default: {}) Kwargs passed to the patches for specific projects the plot function. plot_kwargs: dict, optional (default: {}) Kwargs passed to the plot function. projects: list, optional (default: ["CMIP6"]) List of projects to include as individual colors in the plot. -extra_datasets: bool, optional (default: True) - Add datasets not belonging to any of the projects as individual markers. relative_change: bool, optional (default: False) Creates an additional plot with global relative changes fo each variable in all datasets. -labels: dict, optional - Mapping of variable names to custom xtick labels. Falls back to variable - name if not present. """ # from esmvalcore import preprocessor as pp import logging from collections import defaultdict -from pathlib import Path from pprint import PrettyPrinter import iris import iris.analysis import iris.analysis.cartography -import iris.plot as iplt import matplotlib.pyplot as plt -import numpy as np from iris.analysis import MEAN from iris.analysis.stats import pearsonr -from numpy import ma from esmvaltool.diag_scripts import shared from esmvaltool.diag_scripts.droughts import utils as ut @@ -110,6 +109,7 @@ def process(metas, cfg, key=None): extra_labels = defaultdict(list) for var, var_metas in shared.group_metadata(metas, "short_name").items(): logger.info("Processing %s (%s datasets)", var, len(var_metas)) + print(var_metas) reference = ut.select_single_metadata( var_metas, dataset=cfg["reference"], short_name=var ) @@ -190,12 +190,17 @@ def plot( ) else: results = {key: results[key] for key in cfg["order"]} - - fig, axes = plt.subplots(figsize=(4, 3)) - plot_kwargs = dict(marker="_", linestyle="", markersize="5") + fig_kwargs = {"figsize": (4, 3)} + fig_kwargs.update(cfg.get("fig_kwargs", {})) + fig, axes = plt.subplots(**fig_kwargs) + plot_kwargs = { + "marker": "_", + "linestyle": "", + "markersize": "5", + "color": "darkred", + } plot_kwargs.update(cfg.get("plot_kwargs", {})) var_count = len(results.keys()) - # TODO: Start with simple case for 2 groups (add multiple groups later) projects = cfg.get("projects", ["CMIP6"]) axes.set_title(title) diff --git a/esmvaltool/diag_scripts/droughts/portrait_plot.py b/esmvaltool/diag_scripts/droughts/portrait_plot.py new file mode 100644 index 0000000000..77daec85c3 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/portrait_plot.py @@ -0,0 +1,562 @@ +"""Overview plot for performance metrics. + +Description +----------- +This diagnostic provides plot functionalities for performance metrics. +The multi model overview heatmap might be useful for different +tasks and therefore this diagnostic tries to be as flexible as possible. +X and Y axis, grouping parameter and slits for each rectangle can be +configured in the recipe. All *_by parameters can be set to any metadata +key. To split by 'reference' this key needs to be set as extra_facet in recipe. + +Author +------ +Lukas Ruhe (Universität Bremen, Germany) +Diego Cammarano + +Configuration parameters through recipe: +---------------------------------------- +normalize: str or None, optional + ('mean', 'median', 'centered_mean', 'centered_median', None). + Subtract median/mean if centered. Divide by median/mean if not None. + By default None. +distance_metric: str or None, optional + A method for the distance_metric preprocessor can be set, to apply it to + the input data along all axis before plotting. If set to None, the input + is expected to contain scalar values for each input file. By default, None. +x_by: str, optional + Metadata key for x coordinate. + By default 'alias'. +y_by: str, optional + Metadata key for y coordinate. + By default 'variable_group'. +group_by: str, optional + Metadata key for grouping. + Grouping is always applied in x direction. Can be set to None to skip + grouping into subplots. By default 'project'. +split_by: str, optional + The rectangles can be split into 2-4 triangles. This is used to show + metrics for different references. For this case there is no need to change + this parameter. Multiple variables can be set in the recipe with `split` + assigned as extra_facet to label the different references. Data without + a split assigned will be plotted as main rectangles, this can be changed + by setting default_split parameter. + By default 'split'. +default_split: str, optional + Data labeled with this string, will be used as main rectangles. All other + splits will be plotted as overlays. This can be used to choose the base + reference, while all references are labeled for the legend. + By default None. +plot_legend: bool, optional + If True, a legend is plotted, when multiple splits are given. + By default True. +legend: dict, optional + Customize, if, how and where the legend is plotted. The 'best' position + and size of the legend depends on multiple parameters of the figure + (i.e. lengths of labels, aspect ratio of the plots...). And might require + manual adjustment of `x`, `y` and `size` to fit the figure layout. + Keys (each optional) that will be handled are: + position: str or None, optional + Position of the legend. Can be 'right' or 'left'. Or set to None to + disable plotting the legend. By default 'right'. + x_offset: float, optional + Manually adjust horizontal position to save space or fix overlap. + Number given in Inches. By default 0. + y_offset: float, optional + Manually adjust vertical position to save space or fix overlap. + Number given in Inches. By default 0. + size: float, optional + Size of the legend in Inches. By default 0.3. +plot_kwargs: dict, optional + Dictionary that gets passed as kwargs to `matplotlib.pyplot.imshow()`. + Colormaps will be converted to 11 discrete steps automatically. Default + colormap is RdYlBu_r but can be changed with cmap. + Other common keywords: vmin, vmax + By default {}. +cbar_kwargs: dict, optional + Dictionary that gets passed to `matplotlib.pyplot.colorbar()`. + E.g. label, ticks... + By default {}. +axes_properties: dict, optional + Dictionary that gets passed to `matplotlib.axes.Axes.set()`. + Subplots can be widely customized. For a full list of + properties see: + https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.set.html + E.g. xlabel, ylabel, yticklabels, xmargin... + By default {}. +nan_color: str or None, optional + Matplotlib named color or hexcode for NaN values. If set to None (null in + yaml), no triangles are plotted for NaN values. + By default 'white'. +figsize: list(float), optional + [width, height] of the figure in inches. The final figure will be saved with + bbox_inches="tight", which can change the resulting aspect ratio. + By default [5, 3]. +dpi: int, optional + Dots per inch for the figure. By default 300. +regions: list(str), optional + If list of acronyms is provided, data is limited to this + regions (rather than global). +""" + +import itertools +import logging + +import iris +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +import xarray as xr +from esmvalcore import preprocessor as pp +from matplotlib import patches +from mpl_toolkits.axes_grid1 import ImageGrid + +from esmvaltool.diag_scripts.shared import ( + get_diagnostic_filename, + get_plot_filename, + group_metadata, + run_diagnostic, + select_metadata, +) + +log = logging.getLogger(__name__) + + +def unify_limits(grid): + """Set same limits for all subplots.""" + vmin, vmax = np.inf, -np.inf + images = [ax.get_images()[0] for ax in grid] + for img in images: + vmin = min(vmin, img.get_clim()[0]) + vmax = max(vmax, img.get_clim()[1]) + for img in images: + img.set_clim(vmin, vmax) + + +def plot_matrix(data, row_labels, col_labels, axe, plot_kwargs): + """Create an image for given data.""" + img = axe.imshow(data, **plot_kwargs) + # Show all ticks and label them with the respective list entries. + axe.set_xticks(np.arange(data.shape[1]), labels=col_labels) + axe.set_yticks(np.arange(data.shape[0]), labels=row_labels) + # Rotate the tick labels and set their alignment. + plt.setp( + axe.get_xticklabels(), + rotation=90, + ha="right", + va="center", + rotation_mode="anchor", + ) + # Turn spines off and create white grid. + # ax.spines[:].set_visible(False) + axe.set_xticks(np.arange(data.shape[1] + 1) - 0.5, minor=True) + axe.set_yticks(np.arange(data.shape[0] + 1) - 0.5, minor=True) + axe.grid(which="minor", color="black", linestyle="-", linewidth=1) + axe.tick_params(which="both", bottom=False, left=False) + return img + + +def remove_reference(metas): + """Remove reference for metric from list of metadata. + + NOTE: list() creates a copy with same references to allow removing in place + """ + for meta in list(metas): + if meta.get("reference_for_metric", False): + metas.remove(meta) + + +def add_split_none(metas): + """List of metadata with split=None if no split is given.""" + for meta in metas: + if "split" not in meta: + meta["split"] = None + + +def open_file(cfg, metadata, **selection): + """Try to find a single file for selection and return data. + + If multiple files are found, raise an error. If no file is found, + return np.nan. + """ + metas = select_metadata(metadata, **selection) + if len(metas) > 1: + raise ValueError(f"Multiple files found for {selection}") + if len(metas) < 1: + log.debug("No Metadata found for %s", selection) + return np.nan + log.debug("Metadata found for %s", selection) + if cfg.get("regions", False): + print("Using iris/esmvalcore to extract regions") + cube = iris.load_cube(metas[0]["filename"]) + cube = pp.extract_shape( + cube, shapefile="ar6", ids={"Acronym": cfg["regions"]} + ) + das = xr.DataArray.from_iris(cube).to_dataset() + else: + das = xr.open_dataset(metas[0]["filename"]) + varname = list(das.data_vars.keys())[0] + return das[varname].values.item() + # iris.load_cube(metas[0]["filename"]).data + + +def load_data(cfg, metas): + """Load all nc files from metadata into xarray dataset. + + The dataset contains all relevant information for the plot. Coord + names are metadata keys, ordered as x, y, group, split. The default + reference is None, or if all references are named the first from the + list. + """ + coords = { # order matters: x, y, group, split + cfg["x_by"]: list(group_metadata(metas, cfg["x_by"]).keys()), + cfg["y_by"]: list(group_metadata(metas, cfg["y_by"]).keys()), + cfg["group_by"]: list(group_metadata(metas, cfg["group_by"]).keys()), + cfg["split_by"]: [ + str(k) for k in group_metadata(metas, cfg["split_by"]).keys() + ], + } + print(coords) + shape = [len(coord) for coord in coords.values()] + var_data = xr.DataArray(np.full(shape, np.nan), dims=list(coords.keys())) + data = xr.Dataset({"var": var_data}, coords=coords) + # loop over each cell (coord combination) and load data if existing + for coord_tuple in itertools.product(*coords.values()): + selection = dict(zip(coords.keys(), coord_tuple)) + print(selection) + data["var"].loc[selection] = open_file(cfg, metas, **selection) + # data[coord_tuple] = (list(coords.keys(), value)) + if None in data.coords[cfg["split_by"]].values: + cfg.update({"default_split": None}) + else: + cfg.update({"default_split": data.coords[cfg["split_by"]].values[0]}) + log.debug("using %s as default split", cfg["default_split"]) + log.debug("Loaded Data:") + log.debug(data) + return data + + +def split_legend(cfg, grid, data): + """Create legend for references, based on split coordinate in the dataset. + + Mpl handles axes positions in relative figure coordinates. To anchor the + legend to the origin of the first graph (bottom left) with fixed size, + without messing up the layout for changing figure sizes a few extra steps + are required. + NOTE: maybe `mpl_toolkits.axes_grid1.axes_divider.AxesDivider` simplifies + this a bit by using `append_axes`. + """ + grid[0].get_figure().canvas.draw() # set axes position in figure + size = cfg["legend"].get("size", 0.5) # rect width in physical size (inch) + fig_size = grid[0].get_figure().get_size_inches() # physical figure size + ax_size = (size / fig_size[0], size / fig_size[1]) # legend (fig coords) + gaps = [0.3 / fig_size[0], 0.3 / fig_size[1]] # margins (fig coords) + # anchor legend on origin of first plot or colorbar + anchor = grid[0].get_position().bounds # relative figure coordinates + if cfg["legend"].get("position", "right") == "right": + cbar_x = grid.cbar_axes[0].get_position().bounds[0] + gaps[0] *= 0.8 # compensate colorbar padding + anchor = ( + cbar_x + gaps[0] + cfg["legend"]["x_offset"], + anchor[1] - gaps[1] - ax_size[1] + cfg["legend"]["y_offset"], + ) + else: + anchor = ( + anchor[0] - gaps[0] - ax_size[0] + cfg["legend"]["x_offset"], + anchor[1] - gaps[1] - ax_size[1] + cfg["legend"]["y_offset"], + ) + # create legend as empty imshow like axes in figure coordinates + axes = {"main": grid[0].get_figure().add_axes([*anchor, *ax_size])} + axes["main"].imshow(np.zeros((1, 1))) # same axes properties as main plot + axes["main"].set_xticks([]) + axes["main"].set_yticks([]) + axes["twiny"], axes["twinx"] = [axes["main"].twiny(), axes["main"].twinx()] + axes["twinx"].set_yticks([]) + axes["twiny"].set_xticks([]) + label_at = [ # order matches get_triangle_nodes (halves and quarters) + axes["main"].set_ylabel, # left + axes["twinx"].set_ylabel, # right + axes["main"].set_xlabel, # bottom + axes["twiny"].set_xlabel, # top + ] + for i, label in enumerate(data.coords[cfg["split_by"]].values): + axes["main"].add_patch( + patches.Polygon( + get_triangle_nodes( + i, len(data.coords[cfg["split_by"]].values) + ), + closed=True, + facecolor=["#bbb", "#ccc", "#ddd", "#eee"][i], + edgecolor="black", + linewidth=0.5, + fill=True, + ) + ) + label_at[i](label) + + +def overlay_reference(cfg, axe, data, triangle): + """Create triangular overlays for given data and axes.""" + # use same colors as in main plot + cmap = axe.get_images()[0].get_cmap() + norm = axe.get_images()[0].norm + if cfg["nan_color"] is not None: + cmap.set_bad(cfg["nan_color"]) + for i, j in itertools.product(*map(range, data.shape)): + if np.isnan(data[i, j]) and cfg["nan_color"] is None: + continue + color = cmap(norm(data[i, j])) + edges = [(e[0] + j, e[1] + i) for e in triangle] + patch = patches.Polygon( + edges, + closed=True, + facecolor=color, + edgecolor="black", + linewidth=0.5, + fill=True, + ) + axe.add_patch(patch) + + +def plot_group(cfg, axe, data, title=None): + """Create matrix for one subplot in ax using plt.imshow. + + by default split None is used, if all splits are named the first is + used. Other splits will be added by overlaying triangles. + """ + split = data.sel({cfg["split_by"]: cfg["default_split"]}) + print(f"Plotting group {title}") + print(split) + plot_matrix( + split.values.T, # 2d numpy array + split.coords[cfg["y_by"]].values, # y_labels + split.coords[cfg["x_by"]].values, # x_labels + axe, + cfg["plot_kwargs"], + ) + if title is not None: + axe.set_title(title) + axe.set(**cfg["axes_properties"]) + + +def get_triangle_nodes(position, total_count=2): + """Return list of nodes with relative x, y coordinates. + + The nodes of the triangle are given as list of three tuples. Each tuples + contains relative coordinates (-0.5 to +0.5). For total of <= 2 a top left + (position=0) and bottom right (position=1) rectangle is returned. + For higher counts (3 or 4) one quartile is returned for each position. + NOTE: Order matters. Ensure axis labels for the legend match when changing. + """ + if total_count < 3: + halves = [ + [(0.5, -0.5), (-0.5, -0.5), (-0.5, 0.5)], # top left + [(0.5, -0.5), (0.5, 0.5), (-0.5, 0.5)], # bottom right + ] + return halves[position] + quarters = [ + [(-0.5, -0.5), (0, 0), (-0.5, 0.5)], # left + [(0.5, -0.5), (0, 0), (0.5, 0.5)], # right + [(-0.5, 0.5), (0, 0), (0.5, 0.5)], # bottom + [(-0.5, -0.5), (0, 0), (0.5, -0.5)], # top + ] + return quarters[position] + + +def plot_overlays(cfg, grid, data): + """Call overlay_reference for each split in data and each group in grid.""" + split_count = data.shape[3] + group_count = data.shape[2] + for i in range(group_count): + if split_count < 2: + log.debug("No additional splits for overlay.") + break + if split_count > 4: + log.warning("Too many splits for overlay, only 3 will be plotted.") + group_data = data.isel({cfg["group_by"]: i}) + group_data = group_data.dropna(cfg["x_by"], how="all") + for sss in range(split_count): + split = group_data.isel({cfg["split_by"]: sss}) + split_label = split.coords[cfg["split_by"]].values.item() + if split_label == cfg["default_split"]: + log.debug("Skipping default split for overlay.") + continue + nodes = get_triangle_nodes(sss, split_count) + overlay_reference(cfg, grid[i], split.values.T, nodes) + + +def plot(cfg, data): + """Create figure with subplots for each group. + + sets same color range and overlays additional references based on + the content of data (xr.DataArray) + """ + fig = plt.figure(1, cfg.get("figsize", (5.5, 3.5))) + group_count = len(data.coords[cfg["group_by"]]) + grid = ImageGrid( + fig, + 111, # similar to subplot(111) + cbar_mode="single", + cbar_location="right", + cbar_pad=0.1, + cbar_size=0.2, + nrows_ncols=(1, group_count), + axes_pad=0.1, + ) + # remap colorbar to 10 discrete steps + cmap = mpl.cm.get_cmap(cfg.get("cmap", "RdYlBu_r"), 10) + cfg["plot_kwargs"]["cmap"] = cmap + for i in range(group_count): + log.debug("Plotting group %s", i) + print("Plotting group %s", i) + group = data.isel({cfg["group_by"]: i}) + print(group) + group = group.dropna(cfg["x_by"], how="all") + print("dropped") + print(group) + title = None + if group_count > 1: + title = group.coords[cfg["group_by"]].values.item() + plot_group(cfg, grid[i], group, title=title) + # use same colorrange and colorbar for all subplots: + unify_limits(grid) + # set cb of first image as single cb for the figure + grid.cbar_axes[0].colorbar(grid[0].get_images()[0], **cfg["cbar_kwargs"]) + if data.shape[3] > 1: + plot_overlays(cfg, grid, data) + if cfg["plot_legend"] and data.shape[3] > 1: + split_legend(cfg, grid, data) + basename = "portrait_plot" + fname = get_plot_filename(basename, cfg) + plt.savefig(fname, bbox_inches="tight", dpi=cfg["dpi"]) + log.info("Figure saved:") + log.info(fname) + + +def normalize(array, method, dims): + """Divide and shift values along dims depending on method.""" + log.debug("Normalizing data with method %s", method) + shift = 0 + norm = 1 + if "mean" in method: + norm = array.mean(dim=dims) + elif "median" in method: + norm = array.median(dim=dims) + if "centered" in method: + shift = norm + normalized = (array - shift) / norm + return normalized + + +def sort_data(cfg, dataset): + """Sort the dataset along by custom or alphabetical order.""" + # custom order: dsimport xarray as xr + # import pandas as pd + # order = ['value3', 'value1', 'value2'] # replace by custom order + # ds[cfg['y_by']] = pd.Categorical(ds[cfg['y_by']], categories=order, + # ordered=True) + # ds = ds.sortby('y_by') + # sort alphabetically (caseinsensitive) + dataset = dataset.sortby( + [ + dataset[cfg["x_by"]].str.lower(), + dataset[cfg["y_by"]].str.lower(), + dataset[cfg["group_by"]].str.lower(), + dataset[cfg["split_by"]].str.lower(), + ] + ) + # apply custom orders if given: + if cfg.get("y_order"): + # order = cfg["y_order"] + # dataset[cfg["y_by"]] = pd.Categorical( + # dataset[cfg["y_by"]], categories=order, ordered=True + # ) + dataset = dataset.reindex({cfg["y_by"]: cfg["y_order"]}) + # new_order = [dataset[cfg["x_by"]].values + # dataset = dataset.reindex() + return dataset + + +def apply_distance_metric(cfg, metas): + """Optionally apply preproc method. reference_for_metric facet required.""" + if not cfg["distance_metric"]: + return + new_metas = [] + for y_metas in group_metadata(metas, cfg["y_by"]).values(): + references = select_metadata(y_metas, reference_for_metric=True) + for ref_meta in references: + ref_cube = iris.load_cube(ref_meta["filename"]) + for meta in y_metas: # usually variable + if meta.get("reference_for_metric", False): + continue # skip distance to itself or other references + cube = iris.load_cube(meta["filename"]) + # TODO: fix units should be done in preproc or the diagnostics + if cube.units != ref_cube.units: + pp.convert_units(cube, ref_cube.units) + distance = pp.distance_metric( + [cube], reference=ref_cube, metric=cfg["distance_metric"] + ) + # basename = f"{Path(meta['filename']).stem}" + split = ref_meta.get(cfg["split_by"], cfg["default_split"]) + basename = "" + basename += f"_{meta[cfg['group_by']]}" + basename += f"_{split}" + basename += f"_{meta[cfg['y_by']]}" + basename += f"_{meta[cfg['x_by']]}_{cfg['distance_metric']}" + fname = get_diagnostic_filename(basename, cfg) + iris.save(distance, fname) + log.info("Distance metric saved: %s", fname) + # TODO: adjust all relevant meta data + new_meta = meta.copy() # duplicate for multiple references + new_meta[cfg["split_by"]] = split + new_meta["filename"] = fname + new_metas.append(new_meta) + return new_metas + + +def set_defaults(cfg): + """Set default values for most important config parameters.""" + cfg.setdefault("distance_metric", None) + cfg.setdefault("normalize", "centered_median") + cfg.setdefault("x_by", "dataset") + cfg.setdefault("y_by", "short_name") + cfg.setdefault("group_by", "project") + cfg.setdefault("split_by", "split") # extra facet + cfg.setdefault("default_split", None) + cfg.setdefault("cbar_kwargs", {}) + cfg.setdefault("axes_properties", {}) + cfg.setdefault("nan_color", "white") + cfg.setdefault("figsize", (7.5, 3.5)) + cfg.setdefault("dpi", 300) + cfg.setdefault("plot_legend", True) + cfg.setdefault("plot_kwargs", {}) + cfg["plot_kwargs"].setdefault("cmap", "RdYlBu_r") + cfg["plot_kwargs"].setdefault("vmin", -0.5) + cfg["plot_kwargs"].setdefault("vmax", 0.5) + cfg.setdefault("legend", {}) + cfg["legend"].setdefault("x_offset", 0) + cfg["legend"].setdefault("y_offset", 0) + cfg["legend"].setdefault("size", 0.3) + + +def main(cfg): + """Run the diagnostic.""" + print("RUN MAIN SCRIPT") + metas = list(cfg["input_data"].values()) + set_defaults(cfg) + print(cfg["distance_metric"]) + metas = apply_distance_metric(cfg, metas) + remove_reference(metas) + add_split_none(metas) + dataset = load_data(cfg, metas) + dataset = sort_data(cfg, dataset) + if cfg["normalize"] is not None: + dataset["var"] = normalize( + dataset["var"], cfg["normalize"], [cfg["x_by"], cfg["group_by"]] + ) + plot(cfg, dataset["var"]) + + +if __name__ == "__main__": + with run_diagnostic() as config: + main(config) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 481d119534..32e16ed984 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -698,13 +698,43 @@ def select_meta_from_combi(meta: list, combi: dict, groups: dict) -> tuple: return this_meta, this_cfg -def get_common_meta(metas: list) -> dict: - """Return a dictionary of meta data, that is common between all inputs.""" +def _compare_dicts(dict1, dict2, sort) -> bool: + if dict1.kyes() != dict2.keys(): + return False + return all(_compare_values(dict1[key], dict2[key], sort) for key in dict1) + + +def _compare_values(val1, val2, sort) -> bool: + if isinstance(val1, dict) and isinstance(val2, dict): + return _compare_dicts(val1, val2) + if isinstance(val1, list) and isinstance(val2, list): + if len(val1) != len(val2): + return False + if sort: + val1 = sorted(val1) + val2 = sorted(val2) + return all(_compare_values(v1, v2, sort) for v1, v2 in zip(val1, val2)) + try: + return val1 == val2 + except ValueError: + return False + + +def get_common_meta(metas: list, *, sort: bool = False) -> dict: + """Return a dictionary of meta data, that is common between all inputs. + + Parameters + ---------- + metas : list + List of meta data dictionaries. + sort : bool, optional + Sort lists before comparison. If true lists with same elements but + different order are considered to be equal. By default False. + """ common = {} for key in metas[0]: - unique_values = {m[key] for m in metas} - if len(unique_values) == 1: - common[key] = unique_values.pop() + if all(_compare_values(metas[0][key], m[key], sort) for m in metas): + common[key] = metas[0][key] return common diff --git a/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml index 907133ce45..275bed2dbd 100644 --- a/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml +++ b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml @@ -152,24 +152,33 @@ preprocessors: mask_glaciated: mask_out: glaciated + perday: + <<: *default + convert_units: + units: mm day-1 + + diagnostics: pet_historical: &pet_historical variables: - pr: &pet_default + tasmin: &pet_default <<: *var_default exp: ["historical"] <<: *obs_period additional_datasets: *cmip6_data - tasmin: *pet_default tasmax: *pet_default sfcWind: *pet_default # clt: *pet_default rsds: *pet_default ps: *pet_default + pr: + <<: *pet_default + preprocessor: perday scripts: &scripts_pet pet_pm: script: droughts/pet.R pet_type: "Penman" + method: ICID spei_historical: &spei_historical scripts: @@ -188,18 +197,20 @@ diagnostics: pet_obs: &pet_obs variables: - pr: &pet_default_obs + tasmin: &pet_default_obs <<: *obs_period preprocessor: default mip: Amon additional_datasets: - <<: *era5 - tasmin: *pet_default_obs tasmax: *pet_default_obs sfcWind: *pet_default_obs # clt: *pet_default_obs rsds: *pet_default_obs ps: *pet_default_obs + pr: + <<: *pet_default_obs + preprocessor: perday scripts: pet_pm: script: droughts/pet.R @@ -215,17 +226,20 @@ diagnostics: end_year: 2014 - <<: *cds mip: Lmon - pr: &add_cru + tasmin: &add_cru mip: Amon additional_datasets: - <<: *cru - tasmin: *add_cru tasmax: *add_cru # clt: *add_cru evspsblpot: + preprocessor: perday additional_datasets: - <<: *cru mip: Emon + pr: + <<: *add_cru + preprocessor: perday scripts: diffmaps: script: droughts/diffmap.py @@ -246,13 +260,11 @@ diagnostics: validate_models: &validate_models # additional_datasets: *cmip6_data variables: - # pr: &var_default_historical + # tasmin: &var_default_historical # <<: *var_default # <<: *obs_period # exp: ["historical"] # additional_datasets: *cmip6_data - # tasmin: - # <<: *var_default_historical # tasmax: # <<: *var_default_historical sm: @@ -267,6 +279,9 @@ diagnostics: # <<: *var_default_historical # ps: # <<: *var_default_historical + # pr: + # <<: *var_default_historical + # preprocessor: perday scripts: diffmaps: script: droughts/diffmap.py diff --git a/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml b/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml index 772d41febd..116694655e 100644 --- a/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml +++ b/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml @@ -11,15 +11,18 @@ documentation: - `pet_*, spei_*` for PET and index calculation. - `*_trend` for trend maps of PET and SPEI. authors: - - ruhe_lukas + - lindenlaub_lukas maintainers: - - ruhe_lukas + - lindenlaub_lukas projects: - eval4cmip + realms: [atmos, land] + themes: [phys] GRID: &grid # target_grid: 0.25x0.25 - target_grid: 1x1 # might require a lot of memory (limit parallel tasks) + target_grid: 3x3 + # target_grid: 1x1 # might require a lot of memory (limit parallel tasks) # longest period for CMIP6 only analysis @@ -34,7 +37,7 @@ SSP_PERIOD: &ssp_period DIFFMAPS_DEFAULT: &diffmaps_default - script: droughtindex/diffmap.py + script: droughts/diffmap.py plot_models: False plot_mmm: True clip_land: True @@ -51,43 +54,39 @@ CMIP_DEFAULT: &cmip_default CMIP6_DATA_LMON: &cmip6_data_lmon # no awi or inm - {<<: *cmip_default, mip: Lmon, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS } - {<<: *cmip_default, mip: Lmon, dataset: BCC-CSM2-MR, institute: BCC } - - {<<: *cmip_default, mip: Lmon, dataset: CanESM5, institute: CCCma } - - {<<: *cmip_default, mip: Lmon, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name - - {<<: *cmip_default, mip: Lmon, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 - - {<<: *cmip_default, mip: Lmon, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} - - {<<: *cmip_default, mip: Lmon, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) - # - {<<: *cmip_default, mip: Lmon, dataset: FIO-ESM-2-0, institute: FIO-QLNM } # cmor error in mrsos - # - {<<: *cmip_default, mip: Lmon, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} # cmor error sdepths - - {<<: *cmip_default, mip: Lmon, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} - - {<<: *cmip_default, mip: Lmon, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} - - {<<: *cmip_default, mip: Lmon, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} - - {<<: *cmip_default, mip: Lmon, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source - - {<<: *cmip_default, mip: Lmon, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} - - {<<: *cmip_default, mip: Lmon, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo - - {<<: *cmip_default, mip: Lmon, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 + # - {<<: *cmip_default, mip: Lmon, dataset: CanESM5, institute: CCCma } + # - {<<: *cmip_default, mip: Lmon, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name + # - {<<: *cmip_default, mip: Lmon, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 + # - {<<: *cmip_default, mip: Lmon, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} + # - {<<: *cmip_default, mip: Lmon, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) + # - {<<: *cmip_default, mip: Lmon, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} + # - {<<: *cmip_default, mip: Lmon, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} + # - {<<: *cmip_default, mip: Lmon, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} + # - {<<: *cmip_default, mip: Lmon, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source + # - {<<: *cmip_default, mip: Lmon, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} + # - {<<: *cmip_default, mip: Lmon, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo + # - {<<: *cmip_default, mip: Lmon, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 CMIP6_DATA: &cmip6_data - # trying to add: - # - {<<: *cmip_default, dataset: HadGEM3-GC31-LL, ensemble: r1i1p1f3} # only -MM in picontrol but only -LL in ssp245 - {<<: *cmip_default, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS } - {<<: *cmip_default, dataset: AWI-CM-1-1-MR, institute: AWI } - {<<: *cmip_default, dataset: BCC-CSM2-MR, institute: BCC } - - {<<: *cmip_default, dataset: CanESM5, institute: CCCma } - - {<<: *cmip_default, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name - - {<<: *cmip_default, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 - - {<<: *cmip_default, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} - - {<<: *cmip_default, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) - - {<<: *cmip_default, dataset: FIO-ESM-2-0, institute: FIO-QLNM } - - {<<: *cmip_default, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} - - {<<: *cmip_default, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} - - {<<: *cmip_default, dataset: INM-CM5-0, institute: INM, grid: gr1} # no mrsos? works for pr/tas - - {<<: *cmip_default, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} - - {<<: *cmip_default, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} - - {<<: *cmip_default, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source - - {<<: *cmip_default, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} - - {<<: *cmip_default, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo - - {<<: *cmip_default, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 + # - {<<: *cmip_default, dataset: CanESM5, institute: CCCma } + # - {<<: *cmip_default, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name + # - {<<: *cmip_default, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 + # - {<<: *cmip_default, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} + # - {<<: *cmip_default, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) + # - {<<: *cmip_default, dataset: FIO-ESM-2-0, institute: FIO-QLNM } + # - {<<: *cmip_default, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} + # - {<<: *cmip_default, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} + # - {<<: *cmip_default, dataset: INM-CM5-0, institute: INM, grid: gr1} # no mrsos? works for pr/tas + # - {<<: *cmip_default, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} + # - {<<: *cmip_default, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} + # - {<<: *cmip_default, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source + # - {<<: *cmip_default, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} + # - {<<: *cmip_default, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo + # - {<<: *cmip_default, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 @@ -105,7 +104,7 @@ VAR_SM: &var_sm additional_datasets: *cmip6_data_lmon preprocessors: - default: + default: &default_preproc regrid: <<: *grid scheme: nearest @@ -115,17 +114,23 @@ preprocessors: mask_out: sea mask_glaciated: mask_out: glaciated + perday: + <<: *default_preproc + convert_units: + units: mm day-1 diagnostics: ssp_maps: variables: - pr: &ssp585_variable + tasmin: &ssp585_variable <<: *var_default exp: ssp585 <<: *ssp_period tasmax: *ssp585_variable - tasmin: *ssp585_variable + pr: + <<: *ssp585_variable + preprocessor: perday # sm: *var_sm scripts: diffmaps: &pr_plot @@ -168,60 +173,66 @@ diagnostics: # TODO: test with python repo pet_ssp585: &pet_ssp585 variables: - pr: &pet_585 + tasmin: &pet_585 <<: *var_default exp: ["historical", "ssp585"] <<: *full_period additional_datasets: *cmip6_data - tasmin: *pet_585 tasmax: *pet_585 sfcWind: *pet_585 # clt: *pet_585 rsds: *pet_585 ps: *pet_585 + pr: + <<: *pet_585 + preprocessor: perday scripts: pet_pm: - script: droughtindex/diag_pet.R + script: droughts/pet.R pet_type: "Penman" # "Penman_clt" pet_ssp245: &pet_ssp245 variables: - pr: &pet_245 + tasmin: &pet_245 <<: *var_default exp: ["historical", "ssp245"] <<: *full_period additional_datasets: *cmip6_data - tasmin: *pet_245 tasmax: *pet_245 sfcWind: *pet_245 # clt: *pet_245 rsds: *pet_245 ps: *pet_245 + pr: + <<: *pet_245 + preprocessor: perday scripts: pet_pm: - script: droughtindex/diag_pet.R + script: droughts/pet.R pet_type: "Penman" # "Penman_clt" pet_ssp126: &pet_ssp126 variables: - pr: &pet_126 + tasmin: &pet_126 <<: *var_default exp: ["historical", "ssp126"] <<: *full_period additional_datasets: *cmip6_data - tasmin: *pet_126 tasmax: *pet_126 sfcWind: *pet_126 # clt: *pet_126 rsds: *pet_126 ps: *pet_126 + pr: + <<: *pet_126 + preprocessor: perday scripts: pet_pm: - script: droughtindex/diag_pet.R + script: droughts/pet.R pet_type: "Penman" # "Penman_clt" spei_ssp585: &spei_ssp585 scripts: spei: &script_spei_585 - script: droughtindex/diag_spei.R + script: droughts/spei.R smooth_month: 6 distribution: "log-Logistic" refstart_year: 1950 @@ -241,21 +252,21 @@ diagnostics: ancestors: [pet_ssp126/pet_pm, pet_ssp126/pr] # --- EXTRA WB --- # - wb_ssp126: &wb_ssp126 - scripts: - wb: - script: droughtindex/diag_wb.py - ancestors: [pet_ssp126/pet_pm, pet_ssp126/pr] - wb_ssp245: &wb_ssp245 - scripts: - wb: - script: droughtindex/diag_wb.py - ancestors: [pet_ssp245/pet_pm, pet_ssp245/pr] - wb_ssp585: &wb_ssp585 - scripts: - wb: - script: droughtindex/diag_wb.py - ancestors: [pet_ssp585/pet_pm, pet_ssp585/pr] + # wb_ssp126: &wb_ssp126 + # scripts: + # wb: + # script: droughts/diag_wb.py + # ancestors: [pet_ssp126/pet_pm, pet_ssp126/pr] + # wb_ssp245: &wb_ssp245 + # scripts: + # wb: + # script: droughts/diag_wb.py + # ancestors: [pet_ssp245/pet_pm, pet_ssp245/pr] + # wb_ssp585: &wb_ssp585 + # scripts: + # wb: + # script: droughts/diag_wb.py + # ancestors: [pet_ssp585/pet_pm, pet_ssp585/pr] # --- SPEI Plots --- # diffmap: scripts: @@ -264,7 +275,7 @@ diagnostics: # not scenarios. The loop to calc multi model means need to be aware of exp (hardcoded) # or allow multiple group_by keys as combination.. (or based on basename?) # until than use diffmap/spei_ssp126.. runs - script: droughtindex/diffmap.py + script: droughts/diffmap.py ancestors: - pet_ssp126/pr - pet_ssp245/pr @@ -285,7 +296,7 @@ diagnostics: vmax: 6e-5 vmin: 0 ssp585: &diff_pet_default - script: droughtindex/diffmap.py + script: droughts/diffmap.py ancestors: - "pet_ssp585/pet_pm" - "spei_ssp585/spei" @@ -305,7 +316,7 @@ diagnostics: <<: *diff_pet_default ancestors: ["pet_ssp126/pet_pm"] spei_ssp585: &diff_spei_default - script: droughtindex/diffmap.py + script: droughts/diffmap.py ancestors: ["spei_ssp585/spei"] <<: *ssp_period save_models: True @@ -322,20 +333,20 @@ diagnostics: spei_ssp126: <<: *diff_spei_default ancestors: ["spei_ssp126/spei"] - wb_ssp126: &diff_wb_default - script: droughtindex/diffmap.py - ancestors: ["wb_ssp126/wb"] - <<: *ssp_period - save_models: False - save_mmm: True - plot_models: False - plot_mmm: True - wb_ssp245: - <<: *diff_wb_default - ancestors: ["wb_ssp245/wb"] - wb_ssp585: - <<: *diff_wb_default - ancestors: ["wb_ssp585/wb"] + # wb_ssp126: &diff_wb_default + # script: droughts/diffmap.py + # ancestors: ["wb_ssp126/wb"] + # <<: *ssp_period + # save_models: False + # save_mmm: True + # plot_models: False + # plot_mmm: True + # wb_ssp245: + # <<: *diff_wb_default + # ancestors: ["wb_ssp245/wb"] + # wb_ssp585: + # <<: *diff_wb_default + # ancestors: ["wb_ssp585/wb"] event_area: scripts: ssps: @@ -352,7 +363,7 @@ diagnostics: - [1560, null] reference_dataset: MIROC6 # to pick timeseries from ancestors: [spei_ssp585/spei, spei_ssp245/spei, spei_ssp126/spei] - script: droughtindex/event_area_timeseries.py + script: droughts/event_area_timeseries.py interval: 240 latest_legend: true plot_models: False @@ -370,7 +381,7 @@ diagnostics: short_name: spei dataset: MMM diffmap_metric: diff - script: droughtindex/regional_hexagons.py + script: droughts/regional_hexagons.py shapefile: ar6_regions/IPCC-WGI-reference-regions-v4.shp exclude_regions: [] statistics: [mean] @@ -385,7 +396,7 @@ diagnostics: - diffmap/spei_ssp126 - diffmap/spei_ssp245 - diffmap/spei_ssp585 - script: droughtindex/regional_hexagons.py + script: droughts/regional_hexagons.py # TODO: use preproc instead of shapefile shapefile: ar6_regions/IPCC-WGI-reference-regions-v4.shp exclude_regions: [] @@ -397,7 +408,7 @@ diagnostics: statistics: scripts: histogram: - script: droughtindex/distribution.py + script: droughts/distribution.py clip_land: true # comparison_period: 20 ancestors: @@ -420,7 +431,7 @@ diagnostics: timeseries: scripts: scenarios: - script: droughtindex/timeseries.py + script: droughts/timeseries_scenarios.py ancestors: - pet_ssp126/pr - pet_ssp245/pr From 6166978d0c2b54b366c385ef69cc9040e1e0fafb Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Wed, 19 Mar 2025 11:22:40 +0100 Subject: [PATCH 61/66] fix timeseries: ylabels, legends, units --- esmvaltool/diag_scripts/droughts/styles.py | 111 ++++++++++++++++++ .../droughts/timeseries_scenarios.py | 103 +++++++++------- 2 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 esmvaltool/diag_scripts/droughts/styles.py diff --git a/esmvaltool/diag_scripts/droughts/styles.py b/esmvaltool/diag_scripts/droughts/styles.py new file mode 100644 index 0000000000..6218c78065 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/styles.py @@ -0,0 +1,111 @@ +# precipitation11 = mpl_cm.get_cmap("brewer_BrBG_11") +# fix colors from AR6 +# https://github.com/IPCC-WG1/colormaps + + +def rgb(r, g, b): + return [r / 256, g / 256, b / 256] + + +historical = rgb(10, 10, 10) +ssp119 = rgb(0, 173, 207) +ssp126 = rgb(23, 60, 102) +ssp245 = rgb(247, 148, 32) +ssp370 = rgb(231, 29, 37) +ssp585 = rgb(149, 27, 30) + + +experiment_colors = { + "historical": historical, + "ssp119": ssp119, + "ssp126": ssp126, + "ssp245": ssp245, + "ssp370": ssp370, + "ssp585": ssp585, +} + + +prec_div_5 = [ + rgb(84, 48, 5), + rgb(200, 148, 79), + rgb(248, 248, 247), + rgb(85, 167, 160), + rgb(0, 60, 48), +] + +prec_div_6 = [ + rgb(84, 48, 5), + rgb(191, 129, 44), + rgb(229, 209, 180), + rgb(183, 216, 213), + rgb(53, 151, 143), + rgb(0, 60, 48), +] + +prec_div_7 = [ + rgb(84, 48, 5), + rgb(173, 115, 38), + rgb(216, 182, 135), + rgb(248, 248, 247), + rgb(140, 194, 190), + rgb(44, 135, 127), + rgb(0, 60, 48), +] + +prec_div_8 = [ + rgb(84, 48, 5), + rgb(160, 105, 33), + rgb(207, 163, 103), + rgb(235, 220, 200), + rgb(202, 225, 223), + rgb(109, 179, 173), + rgb(37, 125, 115), + rgb(0, 60, 48), +] + +prec_div_9 = [ + rgb(84, 48, 5), + rgb(150, 98, 30), + rgb(200, 148, 79), + rgb(224, 199, 164), + rgb(248, 248, 247), + rgb(167, 208, 204), + rgb(85, 167, 160), + rgb(33, 116, 107), + rgb(0, 60, 48), +] + +prec_div_10 = [ + rgb(84, 48, 5), + rgb(143, 93, 27), + rgb(195, 137, 60), + rgb(216, 182, 135), + rgb(238, 226, 211), + rgb(212, 230, 229), + rgb(140, 194, 190), + rgb(67, 158, 150), + rgb(29, 110, 100), + rgb(0, 60, 48), +] + +prec_div_11 = [ + rgb(84, 48, 5), + rgb(137, 88, 25), + rgb(191, 129, 44), + rgb(210, 169, 113), + rgb(229, 209, 180), + rgb(248, 248, 247), + rgb(183, 216, 213), + rgb(118, 183, 178), + rgb(53, 151, 143), + rgb(26, 105, 95), + rgb(0, 60, 48), +] + + +# prec_11 = mpl_cm.colors.ListedColormap(prec_11, name="pr_11") +# prec_div = mpl_cm.colors.LinearSegmentedColormap.from_list("pr", prec_11) +# prec_seq + +# plt.register_cmap('prec_div', prec_div) +# plt.register_cmap('prec_11', prec_11) diff --git a/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py index 0163313270..e3f9a2e9a1 100644 --- a/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py +++ b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py @@ -1,4 +1,5 @@ """Plot timeseries of historical period and different ssps for each variable. + Observations will be shown as reference when given. Shaded area shows MM stddv. Works with combined or individual inputs for historical/ssp experiments. @@ -19,11 +20,15 @@ Plot all time series as subplots in one figure with shared x-axis. legend: dict, optional (default: {}) Names to rename the default legend labels. Keys are the original labels. + Set to None (null in yaml) to disable legend. +ylabels: dict, optional (default: {}) + Dictionary with short_names as keys and names to replace them as values. """ import datetime as dt import logging import os +import warnings from copy import deepcopy from pathlib import Path @@ -34,10 +39,11 @@ from iris import plot as iplt from iris.analysis import MEAN, cartography from iris.coord_categorisation import add_year +from iris.cube import Cube from matplotlib.axes import Axes -from esmvaltool.diag_scripts.droughtindex import styles -from esmvaltool.diag_scripts.droughtindex import utils as ut +from esmvaltool.diag_scripts.droughts import styles +from esmvaltool.diag_scripts.droughts import utils as ut from esmvaltool.diag_scripts.shared import ( # ProvenanceLogger, get_plot_filename, @@ -49,50 +55,44 @@ # import nc_time_axis # noqa allow cftime axis to be plotted by mpl # nc_time_axis works but seems to show wrong days on axis when using cftime logger = logging.getLogger(Path(__file__).stem) +warnings.filterwarnings( + "ignore", module="iris", message="Using DEFAULT_SPHERICAL_EARTH_RADIUS" +) +warnings.filterwarnings( + "ignore", + message="Degrees of freedom <= 0 for slice", + category=RuntimeWarning, +) +warnings.filterwarnings( + "ignore", module="iris", message="invalid value encountered in divide" +) -def convert_units(cube): - """Convert units of some variables for display""" - if cube.var_name in ["tas", "tasmax", "tasmin"]: - cube.convert_units("Celsius") - if cube.var_name == "pr": - logger.info("Converting pr units to mm/day") - cube.units = "mm s-1" - cube.convert_units("mm day-1") - if cube.var_name == "evspsblpot": - # cube.units = 'mm mon-1' - ut.monthly2daily(cube) - cube.long_name = "Potential Evapotranspiration" # NOTE: not working? - cube.rename("Potential Evapotranspiration") - - -def global_mean(cfg, cube): +def global_mean(cfg: dict, cube: Cube) -> Cube: """Calculate global mean.""" ut.guess_lat_lon_bounds(cube) if "regions" in cfg: - print("Extracting regions") cube = pp.extract_shape( cube, shapefile="ar6", ids={"Acronym": cfg["regions"]}, ) area_weights = cartography.area_weights(cube) - mean = cube.collapsed( + return cube.collapsed( ["latitude", "longitude"], MEAN, weights=area_weights, ) - return mean -def yearly_average(cube): +def yearly_average(cube: Cube) -> Cube: + """Calculate yearly average.""" add_year(cube, "time") return cube.aggregated_by("year", MEAN) -def plot_experiment(cfg, mean, std_dev, experiment, ax): +def plot_experiment(cfg, mean, std_dev, experiment, ax) -> None: time = mean.coord("time") - # times = time.units.num2date(time.points) exp_color = getattr(styles, experiment) iplt.fill_between( time, @@ -111,7 +111,7 @@ def plot_experiment(cfg, mean, std_dev, experiment, ax): ax.set_ylabel(y_label) -def plot_each_model(cubes, metas, cfg, experiment, smooth=False): +def plot_each_model(cubes, metas, cfg, experiment, smooth=False) -> None: fig, ax = plt.subplots(figsize=cfg["figsize"], dpi=150) ax.grid(axis="y", color="0.95") time = cubes[0].coord("time") @@ -123,7 +123,7 @@ def plot_each_model(cubes, metas, cfg, experiment, smooth=False): fig.savefig(get_plot_filename(basename, cfg), bbox_inches="tight") -def plot_models(cfg, metas, ax, smooth=False): +def plot_models(cfg, metas, ax, smooth=False) -> None: historical_plotted = False for experiment, models in group_metadata(metas, "exp").items(): if experiment == "historical" and historical_plotted: @@ -160,8 +160,8 @@ def plot_models(cfg, metas, ax, smooth=False): if recalc and cfg.get("save_mm", True): iris.save(mm["mean"], fname + "_mean.nc") iris.save(mm["std_dev"], fname + "_stddev.nc") - convert_units(mean) - convert_units(std_dev) + # convert_units(mean) + # convert_units(std_dev) if experiment.startswith("historical-"): experiment = experiment.split("-")[1] @@ -188,24 +188,26 @@ def plot_models(cfg, metas, ax, smooth=False): plot_experiment(cfg, mean, std_dev, experiment, ax) -def plot_obs(cfg, metas, ax, smooth=False): +def plot_obs(cfg, metas, ax, smooth=False) -> None: for meta in metas: cube = iris.load_cube(meta["filename"]) if smooth: cube = yearly_average(cube) mean = global_mean(cfg, cube) - convert_units(mean) + # convert_units(mean) time = mean.coord("time") iplt.plot(time, mean, linestyle="--", label=meta["dataset"], axes=ax) -def process_variable(cfg, metas, short_name, fig=None, ax: Axes = None): +def process_variable( + cfg, metas, short_name, fig=None, ax: Axes = None +) -> None: """Process variable.""" project = cfg.get("project", "CMIP6") model_metas = select_metadata(metas, project=project) obs_metas = [meta for meta in metas if meta["project"] != project] if not cfg.get("subplots", False): - fig, ax = plt.subplots(figsize=cfg.get("figsize", (9, 2)), dpi=300) + fig, ax = plt.subplots(figsize=cfg["figsize"], dpi=300) plot_models(cfg, model_metas, ax, smooth=cfg.get("smooth", False)) plot_obs(cfg, obs_metas, ax, smooth=cfg.get("smooth", False)) basename = f"timeseries_scenarios_{short_name}" @@ -222,20 +224,19 @@ def process_variable(cfg, metas, short_name, fig=None, ax: Axes = None): linestyle="--", linewidth=0.5, ) - # ax.set_frame_on(False) ax.set_xlim([dt.datetime(1950, 1, 1), dt.datetime(2100, 1, 1)]) ax.xaxis.set_major_locator(mdates.YearLocator(10)) ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) for label in ax.get_xticklabels(which="major"): - label.set(rotation=40, horizontalalignment="right") + label.set(rotation=0, horizontalalignment="center") ax.xaxis.set_minor_locator(mdates.YearLocator()) - if "plot_properties" in cfg.keys(): + if "plot_properties" in cfg: ax.set(**cfg["plot_properties"]) if not cfg.get("subplots", False): fig.savefig(get_plot_filename(basename, cfg), bbox_inches="tight") -def main(cfg): +def main(cfg) -> None: """Run diagnostic.""" cfg = deepcopy(cfg) groups = group_metadata(cfg["input_data"].values(), "short_name") @@ -254,7 +255,6 @@ def main(cfg): if cfg["subplots"]: basename = "timeseries_scenarios" for ax in axs: - # ax.set_xticklabels([]) ax.tick_params( axis="x", which="both", @@ -282,21 +282,28 @@ def main(cfg): axs[-1].spines["bottom"].set_visible(True) axs[0].spines["top"].set_visible(True) lines, labels = axs[-1].get_legend_handles_labels() - if cfg.get( - "legend", - cfg.get("subplots", False), - ): # rename and reorder handles and labels + if cfg["legend"] is not None: leg_dict = dict(zip(labels, lines)) - print(labels) labels = list(cfg["legend"].values()) handles = [leg_dict[lab] for lab in cfg["legend"].keys()] + labels.append("Multi-model std") + rect = plt.Rectangle( + (0, 0), + 1, + 1, + fc="0.8", + alpha=0.2, + edgecolor="black", + linewidth=0.5, + ) + handles.append(rect) axs[-1].legend(handles, labels) fig.subplots_adjust(hspace=0.02) fig.tight_layout() fig.savefig(get_plot_filename(basename, cfg), bbox_inches="tight") -def set_defaults(cfg): +def set_defaults(cfg) -> None: cfg.setdefault("plot_mmm", True) cfg.setdefault("smooth", True) cfg.setdefault("combined_split_years", 65) @@ -304,7 +311,15 @@ def set_defaults(cfg): cfg.setdefault("figsize", (9, 2)) cfg.setdefault("reuse_mm", False) cfg.setdefault("subplots", False) - cfg.setdefault("legend", {}) + cfg.setdefault( + "legend", + { + "ssp126": "SSP1-2.6", + "ssp245": "SSP2-4.5", + "ssp585": "SSP5-8.5", + "historical": "Historical", + }, + ) cfg.setdefault("ylabels", {}) From fd57443ed263d6613319561775a4a71022b28bad Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 1 Apr 2025 09:50:59 +0200 Subject: [PATCH 62/66] wip paper --- .../recipes/droughts/recipe_lindenlaub25.rst | 22 +++- esmvaltool/diag_scripts/droughts/diffmap.py | 28 ++-- .../droughts/event_area_timeseries.py | 120 ++++++++---------- .../diag_scripts/droughts/portrait_plot.py | 8 +- .../droughts/timeseries_scenarios.py | 5 +- esmvaltool/diag_scripts/droughts/utils.py | 8 +- .../recipe_lindenlaub25_historical.yml | 58 +++++---- .../recipe_lindenlaub25_scenarios.yml | 77 ++++++----- 8 files changed, 172 insertions(+), 154 deletions(-) diff --git a/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst index bdb0bec553..0e96ec737e 100644 --- a/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst +++ b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst @@ -7,7 +7,9 @@ Agricultural Droughts in CMIP6 Future Projections Overview -------- -The two recipes presented here evaulate historical simulations of 18 CMIP6 models and analyse their projections for three different future pathways. The results are published in Lindenlaub (2025). +The two recipes presented here evaulate historical simulations of 18 CMIP6 +models and analyse their projections for three different future pathways. +The results are published in Lindenlaub (2025). Available recipes and diagnostics @@ -20,10 +22,26 @@ Recipes are stored in ``recipes/droughts/`` Diagnostics used by this recipes: + * droughts/pet.R + * droughts/spei.R * :ref:`droughts/diffmap.py ` + * :ref:`droughts/distribution.py ` + * :ref:`droughts/event_area_timeseries.py ` + * :ref:`droughts/pattern_correlation.py ` + * :ref:`droughts/timeseries_scenarios.py ` + * :ref:`droughts/regional_hexagons.py ` +Data +---- + +Soil moisture is evaluated and discussed, but not required for PET and SPEI +calculation. +``tasmin``, ``tasmax``, ``sfcWind``, ``ps``, ``rsds`` are used to approximate +``evspsblpot`` for ERA5 and 18 CMIP6 datasets. +``SPEI`` is calculated from ``evspsblpot`` and ``pr``. References ---------- -* Lindenlaub, L. (2025). Agricultural Droughts in CMIP6 Future Projections. Journal of Climate, 38(1), 1-15. https://doi.org/10.1029/2025JC012345 +* Lindenlaub, L. (2025). Agricultural Droughts in CMIP6 Future Projections. + Journal of Climate, 38(1), 1-15. https://doi.org/10.1029/2025JC012345 diff --git a/esmvaltool/diag_scripts/droughts/diffmap.py b/esmvaltool/diag_scripts/droughts/diffmap.py index d0a649d568..2c8d78890d 100644 --- a/esmvaltool/diag_scripts/droughts/diffmap.py +++ b/esmvaltool/diag_scripts/droughts/diffmap.py @@ -194,7 +194,7 @@ def fill_era5_gap(meta: dict, cube: Cube) -> None: if ( meta["dataset"] == "ERA5" and meta["short_name"] == "evspsblpot" - and len(cube.data[0]) == 360 + and len(cube.data[0]) == 360 # noqa: PLR2004 ): cube.data[:, 359] = cube.data[:, 0] @@ -287,7 +287,7 @@ def calculate_diff(cfg, meta, mm_data, output_meta, group) -> None: cube, cfg["start_year"], 1, 1, cfg["end_year"], 12, 31 ) dtime = cfg["comparison_period"] * 12 - cubes = {} + cubes: dict[Cube] = {} cubes["total"] = cube.collapsed("time", MEAN) do_metrics = cfg.get("metrics", METRICS) norm = ( @@ -340,14 +340,10 @@ def calculate_mmm(cfg, meta, mm_data, output_meta, group) -> None: meta = meta.copy() # don't modify meta in place: meta["diffmap_metric"] = metric basename = cfg["basename"].format(**meta) - drop = cfg.get("dropcoords", ["time", "height", "realization"]) - # TODO: use pp mean and stdv instead of iris? - mmm, _ = ut.mmm( - mm_data[metric], - dropcoords=drop, - dropmethods=metric != "diff", - mdtol=cfg.get("mdtol", 0.3), + results = pp.multi_model_statistics( + mm_data[metric], "overlap", ["mean"], ignore_scalar_coords=True ) + mmm = results["mean"] meta["title"] = f"Multi-model Mean ({cfg['titles'][metric]})" if cfg.get("plot_mmm", True): plot_kwargs = cfg.get("plot_kwargs", {}).copy() @@ -366,8 +362,8 @@ def calculate_mmm(cfg, meta, mm_data, output_meta, group) -> None: def set_defaults(cfg: dict) -> None: """Update cfg with default values from diffmap.yml in place.""" - config_fpath = os.path.realpath(__file__)[:-3] + ".yml" - with open(config_fpath, encoding="utf-8") as config_file: + config_fpath = Path(__file__).with_suffix(".yml") + with config_fpath.open() as config_file: defaults = yaml.safe_load(config_file) for key, val in defaults.items(): cfg.setdefault(key, val) @@ -409,7 +405,15 @@ def main(cfg) -> None: do_mmm = cfg.get("plot_mmm", True) or cfg.get("save_mmm", True) if do_mmm and len(g_metas) > 1: # copy meta from first dataset and add all ancestors - meta = ut.get_common_meta(g_metas) + keep_keys = [ + "short_name", + "long_name", + "units", + "start_year", + "end_year", + "exp", + ] + meta = {k: g_metas[0][k] for k in keep_keys} meta["ancestors"] = [met["filename"] for met in g_metas] meta["dataset"] = "MMM" calculate_mmm(cfg, meta, mm_data, output, group) diff --git a/esmvaltool/diag_scripts/droughts/event_area_timeseries.py b/esmvaltool/diag_scripts/droughts/event_area_timeseries.py index fe6d19b25a..e4dbd36efa 100644 --- a/esmvaltool/diag_scripts/droughts/event_area_timeseries.py +++ b/esmvaltool/diag_scripts/droughts/event_area_timeseries.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python """Calculate and plot relative area of drought events. Creates timeseries of the spatial extend of all drought events. Different types @@ -61,11 +60,12 @@ import yaml from esmvalcore import preprocessor as pp from iris.analysis.cartography import area_weights +from iris.cube import Cube from matplotlib import gridspec from matplotlib.dates import DateFormatter, YearLocator # MonthLocator from numpy import ma -import esmvaltool.diag_scripts.droughtindex.utils as ut +import esmvaltool.diag_scripts.droughts.utils as ut from esmvaltool.diag_scripts.shared import ( get_diagnostic_filename, get_plot_filename, @@ -76,7 +76,7 @@ log = logging.getLogger(__file__) -def load_and_prepare(cfg, fname): +def load_and_prepare(cfg, fname) -> Cube: """Apply mask and guess lat/lon bounds.""" cube = iris.load_cube(fname) ut.guess_lat_lon_bounds(cube) @@ -88,7 +88,8 @@ def load_and_prepare(cfg, fname): return cube -def get_intervals(cube, interval): +def get_intervals(cube, interval) -> list: + """Generate a list of time intervals for plotting.""" if interval > 0: months = len(cube.coord("time").points) steps = int(months / interval) @@ -100,8 +101,8 @@ def get_intervals(cube, interval): return intervals -def calc_ratio(cube, event, weights): - """Calculates a timeseries of area ratio for specific index range. +def calc_ratio(cube, event, weights) -> np.ndarray: + """Calculate a timeseries of area ratio for specific index range. The fraction of area (or cells) with index values between min and max is calculated for each timestep. NaN values are ignored for each event. @@ -110,7 +111,7 @@ def calc_ratio(cube, event, weights): Parameters ---------- - cube : iris.cube + cube : iris.cube.Cube 3D drought index, with area event : dict must contain floats or "nan" for keys: "min", "max" @@ -129,29 +130,26 @@ def calc_ratio(cube, event, weights): return np.sum(event_areas, axis=(1, 2)) # collapse lat/lon -def get_2d_mask(cube, mask_any=False, tile=False): +def get_2d_mask(cube, mask_any=False, tile=False) -> np.ndarray: """Return a 2d (lat/lon) mask where any or all entries are masked. Parameters ---------- - cube : iris.cube + cube : iris.cube.Cube 3d cube with masked data mask_any: bool return true for any masked entrie along time dim, instead of all tile : bool return a 3d mask (matching cube) repeated along the time dim """ - if mask_any: - mask2d = np.any(cube.data.mask, axis=0) - else: - mask2d = np.all(cube.data.mask, axis=0) + mask2d = np.any(cube.data.mask, axis=0) if mask_any else np.all(cube.data.mask, axis=0) if tile: mask2d = np.tile(mask2d, (cube.shape[0], 1, 1)) return mask2d -def plot_area_ratios(cfg, meta, cube): - """Plot area ratio of given event types for a cube of index values +def plot_area_ratios(cfg, meta, cube) -> None: + """Plot area ratio of given event types for a cube of index values. The area weights are normalized on the masked cube data, resulting in the ratio between area with index values in a given range and the area of all @@ -159,7 +157,7 @@ def plot_area_ratios(cfg, meta, cube): Parameters ---------- - cube : iris.Cube + cube : iris.cube.Cube 3D index values. interval : int number of months per figure. negative values disable the split. @@ -197,40 +195,29 @@ def plot_area_ratios(cfg, meta, cube): plot(cfg, fname, i, y) -def plot(cfg, i, y, fname=None, fig=None, ax=None, label=None, full=False): +def plot(cfg, i, y, fname=None, fig=None, ax=None, label=None) -> None: """Plot area ratios for given interval and events. + pass either fname to save single plots, or fig and ax to plot one axis into existing figure. """ - log.debug("TIMES: %s", cfg["times"]) if i is None: i = (0, len(cfg["times"])) t = cfg["times"][i[0] : i[1]] - # dt = t[1] - t[0] - if not fig: + if not fig or not ax: fig, ax = plt.subplots(figsize=cfg["figsize"], dpi=300) ax.xaxis.set_major_locator(YearLocator(5)) ax.xaxis.set_major_formatter(DateFormatter("%Y")) ax.xaxis.set_minor_locator(YearLocator(1)) ax.set_ylabel(label) - plot_kwargs = dict(step="mid", colors=cfg["colors"], labels=cfg["labels"]) + plot_kwargs = {"step": "mid", "colors": cfg["colors"], "labels": cfg["labels"]} plot_kwargs.update(cfg.get("plot_kwargs", {})) ax.stackplot(t, y[:, i[0] : i[1]], **plot_kwargs) ax.set_ylim(*cfg["ylim"]) ax.set(**cfg.get("axes_properties", {})) ax.tick_params(direction="in", which="both") - # ax.set_xlim(t[0]-dt.timedelta(days=20), t[-1]) # show first tick ax.set_xlim(t[0], t[-1]) ax.set_xticklabels(ax.get_xticklabels(), rotation=00, ha="center") - print(t[0], t[-1]) - # if cfg["interval"] > 0 and not full: - # log.info("setting xlim for interval") - # ax.set_xlim(t[0] - dt, t[-1]) - # if len(t) < cfg["interval"]: - # ax.set_xlim(t[0], t[0] + cfg["interval"] * dt) - # else: - # log.info("setting xlim for full period %s %s", t[0], t[-1]) - # ax.set_xlim(t[0], t[-1]) if cfg.get("strip_plot", False): # standalone legend in seperate figure fig2, ax2 = plt.subplots(figsize=(5, 1), dpi=300) @@ -253,7 +240,7 @@ def plot(cfg, i, y, fname=None, fig=None, ax=None, label=None, full=False): plt.close() -def plot_mmm(cfg, region=None, fig=None, axs=None, label=None, meta=None): +def plot_mmm(cfg, region=None, fig=None, axs=None, label=None, meta=None) -> None: """Plot multi model mean of area ratios for given interval and events.""" if meta is None: meta = {} @@ -267,16 +254,17 @@ def plot_mmm(cfg, region=None, fig=None, axs=None, label=None, meta=None): meta["interval"] = f"{i[0]}-{i[1]}" cfg["basename"].format(**meta) if cfg["subplots"]: + if axs is None: + error_msg = "no axs for subplots" + raise ValueError(error_msg) plot(cfg, i, y, fig=fig, ax=axs[n], label=label) - # else: - # fname = get_plot_filename(basename, cfg) - # plot(cfg, i, y, fname=fname) if cfg.get("subplots_full", False): plot(cfg, i, y, fig=fig, ax=axs, label=label) -def set_defaults(cfg): - """Update cfg with default values from diffmap.yml +def set_defaults(cfg) -> None: + """Update cfg with default values from diffmap.yml. + TODO: This could be a shared function reused in other diagnostics. """ config_file = os.path.realpath(__file__)[:-3] + ".yml" @@ -293,8 +281,9 @@ def set_defaults(cfg): cfg["fullperiod"].setdefault("skip", False) -def set_timecoords(cfg): +def set_timecoords(cfg) -> None: """Read time coordinate points from reference dataset and store it in cfg. + This is required to ensure that all datasets have the same time axis. TODO: maybe new regrid_time with common calendar could replace this? """ @@ -310,7 +299,7 @@ def set_timecoords(cfg): print(cfg["times"]) -def plot_overview(cfg, data, group="unnamed"): +def plot_overview(cfg, data, group="unnamed") -> None: """Prepare a figure with 1 histogrical and 3 future scenario intervals.""" fig = plt.figure(**ut.sub_cfg(cfg, "overview", "fig_kwargs"), dpi=300) gs = gridspec.GridSpec(3, 2) @@ -327,7 +316,10 @@ def plot_overview(cfg, data, group="unnamed"): twin.set_yticks(cfg.get("yticks", None)) leg_ax = fig.add_subplot(gs[1:, 0]) hist_plotted = False - for n, ((_exp), e_data) in enumerate(data.groupby(["exp"])): + # TODO: dont use reversed on "random" input order. make it explicit. + for n, ((_exp), e_data) in enumerate(reversed(list(data.groupby(["exp"])))): + print("EXP") + print(_exp) dat = e_data.squeeze() # pick first and last interval: if not hist_plotted: @@ -381,7 +373,7 @@ def plot_overview(cfg, data, group="unnamed"): fig.savefig(get_plot_filename(f"overview_{cfg['index']}_MMM_{group}", cfg)) -def plot_full_periods(cfg, data): +def plot_full_periods(cfg, data) -> None: """Prepare a figure with full time series for each scenario/region.""" # setup figure with 1 row for legend and 1 for each scenario/region pair fig = plt.figure(**ut.sub_cfg(cfg, "fullperiod", "fig_kwargs"), dpi=300) @@ -390,34 +382,23 @@ def plot_full_periods(cfg, data): leg_ax = fig.add_subplot(gs[0, 0]) axs = [] # loop through data slices and plot to axis - for n, ((exp, reg), dat) in enumerate(data.groupby(["exp", "region"])): + # TODO: allow for both regional and exp grouping? + # for n, ((exp, reg), dat) in enumerate(reversed(data.groupby(["exp", "region"]))): + for n, ((exp), dat) in enumerate(reversed(list(data.groupby(["exp"])))): ax = fig.add_subplot(gs[n + 1, 0]) dat = dat.squeeze() y = dat["event_ratio"].data # hardcode full interval for this plot i = [0, None] - fname = get_plot_filename(f"event_area_{exp}_{reg}", cfg) + fname = get_plot_filename(f"event_area_{exp}", cfg) plot(cfg, i, y, fname=fname, fig=fig, ax=ax) axs.append(ax) - # if cfg.get("intervals", None) is None: - # cfg["intervals"] = get_intervals(cube, cfg["interval"]) - # if cfg.get("plot_models", True): - # for i in cfg["intervals"]: - # ftemp = f"{cube.name()}_{meta['dataset']}_{i[0]}-{i[1]}" - # if "region" in meta: - # ftemp += f"_{meta['region']}" - # fname = get_plot_filename(ftemp, cfg) - # plot(cfg, fname, i, y) - # for i, (exp, emetas) in enumerate(exp_metas.items()): - # exp_axs = [scenario_axs[i]] - # process_datasets(cfg, emetas, fig=fig, axs=exp_axs) - for ax in axs: # all plots ax.set_yticks(cfg.get("yticks", None)) - ax.grid(True, which="both", linestyle="--", linewidth=0.5) + ax.grid(True, which="both", linestyle="--", linewidth=0.5) # noqa: FBT003 ax.tick_params(axis="x", which="both", top=True, bottom=True) - # ax.yaxis.tick_right() ax.tick_params(axis="y", which="both", left=True, right=True) + ax.set_ylabel(exp) for ax in axs[:-1]: # disable xicks ax.set_xticklabels([]) leg_ax.axis("off") @@ -449,18 +430,18 @@ def plot_full_periods(cfg, data): fig.savefig(get_plot_filename(f"{cfg['index']}_MMM", cfg)) -def plot_each_interval(cfg, exp_metas): +def plot_each_interval(cfg, exp_metas) -> None: """Create an individual figure for each interval and each scenario.""" for _i, (_exp, emetas) in enumerate(exp_metas): process_datasets(cfg, emetas, fig=None, axs=None) -def process_datasets(cfg, metas, fig=None, axs=None): +def process_datasets(cfg, metas, fig=None, axs=None) -> None: """Load all models and call event area calculation for each.""" last_meta = None for meta in metas: fname = meta["filename"] - if not meta["short_name"].lower() == cfg["index"]: + if meta["short_name"].lower() != cfg["index"]: log.info("Not matching index (skipped): %s", cfg["index"]) continue cube = load_and_prepare(cfg, fname) @@ -491,11 +472,9 @@ def process_datasets(cfg, metas, fig=None, axs=None): if cfg.get("plot_mmm", True): ylabel = cfg.get("ylabels", {}).get(meta["exp"], meta["exp"]) if cfg.get("global", True) or cfg["combine_regions"]: - print("plotting mmm global/combined") plot_mmm(cfg, fig=fig, axs=axs, label=ylabel, meta=last_meta) else: for region in cfg.get("regions", [None]): - print("plotting mmm each region") plot_mmm( cfg, region=region, @@ -506,8 +485,11 @@ def process_datasets(cfg, metas, fig=None, axs=None): ) -def extract_regions(cfg, cube): - """Extract regions and return a list of cubes.""" +def extract_regions(cfg, cube) -> dict: + """Extract regions and return a list of cubes. + + TODO: use preproc instead + """ extracted = {} params = {"shapefile": "ar6", "ids": {"Acronym": cfg["regions"]}} if cfg["regions"] and cfg["combine_regions"]: @@ -523,7 +505,7 @@ def extract_regions(cfg, cube): return extracted -def regional_weights(cfg, cube): +def regional_weights(cfg, cube) -> np.ndarray: """Calculate area weights normalized to the total unmasked area.""" # NOTE: area_weights does not apply cubes mask, normalize manually if cfg["weighted"]: @@ -534,11 +516,10 @@ def regional_weights(cfg, cube): mask = get_2d_mask(cube, tile=True) weights = ma.masked_array(weights, mask=mask) unmasked_area = np.sum(weights) / cube.shape[0] - weights = weights / unmasked_area - return weights + return weights / unmasked_area -def calculate_event_ratios(cfg, metas, output): +def calculate_event_ratios(cfg, metas, output) -> tuple: """Load data and save calculated event ratio timelines.""" # data: dataset x exp x region x event # data_mmm: exp x region x event @@ -597,6 +578,7 @@ def load_event_ratios(cfg): def main(cfg): """Get common time coordinates, execute the diagnostic code. + Loop over experiments, than datasets. """ set_defaults(cfg) diff --git a/esmvaltool/diag_scripts/droughts/portrait_plot.py b/esmvaltool/diag_scripts/droughts/portrait_plot.py index 77daec85c3..61739acbad 100644 --- a/esmvaltool/diag_scripts/droughts/portrait_plot.py +++ b/esmvaltool/diag_scripts/droughts/portrait_plot.py @@ -9,9 +9,15 @@ configured in the recipe. All *_by parameters can be set to any metadata key. To split by 'reference' this key needs to be set as extra_facet in recipe. +NOTE: this is not the original portrait_plot, but a modified version for drought +diagnostics. Check if its possible to use the original portrait plot before +adding this one to the ESMValTool. + +TODO: prov (if we wanna use this in public code) + Author ------ -Lukas Ruhe (Universität Bremen, Germany) +Lukas Lindenlaub (Universität Bremen, Germany) Diego Cammarano Configuration parameters through recipe: diff --git a/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py index e3f9a2e9a1..134875bec2 100644 --- a/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py +++ b/esmvaltool/diag_scripts/droughts/timeseries_scenarios.py @@ -14,7 +14,7 @@ is only plotted once. plot_properties: dict, optional Additional properties to set on the plot. Passed to ax.set(). -figsize: tuple, optional (default: (9, 2)) +figsize: tuple, optional (default: (10.5, 1.5)) reuse_mm: bool, optional (default: False) subplots: bool, optional (default: False) Plot all time series as subplots in one figure with shared x-axis. @@ -160,8 +160,6 @@ def plot_models(cfg, metas, ax, smooth=False) -> None: if recalc and cfg.get("save_mm", True): iris.save(mm["mean"], fname + "_mean.nc") iris.save(mm["std_dev"], fname + "_stddev.nc") - # convert_units(mean) - # convert_units(std_dev) if experiment.startswith("historical-"): experiment = experiment.split("-")[1] @@ -194,7 +192,6 @@ def plot_obs(cfg, metas, ax, smooth=False) -> None: if smooth: cube = yearly_average(cube) mean = global_mean(cfg, cube) - # convert_units(mean) time = mean.coord("time") iplt.plot(time, mean, linestyle="--", label=meta["dataset"], axes=ax) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 32e16ed984..24e8a80ecb 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -354,13 +354,13 @@ def daily_to_monthly( cube.units = units -def _get_data_hlp(axis, data, ilat, ilon): +def _get_data_hlp(axis, data, ilat, ilon) -> np.ndarray: """Get data_help dependend on axis.""" if axis == 0: data_help = (data[:, ilat, ilon])[:, 0] elif axis == 1: data_help = (data[ilat, :, ilon])[:, 0] - elif axis == 2: + elif axis == 2: # noqa: PLR2004 data_help = data[ilat, ilon, :] else: data_help = None @@ -701,7 +701,7 @@ def select_meta_from_combi(meta: list, combi: dict, groups: dict) -> tuple: def _compare_dicts(dict1, dict2, sort) -> bool: if dict1.kyes() != dict2.keys(): return False - return all(_compare_values(dict1[key], dict2[key], sort) for key in dict1) + return all(_compare_values(dict1[key], dict2.get(key), sort) for key in dict1) def _compare_values(val1, val2, sort) -> bool: @@ -733,7 +733,7 @@ def get_common_meta(metas: list, *, sort: bool = False) -> dict: """ common = {} for key in metas[0]: - if all(_compare_values(metas[0][key], m[key], sort) for m in metas): + if all(_compare_values(metas[0][key], m.get(key), sort) for m in metas): common[key] = metas[0][key] return common diff --git a/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml index 275bed2dbd..ec746cad35 100644 --- a/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml +++ b/esmvaltool/recipes/droughts/recipe_lindenlaub25_historical.yml @@ -45,8 +45,8 @@ CMIP6_DATA_LMON: &cmip6_data_lmon - {<<: *cmip_default, mip: Lmon, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 - {<<: *cmip_default, mip: Lmon, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} - {<<: *cmip_default, mip: Lmon, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) - - {<<: *cmip_default, mip: Lmon, dataset: FIO-ESM-2-0, institute: FIO-QLNM } - - {<<: *cmip_default, mip: Lmon, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} + # - {<<: *cmip_default, mip: Lmon, dataset: FIO-ESM-2-0, institute: FIO-QLNM } # lat not monotonic cmorchecker + # - {<<: *cmip_default, mip: Lmon, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} # sdepth1 does not exist cmorchecker - {<<: *cmip_default, mip: Lmon, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} - {<<: *cmip_default, mip: Lmon, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} - {<<: *cmip_default, mip: Lmon, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} @@ -61,7 +61,7 @@ CMIP6_DATA: &cmip6_data - {<<: *cmip_default, dataset: AWI-CM-1-1-MR} - {<<: *cmip_default, dataset: BCC-CSM2-MR, institute: BCC} - {<<: *cmip_default, dataset: CanESM5, institute: CCCma} - - {<<: *cmip_default, dataset: CAS-ESM2-0, institute: CAS} + # - {<<: *cmip_default, dataset: CAS-ESM2-0, institute: CAS} why not in our list of models? - {<<: *cmip_default, dataset: CMCC-ESM2} # retry other ds name - {<<: *cmip_default, dataset: CNRM-CM6-1, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 - {<<: *cmip_default, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} @@ -75,9 +75,8 @@ CMIP6_DATA: &cmip6_data - {<<: *cmip_default, dataset: KACE-1-0-G, grid: gr} - {<<: *cmip_default, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source - {<<: *cmip_default, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} - - {<<: *cmip_default, dataset: MRI-ESM2-0, institute: MRI} # also calendar issues.. but works solo - - {<<: *cmip_default, dataset: UKESM1-0-LL, ensemble: r1i1p1f2} # pet works 3x3 - + - {<<: *cmip_default, dataset: MRI-ESM2-0, institute: MRI} + - {<<: *cmip_default, dataset: UKESM1-0-LL, ensemble: r1i1p1f2} ERA5: &era5 dataset: ERA5 @@ -115,7 +114,7 @@ CDS: &cds split: REF2 tier: 3 version: COMBINED - start_year: 1979 + start_year: 1980 end_year: 2014 reference_for_metric: true @@ -168,7 +167,6 @@ diagnostics: additional_datasets: *cmip6_data tasmax: *pet_default sfcWind: *pet_default - # clt: *pet_default rsds: *pet_default ps: *pet_default pr: @@ -205,7 +203,6 @@ diagnostics: - <<: *era5 tasmax: *pet_default_obs sfcWind: *pet_default_obs - # clt: *pet_default_obs rsds: *pet_default_obs ps: *pet_default_obs pr: @@ -214,7 +211,8 @@ diagnostics: scripts: pet_pm: script: droughts/pet.R - pet_type: "Penman" # "Penman_clt" + pet_type: "Penman" + method: ICID validate_obs: variables: @@ -222,7 +220,7 @@ diagnostics: additional_datasets: - <<: *era5 mip: Lmon - start_year: 1979 + start_year: 1980 end_year: 2014 - <<: *cds mip: Lmon @@ -231,7 +229,6 @@ diagnostics: additional_datasets: - <<: *cru tasmax: *add_cru - # clt: *add_cru evspsblpot: preprocessor: perday additional_datasets: @@ -243,6 +240,11 @@ diagnostics: scripts: diffmaps: script: droughts/diffmap.py + metrics: ["total", "diff"] + plot_models: True + save_models: True + plot_mmm: False + save_mmm: False ancestors: - pet_obs/pr - pet_obs/tasmin @@ -260,28 +262,26 @@ diagnostics: validate_models: &validate_models # additional_datasets: *cmip6_data variables: - # tasmin: &var_default_historical - # <<: *var_default - # <<: *obs_period - # exp: ["historical"] - # additional_datasets: *cmip6_data - # tasmax: - # <<: *var_default_historical + tasmin: &var_default_historical + <<: *var_default + <<: *obs_period + exp: ["historical"] + additional_datasets: *cmip6_data + tasmax: *var_default_historical + sfcWind: *var_default_historical + ps: *var_default_historical + rsds: *var_default_historical sm: <<: *var_default mip: Lmon - start_year: 1979 + start_year: 1980 end_year: 2014 derive: true exp: ["historical"] additional_datasets: *cmip6_data_lmon - # sfcWind: - # <<: *var_default_historical - # ps: - # <<: *var_default_historical - # pr: - # <<: *var_default_historical - # preprocessor: perday + pr: + <<: *var_default_historical + preprocessor: perday scripts: diffmaps: script: droughts/diffmap.py @@ -289,6 +289,7 @@ diagnostics: save_models: True plot_mmm: True save_mmm: True + metrics: ["total", "diff"] ancestors: - pet_historical/pr - pet_historical/tasmin @@ -318,7 +319,8 @@ diagnostics: - validate_models/diffmaps - validate_obs/diffmaps perfmetric: - script: portrait_plot.py + # TODO: check if general portrait plot is possible to + script: droughts/portrait_plot.py distance_metric: rmse nan_color: null y_labels: diff --git a/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml b/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml index 116694655e..59c87fc51e 100644 --- a/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml +++ b/esmvaltool/recipes/droughts/recipe_lindenlaub25_scenarios.yml @@ -21,8 +21,8 @@ documentation: GRID: &grid # target_grid: 0.25x0.25 - target_grid: 3x3 - # target_grid: 1x1 # might require a lot of memory (limit parallel tasks) + # target_grid: 1x1 + target_grid: 1x1 # might require a lot of memory (limit parallel tasks) # longest period for CMIP6 only analysis @@ -54,39 +54,39 @@ CMIP_DEFAULT: &cmip_default CMIP6_DATA_LMON: &cmip6_data_lmon # no awi or inm - {<<: *cmip_default, mip: Lmon, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS } - {<<: *cmip_default, mip: Lmon, dataset: BCC-CSM2-MR, institute: BCC } - # - {<<: *cmip_default, mip: Lmon, dataset: CanESM5, institute: CCCma } - # - {<<: *cmip_default, mip: Lmon, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name - # - {<<: *cmip_default, mip: Lmon, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 - # - {<<: *cmip_default, mip: Lmon, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} - # - {<<: *cmip_default, mip: Lmon, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) - # - {<<: *cmip_default, mip: Lmon, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} - # - {<<: *cmip_default, mip: Lmon, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} - # - {<<: *cmip_default, mip: Lmon, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} - # - {<<: *cmip_default, mip: Lmon, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source - # - {<<: *cmip_default, mip: Lmon, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} - # - {<<: *cmip_default, mip: Lmon, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo - # - {<<: *cmip_default, mip: Lmon, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 + - {<<: *cmip_default, mip: Lmon, dataset: CanESM5, institute: CCCma } + - {<<: *cmip_default, mip: Lmon, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name + - {<<: *cmip_default, mip: Lmon, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 + - {<<: *cmip_default, mip: Lmon, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} + - {<<: *cmip_default, mip: Lmon, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) + - {<<: *cmip_default, mip: Lmon, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} + - {<<: *cmip_default, mip: Lmon, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} + - {<<: *cmip_default, mip: Lmon, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} + - {<<: *cmip_default, mip: Lmon, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source + - {<<: *cmip_default, mip: Lmon, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} + - {<<: *cmip_default, mip: Lmon, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo + - {<<: *cmip_default, mip: Lmon, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 CMIP6_DATA: &cmip6_data - {<<: *cmip_default, dataset: ACCESS-CM2, institute: CSIRO-ARCCSS } - {<<: *cmip_default, dataset: AWI-CM-1-1-MR, institute: AWI } - {<<: *cmip_default, dataset: BCC-CSM2-MR, institute: BCC } - # - {<<: *cmip_default, dataset: CanESM5, institute: CCCma } - # - {<<: *cmip_default, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name - # - {<<: *cmip_default, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 - # - {<<: *cmip_default, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} - # - {<<: *cmip_default, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) - # - {<<: *cmip_default, dataset: FIO-ESM-2-0, institute: FIO-QLNM } - # - {<<: *cmip_default, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} - # - {<<: *cmip_default, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} - # - {<<: *cmip_default, dataset: INM-CM5-0, institute: INM, grid: gr1} # no mrsos? works for pr/tas - # - {<<: *cmip_default, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} - # - {<<: *cmip_default, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} - # - {<<: *cmip_default, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source - # - {<<: *cmip_default, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} - # - {<<: *cmip_default, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo - # - {<<: *cmip_default, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 + - {<<: *cmip_default, dataset: CanESM5, institute: CCCma } + - {<<: *cmip_default, dataset: CMCC-ESM2, institute: CMCC } # retry other ds name + - {<<: *cmip_default, dataset: CNRM-CM6-1, institute: CNRM-CERFACS, ensemble: r1i1p1f2, grid: gr} # pet works 3x3 + - {<<: *cmip_default, dataset: EC-Earth3-Veg-LR, institute: EC-Earth-Consortium, grid: gr} + - {<<: *cmip_default, dataset: FGOALS-g3, institute: CAS } # latitude as auxilary (not monotonic) + - {<<: *cmip_default, dataset: FIO-ESM-2-0, institute: FIO-QLNM } + - {<<: *cmip_default, dataset: GFDL-ESM4 , institute: NOAA-GFDL, grid: gr1} + - {<<: *cmip_default, dataset: GISS-E2-1-G, institute: NASA-GISS, ensemble: r1i1p1f2} + - {<<: *cmip_default, dataset: INM-CM5-0, institute: INM, grid: gr1} # no mrsos? works for pr/tas + - {<<: *cmip_default, dataset: IPSL-CM6A-LR, institute: IPSL, grid: gr} + - {<<: *cmip_default, dataset: KACE-1-0-G, institute: NIMS-KMA, grid: gr} + - {<<: *cmip_default, dataset: MIROC6, institute: MIROC, grid: gn} # works as single source + - {<<: *cmip_default, dataset: MPI-ESM1-2-LR, institute: MPI-M, grid: gn} + - {<<: *cmip_default, dataset: MRI-ESM2-0, institute: MRI } # also calendar issues.. but works solo + - {<<: *cmip_default, dataset: UKESM1-0-LL, institute: MOHC, ensemble: r1i1p1f2 } # pet works 3x3 @@ -349,7 +349,7 @@ diagnostics: # ancestors: ["wb_ssp585/wb"] event_area: scripts: - ssps: + ssps: &event_area_global subplots: True figsize: [9, 1.3] yticks: [0, 0.2, 0.4, 0.6, 0.8, 1] @@ -367,11 +367,14 @@ diagnostics: interval: 240 latest_legend: true plot_models: False - index: spei + index: SPEI plot_kwargs: baseline: "zero" shapefile: ar6_regions/IPCC-WGI-reference-regions-v4.shp # regions: ['WCE'] + ssps_harvest: + <<: *event_area_global + regions: [] regions: ®ions scripts: spei_ssp585: @@ -445,10 +448,16 @@ diagnostics: plot_models: False strip_plots: True save_mm: True - reuse_mm: True + reuse_mm: False + subplots: True smooth: True - figsize: [9, 2] - y_labels: + figsize: [10.5, 1.5] + legend: + ssp126: "SSP1-2.6" + ssp245: "SSP2-4.5" + ssp585: "SSP5-8.5" + historical: "Historical" + ylabels: pr: $PR$ [mm/day] evspsblpot: $ET_0$ [mm/day] spei: $SPEI$ From 7c03c7ef7d77f2f5b6d703b0009412ee517f5358 Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 1 Apr 2025 09:55:13 +0200 Subject: [PATCH 63/66] init paper doc --- .../recipes/droughts/recipe_lindenlaub25.rst | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst index 0e96ec737e..b01d316ec0 100644 --- a/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst +++ b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst @@ -40,6 +40,40 @@ calculation. ``evspsblpot`` for ERA5 and 18 CMIP6 datasets. ``SPEI`` is calculated from ``evspsblpot`` and ``pr``. + +TODO: ERA5 native on the fly or cmorizer? + +Reference Data (ERA5, CDS-SM, CRU) can be downloaded and cmorized by the +esmvaltool executing the `data` command: + +``` +esmvaltool data download ERA5 pr +esmvaltool data format ERA5 pr +``` + +``tasmin`` and ``tasmax`` are not directly available in the monthly averaged data +product. The download script `diag_scripts/droughts/download_era5_tasminmax.py` +can be used to download and preprocess the data based on the +``minimum_2m_temperature_since_previous_post_processing`` and +``maximum_2m_temperature_since_previous_post_processing`` variables from ERA5 +daily data on single levels. The output files are compatible with the esmvaltool +and can be copied into the ``native6/Tier3/ERA5/v1/mon/`` +directory. Using the esmvaltool environment ensures that all required libraries +are available. The script can be run with the following command: + +``` +python diag_scripts/droughts/download_era5_tasminmax.py +``` + +For more options use the ``--help`` flag. + +The CMIP6 data can be downloaded automatically by the ESMValTool. Just ensure +that ``esgf_download`` is set to ``True`` or ``when_missing`` in the +user configuration. + +Figures +------- + References ---------- From 568164450d789bafcc42ca40edcef04d3507227d Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Tue, 1 Apr 2025 09:55:34 +0200 Subject: [PATCH 64/66] init paper doc --- .../source/recipes/droughts/recipe_lindenlaub25.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst index b01d316ec0..368523ef2f 100644 --- a/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst +++ b/doc/sphinx/source/recipes/droughts/recipe_lindenlaub25.rst @@ -35,10 +35,10 @@ Data ---- Soil moisture is evaluated and discussed, but not required for PET and SPEI -calculation. -``tasmin``, ``tasmax``, ``sfcWind``, ``ps``, ``rsds`` are used to approximate +calculation. +``tasmin``, ``tasmax``, ``sfcWind``, ``ps``, ``rsds`` are used to approximate ``evspsblpot`` for ERA5 and 18 CMIP6 datasets. -``SPEI`` is calculated from ``evspsblpot`` and ``pr``. +``SPEI`` is calculated from ``evspsblpot`` and ``pr``. TODO: ERA5 native on the fly or cmorizer? @@ -68,7 +68,7 @@ python diag_scripts/droughts/download_era5_tasminmax.py For more options use the ``--help`` flag. The CMIP6 data can be downloaded automatically by the ESMValTool. Just ensure -that ``esgf_download`` is set to ``True`` or ``when_missing`` in the +that ``esgf_download`` is set to ``True`` or ``when_missing`` in the user configuration. Figures From 1fadb52847efaf15dceb7aa717af2d68aa9ca77b Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 29 Sep 2025 10:23:39 +0200 Subject: [PATCH 65/66] folder rename, plot adjustments --- .../diag_scripts/droughts/diffmap_paper.py | 424 ++++++++++++++++++ .../diag_scripts/droughts/distribution.py | 16 +- esmvaltool/diag_scripts/droughts/utils.py | 2 +- 3 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 esmvaltool/diag_scripts/droughts/diffmap_paper.py diff --git a/esmvaltool/diag_scripts/droughts/diffmap_paper.py b/esmvaltool/diag_scripts/droughts/diffmap_paper.py new file mode 100644 index 0000000000..5c6c59e888 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/diffmap_paper.py @@ -0,0 +1,424 @@ +"""Plot relative and absolute differences between two time intervals. + +A global map is plotted for each dataset with an index (must be unique). +The map shows the difference of the first and last N years +(N = comparison_period). +For multiple datasets a multi-model mean is calculated by default. This can be +disabled using `plot_mmm: False`. To plot only mmm and skip maps for individual +datasets use `plot_models: False`. +The diagnostic is applied to each variable by default, but for single variables +another meta key can be chosen for grouping like `group_by: project` to treat +observations and models seperatly. +The produced maps can be clipped to non polar landmasses (220, 170, -55, 90) +with `clip_land: True`. + + +Configuration options in recipe +------------------------------- +basename: str, optional + Format string for the plot filename. Can use meta keys and diffmap_metric. + For multi-model mean the dataset will be set to "MMM". Data will be saved + as same name with .nc extension. + By default: "{short_name}_{exp}_{diffmap_metric}_{dataset}" +clip_land: bool, optional (default: False) + Clips map plots to non polar land area (220, 170, -55, 90). +comparison_period: int, optional (default: 10) + Number of years to compare (first and last N years). Must be less or equal + half of the total time period. +filters: dict, or list, optional + Filter for metadata keys to select datasets. Only datasets with matching + values will be processed. This can be usefull, if ancestors or preprocessed + data is abailable, that should not be processed by the diagnostic. + If a list of dicts is given, all datasets matching any of the filters will + be considered. + By default None. +group_by: str, optional (default: short_name) + Meta key to loop over for multiple datasets. +metrics: list, optional + List of metrics to calculate and plot. For the difference ("percent" and + "diff") the mean over two comparison periods ("first" and "last") is + calculated. The "total" periods mean can be calculated and plotted as well. + By default ["first", "last", "diff", "total", "percent"] +mdtol: float, optional (default: 0.5) + Tolerance for missing data in multi-model mean calculation. 0 means no + missing data is allowed. For 1 mean is calculated if any data is available. +plot_kwargs: dict, optional + Kwargs passed to diag_scripts.shared.plot.global_contourf function. + The "cbar_label" parameter is formatted with meta keys. So placeholders + like "{short_name}" or "{units}" can be used. + By default {"cmap": "RdYlBu", "extend": "both"} +plot_kwargs_overwrite: list, optional (default: []) + List of plot_kwargs dicts for specific metrics (diff, first, latest, total) + and group_by values (ie. pr, tas for group_by: short_name). + `group` and `metric` can either be strings or lists of strings to be + applied to all matching plots. Leave any of them empty to apply to all. + All other given keys are applied to the plot_kwargs dict for this plot. + Settings will be applied in order of the list, so later entries can + overwrite previous ones. +plot_mmm: bool, optional (default: True) + Calculate and plot the average over all datasets. +plot_models: bool, optional (default: True) + Plot maps for each dataset. +strip_plots: bool, optional (default: False) + Removes titles, margins and colorbars from plots (to use them in panels). +titles: dict, optional + Customize plot titles for different metrics. Possible dict keys are + "first", "last", "trend", "diff", "total", "percent". The values are + formatted using meta data. Placeholders like "{short_name}" can be used. + By default {"first": "Mean Historical", "last": "Mean Future", + "trend": "Future - Historical", "diff": "Future - Historical", + "total": "Mean Full Period", "percent": "Relative Change"}. +""" + +from __future__ import annotations + +import contextlib +import logging +from collections import defaultdict +from pathlib import Path + +import iris +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +import yaml +from cartopy.util import add_cyclic_point +from esmvalcore import preprocessor as pp +from iris.analysis import MEAN +from iris.cube import Cube + +import esmvaltool.diag_scripts.droughts.utils as ut +import esmvaltool.diag_scripts.shared as e + +# from esmvaltool.diag_scripts.droughts import colors + +log = logging.getLogger(__file__) + + +METRICS = ["first", "last", "diff", "total", "percent"] +PROVENANCE = { + "authors": ["lindenlaub_lukas"], + "domains": ["global"], + "plot_types": ["map"], +} + + +def _get_provenance(cfg: dict, meta: dict) -> dict: + """Create provenance dict for single model plots.""" + prov = PROVENANCE.copy() + prov["statistics"] = ["mean"] + dataset = meta.get("dataset", "unknown") + if dataset == "MMM": + dataset = "Multi-Model Mean" + if meta["diffmap_metric"] == "diff": + prov["statistics"] = ["diff", "mean"] + prov["caption"] = ( + f"Absolute difference in {meta['long_name']} between first and " + f"last {cfg['comparison_period']} years of the period " + f"{meta['start_year']}-{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "percent": + prov["statistics"] = ["diff", "mean"] + prov["caption"] = ( + f"Relative difference in {meta['long_name']} between first and " + f"last {cfg['comparison_period']} years of the period " + f"{meta['start_year']}-{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "first": + prov["caption"] = ( + f"Average {meta['long_name']} over the period " + f"{meta['start_year']}-" + f"{meta['start_year'] + cfg['comparison_period'] - 1}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "last": + prov["caption"] = ( + f"Average {meta['long_name']} over the period " + f"{meta['end_year'] - cfg['comparison_period'] + 1}-" + f"{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + elif meta["diffmap_metric"] == "total": + prov["caption"] = ( + f"Average {meta['long_name']} over the period " + f"{meta['start_year']}-{meta['end_year']}, based on " + f"{meta['dataset']}." + ) + return prov + + +def plot_colorbar( + cfg: dict, + plotfile: str, + plot_kwargs: dict, + orientation: str = "vertical", + mappable: mpl.cm.ScalarMappable | None = None, +) -> None: + """Plot colorbar in its own figure for strip_plots.""" + _ = cfg # we might need this in the future + fig = plt.figure(figsize=(1.5, 3)) + # fixed size axes in fixed size figure + cbar_ax = fig.add_axes([0.01, 0.04, 0.2, 0.92]) + if mappable is None: + cmap = plot_kwargs.get("cmap", "RdYlBu") + norm = mpl.colors.Normalize( + vmin=plot_kwargs.get("vmin"), + vmax=plot_kwargs.get("vmax"), + ) + mappable = mpl.cm.ScalarMappable(norm=norm, cmap=cmap) + cbar = fig.colorbar( + mappable, + cax=cbar_ax, + orientation=orientation, + label=plot_kwargs["cbar_label"], + pad=0.0, + ) + if "cbar_ticks" in plot_kwargs: + cbar.set_ticks(plot_kwargs["cbar_ticks"], minor=False) + fontsize = plot_kwargs.get("cbar_fontsize", 14) + cbar.ax.tick_params(labelsize=fontsize) + cbar.set_label( + plot_kwargs["cbar_label"], + fontsize=fontsize, + labelpad=fontsize, + ) + plotfile = plotfile.removesuffix(".png") + fig.savefig(plotfile + "_cb.png") # , bbox_inches="tight") + + +def fill_era5_gap(meta: dict, cube: Cube) -> None: + """Fill missing gap at 360 for era5 pet calculation.""" + if ( + meta["dataset"] == "ERA5" + and meta["short_name"] == "evspsblpot" + and len(cube.data[0]) == 360 # noqa: PLR2004 + ): + cube.data[:, 359] = cube.data[:, 0] + + +def plot( + cfg: dict, + meta: dict, + cube: Cube, + basename: str, + kwargs: dict | None = None, +) -> str: + """Plot map using diag_scripts.shared module. + + Returns the plot filename. + """ + plotfile = e.get_plot_filename(basename, cfg) + plot_kwargs = cfg.get("plot_kwargs", {}).copy() + if kwargs is not None: + plot_kwargs.update(kwargs) + if "vmax" in plot_kwargs and "vmin" in plot_kwargs: + plot_kwargs["levels"] = np.linspace( + plot_kwargs["vmin"], + plot_kwargs["vmax"], + 9, + ) + label = plot_kwargs.get("cbar_label", "{short_name} ({units})") + plot_kwargs["cbar_label"] = label.format(**meta) + for coord in cube.coords(dim_coords=True): + if not coord.has_bounds(): + log.warning("NO BOUNDS. GUESSING: %s", coord.name()) + cube.coord(coord.name()).guess_bounds() + fill_era5_gap(meta, cube) + add_cyclic_point(cube.data, cube.coord("longitude").points) + mapplot = e.plot.global_contourf(cube, **plot_kwargs) + if cfg.get("clip_land", False): + plt.gca().set_extent((220, 170, -55, 90)) # type: ignore[attr-defined] + plt.title(meta.get("title", basename)) + if cfg.get("strip_plots", False): + plt.gca().set_title(None) + plt.gca().set_ylabel(None) + plt.gca().set_xlabel(None) + cb_mappable = mapplot.colorbar.mappable + mapplot.colorbar.remove() + plot_colorbar(cfg, plotfile, plot_kwargs, mappable=cb_mappable) + fig = mapplot.get_figure() + fig.savefig(plotfile, bbox_inches="tight") + plt.close() + log.info("saved figure: %s", plotfile) + return plotfile + + +def apply_plot_kwargs_overwrite( + kwargs: dict, + overwrites: dict, + metric: str, + group: str, +) -> dict: + """Apply plot_kwargs_overwrite to kwargs dict for selected plots.""" + for overwrite in overwrites: + new_kwargs = overwrite.copy() + groups = new_kwargs.pop("group", []) + if not isinstance(groups, list): + groups = [groups] + if len(groups) > 0 and group not in groups: + continue + metrics = new_kwargs.pop("metric", []) + if not isinstance(metrics, list): + metrics = [metrics] + if len(metric) > 0 and metric not in metrics: + continue + kwargs.update(new_kwargs) + return kwargs + + +def calculate_diff(cfg, meta, mm_data, output_meta, group) -> None: + """Absolute difference between first and last years of a cube. + + Calculates the absolut and relative difference between the first and last + period of a cube. Write data to mm and optionally plot each dataset. + """ + cube = iris.load_cube(meta["filename"]) + if meta["short_name"] in cfg.get("convert_units", {}): + pp.convert_units(cube, cfg["convert_units"][meta["short_name"]]) + with contextlib.suppress(Exception): + # TODO: maybe fix this within cmorizer + cube.remove_coord("Number of stations") # dropped by unit conversions + if "start_year" in cfg or "end_year" in cfg: + log.info("selecting time period") + cube = pp.extract_time( + cube, cfg["start_year"], 1, 1, cfg["end_year"], 12, 31 + ) + dtime = cfg["comparison_period"] * 12 + cubes: dict[Cube] = {} + cubes["total"] = cube.collapsed("time", MEAN) + do_metrics = cfg.get("metrics", METRICS) + norm = ( + int(meta["end_year"]) + - int(meta["start_year"]) + + 1 # count full end year + - cfg["comparison_period"] # decades center to center + ) / 10 + cubes["first"] = cube[0:dtime].collapsed("time", MEAN) + cubes["last"] = cube[-dtime:].collapsed("time", MEAN) + cubes["diff"] = cubes["last"] - cubes["first"] + cubes["diff"].data /= norm + cubes["diff"].units = str(cubes["diff"].units) + " / 10 years" + cubes["percent"] = cubes["diff"] / cubes["first"] * 100 + cubes["percent"].units = "% / 10 years" + if cfg.get("plot_mmm", True): + for key in do_metrics: + mm_data[key].append(cubes[key]) + for key, cube in cubes.items(): + if key not in do_metrics: + continue # i.e. first/last if only diff is needed + meta["diffmap_metric"] = key + meta["exp"] = meta.get("exp", "exp") + basename = cfg["basename"].format(**meta) + meta["title"] = cfg["titles"][key].format(**meta) + prov = _get_provenance(cfg, meta) + prov["ancestors"] = [meta["filename"]] + if cfg.get("plot_models", True): + plot_kwargs = cfg.get("plot_kwargs", {}).copy() + apply_plot_kwargs_overwrite( + plot_kwargs, + cfg.get("plot_kwargs_overwrite", []), + key, + group, + ) + plotfile = plot(cfg, meta, cube, basename, kwargs=plot_kwargs) + plt.close() + ut.log_provenance(cfg, plotfile, prov) + if cfg.get("save_models", True): + work_file = str(Path(cfg["work_dir"]) / f"{basename}.nc") + iris.save(cube, work_file) + meta["filename"] = work_file + output_meta[work_file] = meta.copy() + ut.log_provenance(cfg, work_file, prov) + + +def calculate_mmm(cfg, meta, mm_data, output_meta, group) -> None: + """Calculate multi-model mean for a given metric.""" + for metric in cfg.get("metrics", METRICS): + meta = meta.copy() # don't modify meta in place: + meta["diffmap_metric"] = metric + basename = cfg["basename"].format(**meta) + results = pp.multi_model_statistics( + mm_data[metric], "overlap", ["mean"], ignore_scalar_coords=True + ) + mmm = results["mean"] + meta["title"] = f"Multi-model Mean ({cfg['titles'][metric]})" + if cfg.get("plot_mmm", True): + plot_kwargs = cfg.get("plot_kwargs", {}).copy() + overwrites = cfg.get("plot_kwargs_overwrite", []) + apply_plot_kwargs_overwrite(plot_kwargs, overwrites, metric, group) + plot_file = plot(cfg, meta, mmm, basename, kwargs=plot_kwargs) + prov = _get_provenance(cfg, meta) + prov["ancestors"] = meta["ancestors"] + ut.log_provenance(cfg, plot_file, prov) + if cfg.get("save_mmm", True): + work_file = str(Path(cfg["work_dir"]) / f"{basename}.nc") + meta["filename"] = work_file + output_meta[work_file] = meta.copy() + iris.save(mmm, work_file) + + +def set_defaults(cfg: dict) -> None: + """Update cfg with default values from diffmap.yml in place.""" + config_fpath = Path(__file__).with_suffix(".yml") + with config_fpath.open() as config_file: + defaults = yaml.safe_load(config_file) + for key, val in defaults.items(): + cfg.setdefault(key, val) + if cfg["plot_kwargs_overwrite"] is not defaults["plot_kwargs_overwrite"]: + cfg["plot_kwargs_overwrite"].extend(defaults["plot_kwargs_overwrite"]) + titles = defaults.get("titles", {}) + titles.update(cfg["titles"]) + cfg["titles"] = titles + + +def filter_metas(metas: list, filters: dict | list) -> list: + """Filter metas by filter dicts.""" + if isinstance(filters, dict): + filters = [filters] + filtered = {} + for selection in filters: + for meta in e.select_metadata(metas, **selection): + filtered[meta["filename"]] = meta # unique + return list(filtered.values()) + + +def main(cfg) -> None: + """Execute Diagnostic.""" + set_defaults(cfg) + metas = cfg["input_data"].values() + if cfg.get("filters") is not None: + metas = filter_metas(metas, cfg["filters"]) + groups = e.group_metadata(metas, cfg["group_by"]) + output = {} + for group, g_metas in groups.items(): + mm_data = defaultdict(list) + for meta in g_metas: + if "end_year" not in meta: + meta.update(ut.get_time_range(meta["filename"])) + # adjust norm for selected time period + meta["end_year"] = cfg.get("end_year", meta["end_year"]) + meta["start_year"] = cfg.get("start_year", meta["start_year"]) + calculate_diff(cfg, meta, mm_data, output, group) + do_mmm = cfg.get("plot_mmm", True) or cfg.get("save_mmm", True) + if do_mmm and len(g_metas) > 1: + # copy meta from first dataset and add all ancestors + keep_keys = [ + "short_name", + "long_name", + "units", + "start_year", + "end_year", + "exp", + ] + meta = {k: g_metas[0][k] for k in keep_keys} + meta["ancestors"] = [met["filename"] for met in g_metas] + meta["dataset"] = "MMM" + calculate_mmm(cfg, meta, mm_data, output, group) + ut.save_metadata(cfg, output) + + +if __name__ == "__main__": + with e.run_diagnostic() as config: + main(config) diff --git a/esmvaltool/diag_scripts/droughts/distribution.py b/esmvaltool/diag_scripts/droughts/distribution.py index effbe9f11d..3a5f54ab7e 100644 --- a/esmvaltool/diag_scripts/droughts/distribution.py +++ b/esmvaltool/diag_scripts/droughts/distribution.py @@ -96,10 +96,10 @@ from matplotlib import cbook from scipy.stats import norm -from esmvaltool.diag_scripts.droughtindex import ( +from esmvaltool.diag_scripts.droughts import ( colors as ipcc_colors, ) -from esmvaltool.diag_scripts.droughtindex import ( +from esmvaltool.diag_scripts.droughts import ( utils as ut, ) from esmvaltool.diag_scripts.shared import ( @@ -285,6 +285,12 @@ def plot_histogram(cfg, splits, output, group, fit=True): zorder=3, ) legend = plt.legend() + try: + legend_rename = ut.sub_cfg(cfg, "histogram", "legend") + except KeyError: + legend_rename = {} + for text in legend.get_texts(): # overwrite legend labels if provided + text.set_text(legend_rename.get(text.get_text(), text.get_text())) for patch in legend.get_patches(): patch.set_alpha(1) plot_props = ut.sub_cfg(cfg, "histogram", "plot_properties") @@ -299,7 +305,7 @@ def plot_histogram(cfg, splits, output, group, fit=True): "group": group, }, ) - filename = ut.get_plot_filename(cfg, cfg["basename"], meta, {"/": "_"}) + filename = ut.get_plot_fname(cfg, cfg["basename"], meta, {"/": "_"}) plt.savefig(filename) log.info("saved %s", filename) @@ -323,7 +329,7 @@ def plot_histogram(cfg, splits, output, group, fit=True): # plt.legend(handles=handles, labels=labels + ["normal fit"]) # ax.legend(handles, labels) meta["plot_type"] = "histogram_fit" - filename = ut.get_plot_filename(cfg, cfg["basename"], meta, {"/": "_"}) + filename = ut.get_plot_fname(cfg, cfg["basename"], meta, {"/": "_"}) log.info("saved %s", filename) plt.savefig(filename) plt.close() @@ -438,7 +444,7 @@ def plot_regional_stats(cfg, splits, output, group): plt.tight_layout() plt.xlim(-1, len(regions)) # save plot - fname = ut.get_plot_filename(cfg, f"regional_stats_{group}") + fname = ut.get_plot_fname(cfg, f"regional_stats_{group}") plt.savefig(fname) log.info("saved %s", fname) diff --git a/esmvaltool/diag_scripts/droughts/utils.py b/esmvaltool/diag_scripts/droughts/utils.py index 27f5b48911..03b2612695 100644 --- a/esmvaltool/diag_scripts/droughts/utils.py +++ b/esmvaltool/diag_scripts/droughts/utils.py @@ -106,7 +106,7 @@ def get_plot_fname( for key, value in replace.items(): basename = basename.replace(key, value) fpath = Path(cfg["plot_dir"]) / basename - return str(fpath.with_suffix(cfg["output_file_type"])) + return str(fpath.with_suffix("." + cfg["output_file_type"])) def add_ancestor_input(cfg: dict) -> None: From 502dd4c6bd6073d412b89b87e6b1ac5b5fe7baea Mon Sep 17 00:00:00 2001 From: Lukas Ruhe Date: Mon, 29 Sep 2025 10:28:07 +0200 Subject: [PATCH 66/66] seasonal cycles --- .../diag_scripts/droughts/seasonal_cycle.py | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 esmvaltool/diag_scripts/droughts/seasonal_cycle.py diff --git a/esmvaltool/diag_scripts/droughts/seasonal_cycle.py b/esmvaltool/diag_scripts/droughts/seasonal_cycle.py new file mode 100644 index 0000000000..7a18f8b049 --- /dev/null +++ b/esmvaltool/diag_scripts/droughts/seasonal_cycle.py @@ -0,0 +1,330 @@ +"""Diagnostic script to plot multi panel seasonal cycles for different regions. + +NOTE: Its just a script yet, but could be easily adaptable to a diagnostic. + +regions: List(List(str)): + Nested list of regions. One panel for each first level entry. + Each element can be a list of acronyms to combine (mean) multiple regions. +legend_r: bool + If True, legend is placed right of the panels. Otherwise below. +""" + +from pathlib import Path + +import iris +import matplotlib +import yaml +from esmvalcore import preprocessor as pp +from matplotlib import pyplot as plt + +SESSION_HIST = Path( + "/work/bd0854/b309169/output/recipe_lindenlaub25_historical_20250320_162808" +) +REGIONS = [ + ["WAF"], + ["NES"], + ["EAS"], + ["SAS"], + ["WCE"], + ["CNA"], + ["EEU"], + ["SES"], + ["WSB"], + ["MED"], + ["SAU"], +] +COMBINED = [ + "WCE", + "CNA", + "SAS", + "EAS", + "EEU", + "WAF", + "SES", + "WSB", + "MED", + "NES", + "SAU", +] +REGIONS.append(COMBINED) +VAR = "tasmin" +# VAR = "pr" +TASMA = True # calculate tasmin+max/2 instead of tasmin +# tas plot +TSLICE = None +FILL = False +# TSLICE = [0, 120] # 0 +# TSLICE = [300, 420] # 0 + + +LEGEND_R = True # default bottom + +FNAME = f"~/seasonal_cycle_{VAR}_w.png" +if TSLICE is not None: + FNAME = FNAME.replace(".png", f"_{TSLICE[0]}-{TSLICE[1]}.png") +SHARE_Y = False +YLIMS = None # limit per row or None for auto + +cfg = { + "regions": REGIONS, + "var": VAR, + "plot_stdv": FILL, + "interval": TSLICE, + "tas_from_min_max": TASMA, + "cmip6_styles": yaml.safe_load(Path("cmip6.yml").read_text()), + "share_y": SHARE_Y, + "ylims": YLIMS, +} + +if cfg["var"] == "pr": + cfg["ylims"] = [(0, 12), (0, 6), (0, 6)] # limit per row or None for auto + cfg["share_y"] = True +elif cfg["var"] == "tasmin": + cfg["share_y"] = True + cfg["ylims"] = [ + (260, 305), + (260, 305), + (260, 305), + ] # limit per row or None for auto + +hist_metas = yaml.safe_load( + (SESSION_HIST / f"preproc/pet_historical/{VAR}/metadata.yml").read_text() +) +obs_metas = yaml.safe_load( + (SESSION_HIST / f"preproc/pet_obs/{VAR}/metadata.yml").read_text() +) +extra_metas = yaml.safe_load( + (SESSION_HIST / f"preproc/validate_obs/{VAR}/metadata.yml").read_text() +) +obs_metas.update(extra_metas) + +metas = obs_metas.copy() +metas.update(hist_metas) + + +styles = yaml.safe_load(Path("cmip6.yml").read_text()) + +# REGIONS = [["MED"], ["WCE"], []] +# hist_metas = yaml.safe_load((SESSION_HIST / "preproc/pet_historical/pr/metadata.yml").read_text()) +# obs_metas = yaml.safe_load((SESSION_HIST / "preproc/pet_obs/pr/metadata.yml").read_text()) + +# pr plot + + +def create_figure_layout(cfg): + fig = plt.figure() + axs = [] + nreg = len(cfg["regions"]) + if nreg > 4: + ncols = 4 + nrows = (nreg // ncols) + (1 if nreg % ncols > 0 else 0) + else: + ncols = nreg + nrows = 1 + if cfg.get("legend_r", False): + fig.set_size_inches(2.5 * (ncols + 1.5), 2.5 * nrows) + gs = matplotlib.gridspec.GridSpec( + nrows, + ncols + 1, + width_ratios=[3] * ncols + [1.9], + wspace=0.07, + hspace=0.07, + ) + leg_ax = fig.add_subplot(gs[:, -1]) + else: + fig.set_size_inches(2.5 * ncols, 2.5 * nrows + 1) + gs = matplotlib.gridspec.GridSpec( + nrows + 1, + ncols, + height_ratios=[3] * nrows + [1.5], + wspace=0.07, + hspace=0.07, + ) + leg_ax = fig.add_subplot(gs[-1, 0:]) + col_ax = None + for i in range(nrows): + for j in range(ncols): + if i * ncols + j >= nreg: + break + ax = fig.add_subplot(gs[i, j]) + if j == 0: + # TODO: use metadata instead + if cfg["var"] == "pr": + ax.set_ylabel("Precipitation (mm/day)") + elif cfg["var"] in ["tasmin", "tasmax", "tas"]: + ax.set_ylabel("Temperature (K)") + else: + ax.set_ylabel(cfg["var"]) + col_ax = ax + if cfg["ylims"] is not None: + ax.ylim = cfg["ylims"][i] + elif cfg["share_y"]: + ax.sharey(col_ax) + if cfg["share_y"] and j == 0: + ax.tick_params(axis="y", labelleft=True, labelright=False) + elif cfg["share_y"] and j == ncols - 1: + ax.tick_params(axis="y", labelleft=False, labelright=True) + else: + ax.tick_params(axis="y", labelleft=False, labelright=False) + ax.tick_params( + axis="both", which="both", direction="in", top=True, right=True + ) + ax.yaxis.grid(True, which="major", linestyle=":", alpha=0.4) + ax.xaxis.grid(True, which="major", linestyle=":", alpha=0.4) + axs.append(ax) + ax.set_xticks(range(1, 13)) + if i == nrows - 1: + ax.set_xlabel("Month") + ax.set_xticklabels( + [ + "Jan", + "", + "Mar", + "", + "May", + "", + "Jul", + "", + "Sep", + "", + "Nov", + "", + ] + ) + else: + ax.set_xticklabels([]) + return fig, axs, gs, nrows, ncols, leg_ax + + +def plot_cycle(cube, ax, meta, **kwargs): + ax.plot( + cube.coord("month_number").points, + cube.data, + label=meta["dataset"], + **kwargs, + ) + + +def plot_stdv(cube, std_dev, ax, **kwargs): + ax.fill_between( + std_dev.coord("month_number").points, + cube.data - std_dev.data, + cube.data + std_dev.data, + color=kwargs.get("color", "gray"), + alpha=0.3, + # label="± 1 std. dev." if "± 1 std. dev." not in ax.get_legend_handles_labels()[1] else None + ) + + +def guess_bounds(cube): + if not cube.coord("latitude").has_bounds(): + cube.coord("latitude").guess_bounds() + if not cube.coord("longitude").has_bounds(): + cube.coord("longitude").guess_bounds() + return cube + + +def regional_cycle(cube, operator="mean", regions=None): + cube = guess_bounds(cube) + if regions is None: + regions = ["MED"] + if len(regions) > 0: + extracted = pp.extract_shape( + cube, shapefile="ar6", ids={"Acronym": regions} + ) + else: + extracted = cube + reg_mean = pp.area_statistics(extracted, operator="mean") + cycle = pp.climate_statistics( + reg_mean, operator=operator, period="monthly" + ) + return cycle + + +def load_cube(fname): + cube = iris.load_cube(fname) + if cfg["var"] == "tasmin" and cfg["tas_from_min_max"]: + max_cube = iris.load_cube(fname.replace("tasmin", "tasmax")) + cube = (cube + max_cube) / 2 + if cfg["interval"] is not None: + cube = cube[cfg["interval"][0] : cfg["interval"][1]] + return cube + + +def process_region(ax, hist_metas, obs_metas, regions, **kwargs): + # plot cmip6 models + for fname, meta in hist_metas.items(): + cube = load_cube(fname) + cycle = regional_cycle(cube, regions=regions) + if meta["dataset"] not in styles: + print(f"WARNING: No style for {meta['dataset']} found!") + color = styles.get(meta["dataset"], {}).get("color", "black") + linestyle = styles.get(meta["dataset"], {}).get("dash", "-") + plot_cycle( + cycle, ax, meta, linestyle=linestyle, color=color, alpha=0.5 + ) + title = regions[0] if len(regions) == 1 else "Combined" + ax.text( + 0.06, + 0.92, + title, + transform=ax.transAxes, + va="top", + ha="left", + bbox=dict(facecolor="white", alpha=0.6, edgecolor="none"), + ) + # plot era5 + for fname, meta in obs_metas.items(): + cube = load_cube(fname) + cycle = regional_cycle(cube, regions=regions) + color = "red" + if meta["dataset"].upper() == "CRU": + color = "black" + if cfg["plot_stdv"]: + std_dev = regional_cycle(cube, operator="std_dev") + plot_stdv(cycle, std_dev, ax, color=color) + plot_cycle(cycle, ax, meta, color=color, linewidth=1.5) + + +def add_legend(cfg, leg_ax, axs): + leg_ax.axis("off") + hands, labs = axs[0].get_legend_handles_labels() + # leg_ax.legend(handles=hands, labels=labs, bbox_to_anchor=(1, 1), loc='lower right', ncols=6) + if cfg.get("legend_r", False): + bbox = (1.3, 0.5) + ncols = 1 + loc = "center right" + else: + bbox = (0.5, -0.25) + ncols = 5 + loc = "lower center" + leg_ax.legend( + handles=hands, + labels=labs, + ncols=ncols, + loc=loc, + fancybox=False, + bbox_to_anchor=bbox, + ) # Lower position + + +def main(cfg, metas): + # create and plot + fig, axs, gs, nrows, ncols, leg_ax = create_figure_layout(cfg) + for i, regs in enumerate(cfg["regions"]): + print("regs:", regs) + process_region(axs[i], hist_metas, obs_metas, regs) + + add_legend(cfg, leg_ax, axs) + # save + fig.tight_layout() + plt.subplots_adjust(left=0.06, right=0.96, top=0.96, bottom=0.06) + if cfg.get("tas_from_min_max", False): + cfg["fname"] = cfg["fname"].replace("tasmin", "tasmid") + fig.savefig(cfg["fname"], dpi=300) + print("DONE") + + +if __name__ == "__main__": + print("starting") + main(cfg, metas)