Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Exposure of people to changing extreme precipitation

In this tutorial we quantify how many people are exposed to an increase in the frequency of extreme precipitation events under future climate projections. Building on the hazard analysis in Projected changes of extreme precipitation, we combine:

  1. Hazard — the likelihood change factor for a historical 20-year extreme precipitation event, derived from CORDEX-EUR11 ensemble projections.

  2. Exposure — gridded population from the GHS-WUP-POP dataset (1975–2100, 5-year steps), which follows UN World Urbanisation Prospects and covers all future windows analysed here.

A person is counted as exposed if they live at a grid point where the multi-model mean likelihood factor for the 20-year event is ≥ 1.5 (i.e. the event is at least 50% more frequent than in the historical baseline).

Setup

import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from scipy.stats import genextreme
from scipy.spatial import cKDTree
import rioxarray as rxr
import geopandas as gpd
import regionmask
import os
import re
from pathlib import Path

Settings

The hazard settings below are identical to changing_hazard.ipynb to ensure consistency. The two additional parameters rp_exposure and factor_threshold define the event and the exposure criterion:

  • rp_exposure: the return period (in years) of the event whose frequency change we track.

  • factor_threshold: a grid cell is classified as exposed when the multi-model mean likelihood factor equals or exceeds this value.

# --- Hazard settings (identical to changing_hazard.ipynb) ---
admin_id       = 'ITF2'
baseline_start = 1976
baseline_end   = 2005
return_periods = np.array([2, 5, 10, 20, 50, 100])

future_periods = {
    'Near-term (2021-2050)': (2021, 2050),
    'Mid-term (2041-2070)':  (2041, 2070),
    'Long-term (2071-2100)': (2071, 2100),
}
future_period_names = list(future_periods.keys())

rcps        = ['rcp_2_6', 'rcp_4_5', 'rcp_8_5']
rcp_labels  = {'rcp_2_6': 'RCP 2.6', 'rcp_4_5': 'RCP 4.5', 'rcp_8_5': 'RCP 8.5'}
rcp_colors  = {'rcp_2_6': 'steelblue', 'rcp_4_5': 'darkorange', 'rcp_8_5': 'crimson'}

# --- Exposure-specific settings ---
rp_exposure      = 20    # return period to track [years]
factor_threshold = 1.5   # likelihood factor defining 'exposed'
window_len       = 30    # sliding window length [years]

# --- Paths ---
#workdir     = Path('/home/nejk/code/extreme_precip_exposure')
#os.chdir(workdir)
main_dir    = Path("../")
data_dir    = main_dir / 'data'
hist_file   = data_dir / admin_id / 'extreme_precip_exposure' / f'{admin_id}_precip_hist_amax_all_points.csv'
proj_file   = data_dir / admin_id / 'extreme_precip_exposure' / f'{admin_id}_precip_proj_amax_all_points.csv'
ghs_wup_dir = data_dir / 'population' / 'GHS_WUP_POP'
nuts_shp    = data_dir / 'regions' / 'NUTS_RG_20M_2024_4326' / 'NUTS_RG_20M_2024_4326.shp'
pop_csv     = data_dir / admin_id / 'ghs_population' / f'population_ghs_wup_{admin_id}.csv'

print(f'Region           : {admin_id} (Molise, Italy)')
print(f'Event analysed   : {rp_exposure}-year return period')
print(f'Exposure threshold: factor \u2265 {factor_threshold}\u00d7')
Region           : ITF2 (Molise, Italy)
Event analysed   : 20-year return period
Exposure threshold: factor ≥ 1.5×

Hazard baseline

We reproduce the key steps from the hazard tutorial in compact form: load historical annual maxima, fit a Generalised Extreme Value (GEV) distribution at each model–grid-point combination over the baseline period (1976–2005), and derive return levels. These return levels become the fixed event thresholds against which future exceedances are counted.

