Projected climate change signals and uncertainty under global warming levels#

This notebook is a reproducibility example of the IPCC-WGI AR6 Interactive Atlas products. This work is licensed under a Creative Commons Attribution 4.0 International License.

Creative Commons License >

E. Cimadevilla and M. Iturbide (Santander Meteorology Group. Instituto de Física de Cantabria, CSIC-UC, Santander, Spain).

This notebook is an example of the calculation and visualization of the IPCC-WGI AR6 uncertainty methods (simple and advanced) for projected delta changes. Please refer to the AR6 WGI Cross-Chapter Box Atlas 1 (Gutiérrez et al., 2021) for more information. We also introduce the Global Warming Level dimension for the analysis of the climate change signal.

See also hatching-uncertainty_R.ipynb at the IPCC-WGI/Atlas GitHub repository (also available in the hub: shared/repositories/IPCC-WGI-Atlas/notebooks).

This notebook works with the data available in this Hub, this is the dataset underlying the IPCC-WGI AR6 Interactive Atlas, originally published at DIGITAL.CSIC for the long-term archival, and also available through the Copernicus Data Store (CDS). Open the Getting_started.ipynb for a quick description of the available data.

Contents in this notebook#

  1. Libraries

  2. The Global Warming Level analysis dimension

  3. Data loading for the different GWLs

  4. Data loading for the historical reference

  5. Uncertainty calculation and representation


1. Libraries#

import re
import math

import xarray
import numpy as np
import pandas as pd
import cartopy.crs as ccrs
import matplotlib.pyplot as plt
import requests

2. The Global Warming Level analysis dimension#

Instead of calculating climate change anomalies for a fixed period, we will calculate the anomaly for a given level of global warming (GWL). To do so, we need the information on the time windows where the global surface temperature reaches the different levels of warming. This information is available at the IPCC-WGI/Atlas GitHub repository.

url = "https://github.com/SantanderMetGroup/ATLAS/raw/refs/heads/main/warming-levels/CMIP6_Atlas_WarmingLevels.csv"
gwls_file = url.split("/")[-1]
with open(gwls_file, "w") as f:
    response = requests.get(url)
    response.raise_for_status()
    f.write(response.text)
gwls = pd.read_csv(gwls_file)
gwls.head()
model_run 1.5_ssp126 2_ssp126 3_ssp126 4_ssp126 1.5_ssp245 2_ssp245 3_ssp245 4_ssp245 1.5_ssp370 2_ssp370 3_ssp370 4_ssp370 1.5_ssp585 2_ssp585 3_ssp585 4_ssp585
0 ACCESS-CM2_r1i1p1f1 2027.0 2042.0 NaN NaN 2028 2040 2070.0 NaN 2027 2039 2062.0 2082.0 2025 2038 2055 2071.0
1 ACCESS-ESM1-5_r1i1p1f1 2030.0 2072.0 NaN NaN 2029 2045 NaN NaN 2033 2048 2069.0 NaN 2027 2039 2060 2078.0
2 AWI-CM-1-1-MR_r1i1p1f1 2022.0 2050.0 NaN NaN 2020 2039 NaN NaN 2021 2037 2064.0 NaN 2019 2036 2059 2079.0
3 BCC-CSM2-MR_r1i1p1f1 2041.0 NaN NaN NaN 2035 2057 NaN NaN 2032 2046 2074.0 NaN 2031 2043 2065 NaN
4 CAMS-CSM1-0_r2i1p1f1 NaN NaN NaN NaN 2055 2088 NaN NaN 2046 2065 NaN NaN 2047 2064 2088 NaN

Note that we have pointed to the CMIP6_Atlas_WarmingLevels.csv file, as we are going to work with CMIP6 data. In this example, we will focus on the +3ºC GWL. We will consider the ssp585 scenario, however, any other scenario can be considered, as the anomalies do not vary significantly across scenarios when the GWL dimension is analyzed.