For the full explanation of GEV fitting, spatial variability, and multi-model uncertainty see Projected changes of extreme precipitation.

# Region boundary
nuts_gdf = gpd.read_file(nuts_shp)
sel_gdf  = nuts_gdf[nuts_gdf['NUTS_ID'] == admin_id]
lon_min, lat_min, lon_max, lat_max = sel_gdf.geometry.total_bounds
print(f'Region boundary loaded: {sel_gdf["NUTS_NAME"].values[0]} ({admin_id})')

# Load historical annual maxima
hist_df = pd.read_csv(hist_file)
hist_df = hist_df.dropna(subset=['rx1day'])
hist_df = hist_df.drop_duplicates(subset=['member_id', 'lat', 'lon', 'year'])
print(f'Historical data: {hist_df["member_id"].nunique()} models, '
      f'{hist_df[["lat","lon"]].drop_duplicates().shape[0]} grid points, '
      f'{hist_df["year"].min()}\u2013{hist_df["year"].max()}')

# Fit GEV to baseline period
baseline_df = hist_df[(hist_df['year'] >= baseline_start) & (hist_df['year'] <= baseline_end)]
gev_records = []
for (member_id, lat, lon), grp in baseline_df.groupby(['member_id', 'lat', 'lon']):
    amax = grp['rx1day'].dropna().values
    if len(amax) < 10:
        continue
    try:
        shape, loc, scale = genextreme.fit(amax)
        gev_records.append({'member_id': member_id, 'lat': lat, 'lon': lon,
                            'shape': shape, 'loc': loc, 'scale': scale})
    except Exception:
        continue
gev_df = pd.DataFrame(gev_records)

# Compute return levels for every (model, grid point, return period)
rl_records = []
for _, row in gev_df.iterrows():
    hist_sub = hist_df[
        (hist_df['member_id'] == row['member_id']) &
        (hist_df['lat']       == row['lat'])       &
        (hist_df['lon']       == row['lon'])
    ]
    for rp in return_periods:
        rl = float(genextreme.ppf(1 - 1/rp, row['shape'], row['loc'], row['scale']))
        rc = int((hist_sub['rx1day'] >= rl).sum())
        rl_records.append({
            'member_id': row['member_id'], 'lat': row['lat'], 'lon': row['lon'],
            'return_period': rp, 'return_level': rl, 'hist_count': rc,
        })
return_levels_df = pd.DataFrame(rl_records)

n_models = gev_df['member_id'].nunique()
n_points = gev_df[['lat', 'lon']].drop_duplicates().shape[0]
print(f'GEV fits: {n_models} models \u00d7 {n_points} grid points = {len(gev_df)} combinations')
Region boundary loaded: Molise (ITF2)
Historical data: 71 models, 32 grid points, 1970–2005
GEV fits: 71 models × 32 grid points = 2272 combinations

Population data

We use GHS-WUP-POP, the gridded population projections at 30 arcsec (~1 km) resolution for 1975–2100 in 5-year steps, consistent with UN World Urbanisation Prospects. See the how-to-guide on population data for instructions to download the population data, and the the population tutorial for a quick visualiation and explanation of the datasets.

The GHS-WUP-POP dataset extends to 2100, making it ideal for matching the long-term future window used in this analysis. The GHS-WUP cells (~1 km) are much finer than the climate model grid (~12 km). We aggregate population onto the hazard grid by assigning each GHS-WUP cell to its nearest hazard grid cell (via a KD-tree nearest-neighbour lookup) and summing. Every population cell within the region boundary is assigned to exactly one hazard cell, so regional population totals are perfectly preserved.

# Hazard grid and KD-tree
haz_grid   = return_levels_df[['lat', 'lon']].drop_duplicates().reset_index(drop=True)
haz_coords = haz_grid[['lat', 'lon']].values          # shape (N_haz, 2)
tree       = cKDTree(haz_coords)
print(f'Hazard grid: {len(haz_coords)} cells')

# Infer grid spacing from hazard points
lats_haz = sorted(haz_grid['lat'].unique())
lons_haz = sorted(haz_grid['lon'].unique())
dlat = float(np.median(np.diff(lats_haz))) if len(lats_haz) > 1 else 0.11
dlon = float(np.median(np.diff(lons_haz))) if len(lons_haz) > 1 else 0.11
print(f'Grid spacing  : \u0394lat={dlat:.4f}\u00b0, \u0394lon={dlon:.4f}\u00b0')

# Region mask for GHS rasters
admin_mask = regionmask.from_geopandas(sel_gdf, names='NUTS_ID')

# Load and regrid all GHS-WUP-POP rasters
print('\nLoading and regridding GHS-WUP-POP rasters ...')
tif_paths = sorted(ghs_wup_dir.glob('*/GHS_WUP_POP_E*_GLOBE_R2025A_4326_30ss_V1_0.tif'))
if not tif_paths:
    raise FileNotFoundError(f'No GHS-WUP-POP rasters found under {ghs_wup_dir}. \nPlease download data following the instructions in the How-To-Guide on Population data.')

pop_haz_records = []
for tif_path in tif_paths:
    m = re.search(r'E(\d{4})', tif_path.name)
    if not m:
        continue
    year = int(m.group(1))

    da = rxr.open_rasterio(tif_path, masked=True).squeeze('band', drop=True)
    da_clip = da.sel(
        x=slice(lon_min - 0.1, lon_max + 0.1),
        y=slice(lat_max + 0.1, lat_min - 0.1),   # y is descending in rasters
    )

    # Boolean region mask on the GHS grid
    raw_mask  = admin_mask.mask(da_clip.x, da_clip.y)
    in_region = ~np.isnan(raw_mask.values)         # True where inside the region

    lons_2d, lats_2d = np.meshgrid(da_clip.x.values, da_clip.y.values)
    pop_vals = np.nan_to_num(da_clip.values, nan=0.0)
    pop_vals = np.where(pop_vals < 0, 0.0, pop_vals)  # remove fill values

    pop_lat_in = lats_2d[in_region]
    pop_lon_in = lons_2d[in_region]
    pop_val_in = pop_vals[in_region]

    # Assign each GHS cell to nearest hazard cell and sum
    _, idx = tree.query(np.column_stack([pop_lat_in, pop_lon_in]))
    pop_per_haz = np.zeros(len(haz_coords))
    np.add.at(pop_per_haz, idx, pop_val_in)

    for i in range(len(haz_coords)):
        pop_haz_records.append({
            'year': year,
            'lat':  haz_coords[i, 0],
            'lon':  haz_coords[i, 1],
            'population': pop_per_haz[i],
        })
    print(f'  {year}: {pop_per_haz.sum():>12,.0f} people')

pop_haz_df = pd.DataFrame(pop_haz_records)
print(f'\nDone. {pop_haz_df["year"].nunique()} years \u00d7 {len(haz_coords)} hazard cells = {len(pop_haz_df):,} rows')
Hazard grid: 32 cells
Grid spacing  : Δlat=0.1250°, Δlon=0.1250°

Loading and regridding GHS-WUP-POP rasters ...
  1975:      326,934 people
  1980:      324,848 people
  1985:      317,922 people
  1990:      310,127 people
  1995:      304,151 people
  2000:      294,884 people
  2005:      293,907 people
  2010:      294,722 people
  2015:      280,672 people
  2020:      257,922 people
  2025:      241,395 people
  2030:      223,097 people
  2035:      205,248 people
  2040:      187,136 people
  2045:      170,265 people
  2050:      152,605 people
  2055:      135,951 people
  2060:      118,726 people
  2065:      105,774 people
  2070:       93,365 people
  2075:       85,471 people
  2080:       78,185 people
  2085:       72,967 people
  2090:       67,868 people
  2095:       63,721 people
  2100:       59,564 people