gwls3 = gwls[["model_run", "3_ssp585"]]
gwls3.head()
model_run 3_ssp585
0 ACCESS-CM2_r1i1p1f1 2055
1 ACCESS-ESM1-5_r1i1p1f1 2060
2 AWI-CM-1-1-MR_r1i1p1f1 2059
3 BCC-CSM2-MR_r1i1p1f1 2065
4 CAMS-CSM1-0_r2i1p1f1 2088

Object gwl3 is a data frame containing the central year in a time window of 20 years where the +3ºC GWL is reached for each model (Find more information in the IPCC-WGI/Atlas repository).

3. Data loading for the different GWLs#

df = pd.read_csv("../../../data_inventory.csv")
subset = df.query('type == "opendap" & variable == "pr" & project == "CMIP6" & experiment == "ssp585" & frequency == "mon"')
location = subset["location"].iloc[0]
location
'https://hub.climate4r.ifca.es/thredds/dodsC/ipcc/ar6/atlas/ia-monthly/CMIP6/ssp585/pr_CMIP6_ssp585_mon_201501-210012.nc'
cmip6_ssp585 = xarray.open_dataset(location)
cmip6_ssp585["member"] = cmip6_ssp585["member"].astype(str)  # due to OPeNDAP reading as bytes
cmip6_ssp585["member"] = [re.sub(r"[^_]+_", "", x, count=1) for x in cmip6_ssp585["member"].values]  # due to different values

lats, lons = slice(35, 72), slice(-11, 35)

# common members
members = [x for x in cmip6_ssp585["member"].values if x in gwls["model_run"].values]
gwls3_ssp585 = gwls3[gwls3["model_run"].isin(members)]
cmip6_ssp585 = cmip6_ssp585.sel(
    member=members,
    lat=lats,
    lon=lons,
    time=cmip6_ssp585.time.dt.month.isin([12,1,2]))

cmip6_ssp585
<xarray.Dataset> Size: 58MB
Dimensions:    (member: 33, time: 258, lat: 37, lon: 46, bnds: 2)
Coordinates:
  * member     (member) <U24 3kB 'ACCESS-CM2_r1i1p1f1' ... 'UKESM1-0-LL_r1i1p...
  * time       (time) datetime64[ns] 2kB 2015-01-01 2015-02-01 ... 2100-12-01
  * lat        (lat) float64 296B 35.5 36.5 37.5 38.5 ... 68.5 69.5 70.5 71.5
  * lon        (lon) float64 368B -10.5 -9.5 -8.5 -7.5 ... 31.5 32.5 33.5 34.5
Dimensions without coordinates: bnds
Data variables:
    time_bnds  (time, bnds) datetime64[ns] 4kB ...
    lat_bnds   (lat, bnds) float64 592B ...
    lon_bnds   (lon, bnds) float64 736B ...
    crs        int32 4B ...
    pr         (member, time, lat, lon) float32 58MB ...