Done. 26 years × 32 hazard cells = 832 rows

Exposure in the three future periods

We apply the same three 30-year future windows used in the hazard tutorial. For each window and each RCP scenario, we:

  1. Count how many times the historical 20-year return level is exceeded at each model–grid-point combination.

  2. Compute the likelihood factor = exceedance count × T / 30 (where T = 20 years).

  3. Average the factor across ensemble members at each grid cell.

  4. Classify grid cells as exposed where the multi-model mean factor ≥ 1.5.

  5. Sum the GHS-WUP population at exposed cells, using the population at the period mid-year.

# Load projections
print('Loading projection data ...')
proj_df = pd.read_csv(proj_file)
proj_df = proj_df.dropna(subset=['rx1day'])
proj_df = proj_df.drop_duplicates(subset=['member_id', 'lat', 'lon', 'year', 'rcp'])
print(f'  {proj_df["member_id"].nunique()} models, '
      f'{proj_df[["lat","lon"]].drop_duplicates().shape[0]} grid points, '
      f'{proj_df["year"].min()}\u2013{proj_df["year"].max()}')

# Historical threshold for the chosen return period
threshold_rp = (
    return_levels_df[return_levels_df['return_period'] == rp_exposure]
    [['member_id', 'lat', 'lon', 'return_level']]
    .rename(columns={'return_level': 'hist_threshold'})
)

# Compute future exceedance counts and likelihood factors (same approach as changing_hazard)
future_records = []
for period_name, (pstart, pend) in future_periods.items():
    period_proj = proj_df[(proj_df['year'] >= pstart) & (proj_df['year'] <= pend)]
    wlen = pend - pstart + 1
    for rcp in rcps:
        rcp_proj = period_proj[period_proj['rcp'] == rcp]
        if rcp_proj.empty:
            continue
        merged = rcp_proj.merge(threshold_rp, on=['member_id', 'lat', 'lon'], how='inner')
        merged['exceeds'] = merged['rx1day'] >= merged['hist_threshold']
        counts = (
            merged.groupby(['member_id', 'lat', 'lon'])['exceeds']
            .sum()
            .reset_index(name='proj_count')
        )
        counts['period'] = period_name
        counts['rcp']    = rcp
        counts['factor'] = counts['proj_count'] * rp_exposure / wlen
        future_records.append(counts)

future_rp_df = pd.concat(future_records, ignore_index=True)
print(f'Future hazard table: {len(future_rp_df):,} rows')
Loading projection data ...
  71 models, 32 grid points, 2006–2100
Future hazard table: 11,328 rows
# Pre-build interpolated population for arbitrary years (for reuse in timeseries section)
pop_haz_pivot = pop_haz_df.pivot_table(index=['lat', 'lon'], columns='year', values='population')
all_years_ghs = sorted(pop_haz_pivot.columns)
all_years_interp = list(range(all_years_ghs[0], all_years_ghs[-1] + 1))
pop_haz_pivot_full = (
    pop_haz_pivot
    .reindex(columns=sorted(set(all_years_ghs) | set(all_years_interp)))
    .interpolate(axis=1, method='linear', limit_direction='both')
    [all_years_interp]
)

def pop_at_year(year):
    """Return population Series indexed by (lat, lon) for the given year."""
    y = int(np.clip(year, all_years_interp[0], all_years_interp[-1]))
    return pop_haz_pivot_full[y]

# Compute exposure for the three future periods
exposure_records = []
for period_name, (pstart, pend) in future_periods.items():
    pop_mid = pop_at_year((pstart + pend) // 2)
    total_pop = pop_mid.sum()

    for rcp in rcps:
        cell_factor = (
            future_rp_df[
                (future_rp_df['period'] == period_name) &
                (future_rp_df['rcp']    == rcp)
            ]
            .groupby(['lat', 'lon'])['factor'].mean()
        )
        exposed_idx = cell_factor[cell_factor >= factor_threshold].index
        exposed_pop = pop_mid.reindex(exposed_idx).fillna(0).sum()

        exposure_records.append({
            'period':          period_name,
            'rcp':             rcp,
            'mid_year':        (pstart + pend) // 2,
            'total_pop':       total_pop,
            'exposed_pop':     exposed_pop,
            'pct_exposed':     100 * exposed_pop / total_pop if total_pop > 0 else 0,
            'n_exposed_cells': len(exposed_idx),
            'n_total_cells':   len(cell_factor),
        })

exposure_df = pd.DataFrame(exposure_records)
exposure_df
#print(f'Exposure results (factor \u2265 {factor_threshold}\u00d7 for {rp_exposure}-year event):')
#print(exposure_df[['period', 'rcp', 'mid_year', 'total_pop', 'exposed_pop', 'pct_exposed',
#                    'n_exposed_cells', 'n_total_cells']]
#      .to_string(index=False, float_format='{:,.0f}'.format))
Loading...
# plot absolute exposed population and percentage, faceted by future period and coloured by RCP (overlaid on total population in grey)
fig = make_subplots(
    rows=1, cols=3,
    subplot_titles=future_period_names,
    shared_yaxes=True,
    horizontal_spacing=0.06,
)

for col_idx, period_name in enumerate(future_period_names, start=1):
    pdata = exposure_df[exposure_df['period'] == period_name].set_index('rcp')
    x_labels = [rcp_labels[r] for r in rcps]

    # Grey bar: total regional population
    fig.add_trace(go.Bar(
        x=x_labels,
        y=pdata.loc[rcps, 'total_pop'].values,
        name='Total regional population',
        marker_color='lightgrey',
        legendgroup='total',
        showlegend=(col_idx == 1),
        hovertemplate='Total pop: %{y:,.0f}<extra></extra>',
    ), row=1, col=col_idx)

    # Coloured bar: exposed population (overlaid)
    fig.add_trace(go.Bar(
        x=x_labels,
        y=pdata.loc[rcps, 'exposed_pop'].values,
        name='Exposed population',
        marker_color=[rcp_colors[r] for r in rcps],
        legendgroup='exposed',
        showlegend=(col_idx == 1),
        hovertemplate='Exposed: %{y:,.0f}<extra></extra>',
    ), row=1, col=col_idx)

    # Annotate % exposed
    for i, rcp in enumerate(rcps):
        pct = pdata.loc[rcp, 'pct_exposed']
        fig.add_annotation(
            x=x_labels[i],
            y=pdata.loc[rcp, 'exposed_pop'],
            text=f'{pct:.0f}%',
            showarrow=False,
            yshift=10,
            font=dict(size=10),
            row=1, col=col_idx,
        )

fig.update_yaxes(title_text='Population', row=1, col=1)
fig.update_layout(
    title=(
        f'Population exposed to a \u2265{factor_threshold}\u00d7 increase in the '
        f'{rp_exposure}-year event<br>'
        f'<sup>Coloured bars = exposed; grey bars = total regional population at period mid-year. '
        f'Percentages annotated above bars.</sup>'
    ),
    barmode='overlay',
    legend_title='',
    autosize=True, height=500,
)
fig.show(config={'displayModeBar': True, 'responsive': True})
Loading...

Figure: Number of people exposed to extreme precipitation exceeding the 1.5× historical threshold in the Molise region per future period and RCP scenario, shown as absolute counts (bars) and as a share of total population (dots).

The bars reveal two competing forces: a growing hazard signal (more grid cells exceed the 1.5× threshold as warming progresses) and a shrinking population (Molise is projected to lose population throughout this century). The balance between these two dynamics determines whether absolute exposure rises or falls — a key insight that would be missed by looking at hazard alone.

Moving-window timeseries of exposed population

The three period snapshots above show how exposure evolves at specific time horizons. To trace the full trajectory, we slide a 30-year trailing window across the projection period year by year. For each window end year tt:

  1. Use the window [t−29,t][t - 29, t] to count exceedances of the historical 20-year threshold.

  2. Compute the multi-model mean likelihood factor per grid cell.

  3. Flag exposed cells (factor ≥ 1.5×) and sum the GHS-WUP population at year tt (linearly interpolated between 5-year steps).

The first complete 30-year window requires data from 2006 onwards, so the timeseries begins at 2035.


# Pre-merge projection data with thresholds (avoids repeating inside the loop)
proj_merged = proj_df.merge(threshold_rp, on=['member_id', 'lat', 'lon'], how='inner')
proj_merged['exceeds'] = proj_merged['rx1day'] >= proj_merged['hist_threshold']

t_first = int(proj_df['year'].min()) + window_len - 1  # first window end year
t_last  = int(proj_df['year'].max())
print(f'Sliding window: {window_len}-year trailing window, {t_first}\u2013{t_last}')

ts_records = []
for t in range(t_first, t_last + 1):
    window_data = proj_merged[
        (proj_merged['year'] >= t - window_len + 1) & (proj_merged['year'] <= t)
    ]
    pop_t       = pop_at_year(t)
    total_pop_t = pop_t.sum()

    for rcp in rcps:
        rcp_data = window_data[window_data['rcp'] == rcp]
        if rcp_data.empty:
            continue

        counts = (
            rcp_data.groupby(['member_id', 'lat', 'lon'])['exceeds']
            .sum()
            .reset_index(name='count')
        )
        counts['factor'] = counts['count'] * rp_exposure / window_len

        # Per-model exposure — threshold applied per model, then aggregate
        model_exposed = []
        for _mid, grp in counts.groupby('member_id'):
            cell_f  = grp.set_index(['lat', 'lon'])['factor']
            exp_idx = cell_f[cell_f >= factor_threshold].index
            exp_pop = pop_t.reindex(exp_idx).fillna(0).sum()
            model_exposed.append(float(exp_pop))

        # All summary statistics derived from the same per-model values so
        # the mean always lies within the p10–p90 band
        exposed_pop = float(np.mean(model_exposed))
        p10 = float(np.percentile(model_exposed, 10))
        p90 = float(np.percentile(model_exposed, 90))

        # Number of exposed cells: use the multi-model mean factor for a
        # representative spatial summary (not used for the timeseries lines)
        cell_mean   = counts.groupby(['lat', 'lon'])['factor'].mean()
        exposed_idx = cell_mean[cell_mean >= factor_threshold].index

        ts_records.append({
            'year':            t,
            'rcp':             rcp,
            'exposed_pop':     exposed_pop,
            'p10_exposed_pop': p10,
            'p90_exposed_pop': p90,
            'total_pop':       float(total_pop_t),
            'pct_exposed':     100 * exposed_pop / float(total_pop_t) if total_pop_t > 0 else 0,
            'pct_p10':         100 * p10 / float(total_pop_t) if total_pop_t > 0 else 0,
            'pct_p90':         100 * p90 / float(total_pop_t) if total_pop_t > 0 else 0,
            'n_exposed_cells': len(exposed_idx),
            'n_total_cells':   len(cell_mean),
        })

ts_df = pd.DataFrame(ts_records)
print(f'Done. {len(ts_df):,} rows ({ts_df["year"].nunique()} years \u00d7 {len(rcps)} RCPs)')
Sliding window: 30-year trailing window, 2035–2100
Done. 198 rows (66 years × 3 RCPs)

# Semi-transparent band colours matching rcp_colors (steelblue / darkorange / crimson)
rcp_band_colors = {
    'rcp_2_6': 'rgba(70,130,180,0.15)',
    'rcp_4_5': 'rgba(255,140,0,0.15)',
    'rcp_8_5': 'rgba(220,20,60,0.15)',
}

fig = make_subplots(
    rows=2, cols=1,
    shared_xaxes=True,
    subplot_titles=[
        f'People exposed to \u2265{factor_threshold}\u00d7 increase in {rp_exposure}-year event',
        '% of regional population exposed',
    ],
    vertical_spacing=0.12,
)

for rcp in rcps:
    rcp_ts     = ts_df[ts_df['rcp'] == rcp].sort_values('year')
    color      = rcp_colors[rcp]
    band_color = rcp_band_colors[rcp]
    label      = rcp_labels[rcp]

    # ---- absolute exposure (row 1) ----
    # 10th–90th percentile uncertainty band
    fig.add_trace(go.Scatter(
        x=rcp_ts['year'], y=rcp_ts['p10_exposed_pop'],
        mode='lines', line=dict(width=0), showlegend=False,
        hoverinfo='skip', legendgroup=rcp,
    ), row=1, col=1)
    fig.add_trace(go.Scatter(
        x=rcp_ts['year'], y=rcp_ts['p90_exposed_pop'],
        mode='lines', line=dict(width=0),
        fill='tonexty', fillcolor=band_color,
        name=f'{label} (10th–90th pct)', legendgroup=rcp, showlegend=True,
        hovertemplate=f'{label} p90<br>Year: %{{x}}<br>%{{y:,.0f}}<extra></extra>',
    ), row=1, col=1)
    # Ensemble mean line
    fig.add_trace(go.Scatter(
        x=rcp_ts['year'], y=rcp_ts['exposed_pop'],
        mode='lines', name=label, legendgroup=rcp, showlegend=False,
        line=dict(color=color, width=2.5),
        hovertemplate=f'{label} mean<br>Year: %{{x}}<br>%{{y:,.0f}}<extra></extra>',
    ), row=1, col=1)

    # ---- % exposed (row 2) ----
    fig.add_trace(go.Scatter(
        x=rcp_ts['year'], y=rcp_ts['pct_p10'],
        mode='lines', line=dict(width=0), showlegend=False,
        hoverinfo='skip', legendgroup=rcp,
    ), row=2, col=1)
    fig.add_trace(go.Scatter(
        x=rcp_ts['year'], y=rcp_ts['pct_p90'],
        mode='lines', line=dict(width=0),
        fill='tonexty', fillcolor=band_color,
        name=f'{label} (10th–90th pct)', legendgroup=rcp, showlegend=False,
        hovertemplate=f'{label} p90<br>Year: %{{x}}<br>%{{y:.1f}}%<extra></extra>',
    ), row=2, col=1)
    fig.add_trace(go.Scatter(
        x=rcp_ts['year'], y=rcp_ts['pct_exposed'],
        mode='lines', name=label, legendgroup=rcp, showlegend=False,
        line=dict(color=color, width=2.5),
        hovertemplate=f'{label} mean<br>Year: %{{x}}<br>%{{y:.1f}}%<extra></extra>',
    ), row=2, col=1)

# Total regional population (dotted grey reference)
pop_total_ts = ts_df[ts_df['rcp'] == rcps[0]].sort_values('year')
fig.add_trace(go.Scatter(
    x=pop_total_ts['year'], y=pop_total_ts['total_pop'],
    mode='lines', name='Total regional population',
    line=dict(color='lightgrey', width=1.5, dash='dot'),
    hovertemplate='Total pop (year %{x}): %{y:,.0f}<extra></extra>',
), row=1, col=1)

# Mark future period mid-years
for period_name, (pstart, pend) in future_periods.items():
    mid         = (pstart + pend) // 2
    label_short = period_name.split(' ')[0]
    for row in [1, 2]:
        fig.add_vline(
            x=mid, line_dash='dot', line_color='darkgrey', line_width=1,
            row=row, col=1,
        )
    fig.add_annotation(
        x=mid, y=1.04, text=label_short,
        showarrow=False, font=dict(size=9, color='darkgrey'),
        xref='x', yref='paper',
    )

fig.update_yaxes(title_text='People', showgrid=True, gridcolor='lightgrey', row=1, col=1)
fig.update_yaxes(title_text='%',      showgrid=True, gridcolor='lightgrey', row=2, col=1)
fig.update_xaxes(
    title_text='Window end year (trailing 30-year window)',
    showgrid=True, gridcolor='lightgrey',
    row=2, col=1,
)
fig.update_layout(
    title=dict(
        text=(
            f'Moving-window timeseries: population exposed to extreme precipitation increase<br>'
            f'<sup>30-year trailing window | {rp_exposure}-year event | '
            f'factor \u2265 {factor_threshold}\u00d7 | solid line = multi-model mean | '
            f'shaded band = 10th\u201390th percentile across ensemble members</sup>'
        ),
        font=dict(size=13),
    ),
    legend=dict(title='RCP scenario', x=1.01, y=0.99, xanchor='left', yanchor='top'),
    autosize=True, height=650,
)
fig.show(config={'displayModeBar': True, 'responsive': True})
Loading...

Figure: Timeseries of exposed population in the Molise region combining the changing extreme precipitation hazard with GHS-WUP population projections, shown as a 10-year rolling mean per RCP scenario.

The timeseries captures the interplay between two opposing trends:

  • Increasing hazard: the rising likelihood factor progressively brings more grid cells above the 1.5× threshold, particularly under RCP 8.5 from the mid-century onwards.

  • Declining population: Molise is projected to lose population throughout the century. This dampens the growth in absolute exposed numbers, and — if the population decline outpaces the expanding hazard footprint — can even cause absolute exposure to plateau or decrease despite a worsening hazard.

Summary

How exposure is estimated

This tutorial combines a hazard signal (here: the likelihood change factor for a historical extreme precipitation event) with gridded population projections to estimate how many people are exposed to a worsening hazard. The workflow proceeds in four steps:

  1. Reproduce the historical GEV-based return levels from the hazard tutorial (event thresholds).

  2. Regrid GHS-WUP-POP (1 km) onto the climate model grid (~12 km) by nearest-neighbour aggregation, preserving regional population totals.

  3. For each future scenario and time window, identify grid cells where the multi-model mean likelihood factor ≥ 1.5× and sum the co-located population.

  4. Slide a 30-year window year by year to produce a continuous timeseries of exposed population.

Key insights

  • Hazard and demographics interact. Absolute exposure depends on both the spatial extent of the hazard increase and the population at risk. In regions with strong demographic decline (such as Molise), absolute exposure can fall even as the hazard worsens.

  • Emission scenario matters most in the long term. Near-term exposure is similar across RCPs; the differences become pronounced only from mid-century onwards, underscoring the importance of long planning horizons.

  • The percentage exposed is a robust metric. Normalising by the regional population at each time step removes the confounding demographic signal and more directly reflects the changing hazard footprint.

  • Model uncertainty propagates into exposure. The multi-model mean was used here; a full uncertainty analysis would show the range of exposed population across ensemble members.

Limitations and extensions

(i) Fixed thresholds vs. changing baselines. The event thresholds are derived from a fixed historical baseline. A non-stationary analysis would refit the GEV to rolling historical windows and track how thresholds themselves shift.

(ii) Uniform vulnerability. Every person at an exposed grid cell is counted equally. In reality, vulnerability differs by age, income, and infrastructure quality. Incorporating vulnerability data would allow a more refined risk estimate.

(iii) Spatial resolution mismatch. Climate model grid cells (~12 km) capture area-averaged precipitation changes, not point-scale extremes. Downscaling or bias adjustment could sharpen the spatial gradient of hazard change.