Attributes:
    Conventions:               CF-1.9 ACDD-1.3
    summary:                   IPCC-WGI AR6 Interactive Atlas dataset: Monthl...
    keywords:                  CMIP5, CMIP6, CORDEX, IPCC, Interactive Atlas
    institution:               Instituto de Fisica de Cantabria (IFCA, CSIC-U...
    license:                   CC-BY 4.0, https://creativecommons.org/license...
    references:                https://doi.org/10.1017/9781009157896.021 http...
    standard_name_vocabulary:  CF Standard Name Table (Version 79, 2022-03-19)
    experiment_id:             ssp585
    source:                    CMIP6
    frequency:                 mon
    variable_id:               pr
cmip6_ssp585 = xarray.concat(
    [cmip6_ssp585.sel(
        member=model_run,
        time=slice(f"{year-10}1201", f"{year+10}0201"))
     for model_run, year in gwls3_ssp585.values
     if year != 9999],
    "member")

cmip6_ssp585
<xarray.Dataset> Size: 46MB
Dimensions:    (member: 33, time: 204, bnds: 2, lat: 37, lon: 46)
Coordinates:
  * time       (time) datetime64[ns] 2kB 2030-12-01 2031-01-01 ... 2098-02-01
  * lat        (lat) float64 296B 35.5 36.5 37.5 38.5 ... 68.5 69.5 70.5 71.5
  * lon        (lon) float64 368B -10.5 -9.5 -8.5 -7.5 ... 31.5 32.5 33.5 34.5
  * member     (member) <U24 3kB 'ACCESS-CM2_r1i1p1f1' ... 'UKESM1-0-LL_r1i1p...
Dimensions without coordinates: bnds
Data variables:
    time_bnds  (member, time, bnds) datetime64[ns] 108kB NaT NaT NaT ... NaT NaT
    lat_bnds   (member, lat, bnds) float64 20kB 35.0 36.0 36.0 ... 71.0 72.0
    lon_bnds   (member, lon, bnds) float64 24kB -11.0 -10.0 -10.0 ... 34.0 35.0
    crs        (member) int32 132B -2147483647 -2147483647 ... -2147483647
    pr         (member, time, lat, lon) float32 46MB nan nan nan ... nan nan nan
Attributes:
    Conventions:               CF-1.9 ACDD-1.3
    summary:                   IPCC-WGI AR6 Interactive Atlas dataset: Monthl...
    keywords:                  CMIP5, CMIP6, CORDEX, IPCC, Interactive Atlas
    institution:               Instituto de Fisica de Cantabria (IFCA, CSIC-U...
    license:                   CC-BY 4.0, https://creativecommons.org/license...
    references:                https://doi.org/10.1017/9781009157896.021 http...
    standard_name_vocabulary:  CF Standard Name Table (Version 79, 2022-03-19)
    experiment_id:             ssp585
    source:                    CMIP6
    frequency:                 mon
    variable_id:               pr
qmean = cmip6_ssp585.mean(["lat", "lon"]).resample(time="QS-DEC").mean()
plot = qmean["pr"].sel(time=qmean["pr"].time.dt.month==12).plot.line(x="time", add_legend=False, figsize=(15, 5))
../_images/fa61aceab81cd9ce78cd42fc743460986244388dcf7b713d23c2726055b165ef.png
plot = cmip6_ssp585["pr"].mean("time").plot(
    x="lon", y="lat", col="member", col_wrap=6,
    figsize=(28,13),
    add_colorbar=True,
    cmap="coolwarm_r",
    cbar_kwargs={"shrink": .5},
    subplot_kws=dict(projection=ccrs.PlateCarree(central_longitude=0)),
    transform=ccrs.PlateCarree())

for ax in plot.axs.flatten():
    ax.coastlines()
    ax.set_extent((lons.start, lons.stop, lats.start, lats.stop), ccrs.PlateCarree())
../_images/79d1a18a487ce5adfe53418d4d40c596616deae5a570917d2204b6bc405a1f92.png

4. Data loading for the historical reference#

To get the climate change signal, we first need to load data from the historical scenario. As mentioned above, we are considering the pre-industrial period. In this case, the reference period is common to all models, therefore the process becomes simpler; all models are loaded at once into a single grid object and we do not need to set a loop.

df = pd.read_csv("../../data_inventory.csv")
subset = df.query('type == "opendap" & variable == "pr" & project == "CMIP6" & experiment == "historical" & frequency == "mon"')
location = subset["location"].iloc[0]
location
'https://hub.climate4r.ifca.es/thredds/dodsC/ipcc/ar6/atlas/ia-monthly/CMIP6/historical/pr_CMIP6_historical_mon_185001-201412.nc'
cmip6_hist = xarray.open_dataset(location).sel(
    lat=lats,
    lon=lons,
    time=slice("18501201", "19000201"))
cmip6_hist["member"] = cmip6_hist["member"].astype(str)  # due to OPeNDAP reading as bytes
cmip6_hist["member"] = [re.sub(r"[^_]+_", "", x, count=1) for x in cmip6_hist["member"].values]  # due to different values

# common members
members = [x for x in cmip6_ssp585["member"].values if x in cmip6_hist["member"].values]
cmip6_hist = cmip6_hist.sel(
    member=members,
    time=cmip6_hist.time.dt.month.isin([12,1,2]))

cmip6_hist
<xarray.Dataset> Size: 34MB
Dimensions:    (member: 33, time: 150, lat: 37, lon: 46, bnds: 2)
Coordinates:
  * member     (member) <U25 3kB 'ACCESS-CM2_r1i1p1f1' ... 'UKESM1-0-LL_r1i1p...
  * time       (time) datetime64[ns] 1kB 1850-12-01 1851-01-01 ... 1900-02-01
  * lat        (lat) float64 296B 35.5 36.5 37.5 38.5 ... 68.5 69.5 70.5 71.5
  * lon        (lon) float64 368B -10.5 -9.5 -8.5 -7.5 ... 31.5 32.5 33.5 34.5
Dimensions without coordinates: bnds
Data variables:
    time_bnds  (time, bnds) datetime64[ns] 2kB ...
    lat_bnds   (lat, bnds) float64 592B ...
    lon_bnds   (lon, bnds) float64 736B ...
    crs        int32 4B ...
    pr         (member, time, lat, lon) float32 34MB ...
Attributes:
    Conventions:               CF-1.9 ACDD-1.3
    summary:                   IPCC-WGI AR6 Interactive Atlas dataset: Monthl...
    keywords:                  CMIP5, CMIP6, CORDEX, IPCC, Interactive Atlas
    institution:               Instituto de Fisica de Cantabria (IFCA, CSIC-U...
    license:                   CC-BY 4.0, https://creativecommons.org/license...
    references:                https://doi.org/10.1017/9781009157896.021 http...
    standard_name_vocabulary:  CF Standard Name Table (Version 79, 2022-03-19)
    experiment_id:             historical
    source:                    CMIP6
    frequency:                 mon
    variable_id:               pr

It is always a good practice to make sure that the members in both grids are identical:

print(f'Are all members identical? {(cmip6_hist["member"] == cmip6_ssp585["member"]).all().item()}')
Are all members identical? True

Now we can calculate the anomaly by computing the difference between both climatologies:

%%time

delta = cmip6_ssp585["pr"].mean("time") - cmip6_hist["pr"].mean("time")
CPU times: user 5.78 s, sys: 1.43 s, total: 7.22 s
Wall time: 2min 7s
plot = delta.plot(
    x="lon", y="lat", col="member", col_wrap=6,
    figsize=(28,13),
    add_colorbar=True,
    cmap="coolwarm_r",
    cbar_kwargs={"shrink": .5},
    subplot_kws=dict(projection=ccrs.PlateCarree(central_longitude=0)),
    transform=ccrs.PlateCarree())

for ax in plot.axs.flatten():
    ax.coastlines()
    ax.set_extent((lons.start, lons.stop, lats.start, lats.stop), ccrs.PlateCarree())

plot.fig.savefig("pr-delta.svg")
../_images/e98d1c828a16a1706769e06582ec617b6cc427d03759d4262eab68203b56ffbb.png

We can calculate the relative anomaly the same way:

delta_rel = (delta / cmip6_hist["pr"].mean("time")) * 100
plot = delta_rel.plot(
    x="lon", y="lat", col="member", col_wrap=6,
    figsize=(28,13),
    add_colorbar=True,
    cmap="BrBG",
    cbar_kwargs={"shrink": .5},
    subplot_kws=dict(projection=ccrs.PlateCarree(central_longitude=0)),
    transform=ccrs.PlateCarree())

for ax in plot.axs.flatten():
    ax.coastlines()
    ax.set_extent((lons.start, lons.stop, lats.start, lats.stop), ccrs.PlateCarree())
../_images/6a792fcb2793eb8280fa9ab3667bcccb3a46279f619ccc5207fa1c036e200733.png

To calculate the multi-model mean, use function aggregateGrid.

ens_mean = delta_rel.mean("member")

plot = ens_mean.plot(
    add_colorbar=True,
    cmap="BrBG",
    cbar_kwargs={"shrink": .5},
    vmin=-50, vmax=50,
    subplot_kws=dict(projection=ccrs.PlateCarree(central_longitude=0)),
    transform=ccrs.PlateCarree())

plot.axes.coastlines()
<cartopy.mpl.feature_artist.FeatureArtist at 0x7adaab125e10>
../_images/133f64c558d59b4abc2e55732b3df92fcd989ba8e5c2b09317ec9f1497dd0f8b.png

5. Uncertainty calculation and representation#

climate4R implements the “simple” and “advanced” methods for the uncertainty characterization defined in the IPCC Sixth Assessment report. The function to apply is computeUncertainty. Please refer to the AR6 WGI Cross-Chapter Box Atlas 1 (Gutiérrez et al., 2021) for more information.

def model_agreement(da, axis, th = 80):
    nmembers, nlat, nlon = da.shape
    mask = np.array([
        (da[:,i,j] > 0).sum() > int(nmembers * th / 100)
        if da[:,i,j].mean() > 0
        else (da[:,i,j] < 0).sum() > int(nmembers * th / 100)
        for i in range(nlat)
        for j in range(nlon)]).reshape((nlat,nlon))
    
    return mask

def hatching(plot, mask, data):
    rows, cols = mask.shape
    for i in range(rows):
        for j in range(cols):
            lat, lon = data["lat"][i].item(), data["lon"][j].item()
            if mask[i,j]:
                plot.axes.plot([lon-.5,lon+.5],[lat+.5,lat-.5],'-',c="black", linewidth=.5)
mask_simple = delta.reduce(model_agreement, "member")

plot = ens_mean.plot(
    add_colorbar=True,
    cmap="BrBG",
    cbar_kwargs={"shrink": .5},
    vmin=-50, vmax=50,
    subplot_kws=dict(projection=ccrs.PlateCarree(central_longitude=0)),
    transform=ccrs.PlateCarree())

hatching(plot, ~mask_simple, ens_mean)

plot.axes.coastlines()

plt.savefig("uncer-europe.svg", bbox_inches="tight")
../_images/95917779dd2421cfd3c78d6c1d04ce7aba39de2a4ab3c1524a2793b4d0404dea.png

Now we compute the “advanced” method:

def advanced_method(historical, delta):
    vth = historical.reduce(
        lambda da, axis: ((math.sqrt(2) * 1.645 * np.std(da, axis=axis, ddof=1)) / math.sqrt(20)),
        "time")
    anom_abs = np.abs(delta)
    sig = anom_abs - vth
    si = xarray.where(sig > 0, 1, 0)
    uncer3 = delta.reduce(model_agreement, "member", th=80)
    uncer1 = si.reduce(
        lambda da, axis, nmembers, th: (da.sum(axis)/nmembers)*100 > th,
        "member",
        nmembers=si["member"].size,
        th=66)
    uncer2 = si.reduce(
        lambda da, axis, nmembers, th: (da.sum(axis)/nmembers)*100 < th,
        "member",
        nmembers=si["member"].size,
        th=66)
    uncer23 = (uncer2.astype(int) + uncer3.astype(int)) > 0
    uncer_a_aux1 = ((uncer1.astype(int) - 1) * -1).values
    uncer_a_aux2 = ((uncer23.astype(int) - 1) * -1).values
    uncer_a_aux2[uncer_a_aux2 == 1] = 2
    uncer_a_aux = uncer_a_aux1 + uncer_a_aux2
    uncer_a_aux[uncer_a_aux>1] = 2

    return uncer_a_aux
a = cmip6_hist["pr"].resample(time="QS-DEC").mean()
historical = a.sel(time=a.time.dt.month==12)

mask_advanced = advanced_method(historical, delta)
plot = ens_mean.plot(
    add_colorbar=True,
    cmap="BrBG",
    cbar_kwargs={"shrink": .5},
    vmin=-50, vmax=50,
    subplot_kws=dict(projection=ccrs.PlateCarree(central_longitude=0)),
    transform=ccrs.PlateCarree())

hatching(plot, mask_advanced, ens_mean)

plot.axes.coastlines()
<cartopy.mpl.feature_artist.FeatureArtist at 0x7adac02a2f90>
../_images/1688fb0bf5fa14c04c61067e91e9ea9d34a737d60ae922073e37756294fe930c.png
fig, axes = plt.subplots(1,2, figsize=(12,6), subplot_kw=dict(projection=ccrs.PlateCarree(central_longitude=0)))

plot_simple = ens_mean.plot(
    ax=axes[0],
    add_colorbar=True,
    cmap="BrBG",
    cbar_kwargs={"shrink": .5},
    vmin=-50, vmax=50,
    transform=ccrs.PlateCarree())

plot_advanced = ens_mean.plot(
    ax=axes[1],
    add_colorbar=True,
    cmap="BrBG",
    cbar_kwargs={"shrink": .5},
    vmin=-50, vmax=50,
    transform=ccrs.PlateCarree())

hatching(plot_simple, ~mask_simple, ens_mean)
hatching(plot_advanced, mask_advanced, ens_mean)

for ax in axes.flatten():
    ax.coastlines()
    ax.set_extent((lons.start, lons.stop, lats.start, lats.stop), ccrs.PlateCarree())

plt.savefig("uncertainty.svg")
../_images/b8c446c029932f7df07cdf4bd0e3bbeb7bac5a77339bd838c03a70ddf63f6c19.png

Regional stripes#

Here is an example of generating regional stripes.

cmip6_ssp585_y = cmip6_ssp585["pr"].resample(time="QS-DEC").mean()
cmip6_ssp585_y = cmip6_ssp585_y.sel(time=cmip6_ssp585_y["time"].dt.month==12)

cmip6_hist_c = cmip6_hist["pr"].mean(["time"])

year_delta = cmip6_ssp585_y - cmip6_hist_c
year_delta_rel = year_delta / cmip6_hist_c * 100

regional_mean = year_delta_rel.mean(["lat", "lon"])
regional_mean.plot.imshow(
    figsize=(14,7),
    add_colorbar=True,
    cmap="BrBG",
    vmin=-50,vmax=50)
plt.savefig("regional-stripes-pr.pdf", bbox_inches="tight")
../_images/39f16297e7e812f2a2e6a759a8631a759ed80e9f85e6101d38ee2b06553edbd3.png
cmip6_hist_y = cmip6_hist["pr"].resample(time="QS-DEC").mean()
cmip6_hist_y = cmip6_hist_y.sel(time=cmip6_hist_y["time"].dt.month==12)

cmip6_hist_y.mean(["lat", "lon"]).plot.imshow(
    figsize=(14,7),
    add_colorbar=True,
    cmap="YlGnBu",
    vmin=0,vmax=5)
<matplotlib.image.AxesImage at 0x7adaaa10cfd0>
../_images/9bb5e2dfa2309539b233f8749e02b052bd3dada15bfcfe55dc85d7d20333f1dd.png

We may omit the color bar to display only the spawn of each model time series.

regional_mean.where(np.isnan(regional_mean), 0).plot.imshow(
    figsize=(14,7),
    add_colorbar=False)
<matplotlib.image.AxesImage at 0x7adaaa141b90>
../_images/f46b03a6ac8653694bc0cc6740298fd3d0a87ce64b9aa27bbc4e00f09bcbbb60.png