--- title: "Spatial correlation modeling of environmental extremes with HazardMaps" author: "Maria Valentina Clavijo Mesa" date: "`r Sys.Date()`" output: rmarkdown::html_vignette: toc: true toc_depth: 2 vignette: > %\VignetteIndexEntry{Spatial correlation modeling of environmental extremes with HazardMaps} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4.5, fig.align = "center", message = FALSE, warning = FALSE ) ``` ## Overview **HazardMaps** provides a unified workflow for spatial dependence modeling of environmental extremes — from raw gridded datasets to spatially coherent pseudoscenarios. The package supports regular longitude-latitude grids (e.g. ERA5, Copernicus) and rotated curvilinear grids (e.g. EURO-CORDEX) without any change in the user interface.
**Example dataset.** This vignette uses daily maximum wind-gust speed over Northeast Italy as a running example. The workflow applies unchanged to any gridded environmental variable: precipitation, temperature, wave height, etc. The package was developed as part of the research presented in Clavijo Mesa et al. (2026a, 2026b), whose datasets are included as built-in example files. Users who need to prepare raw Copernicus or EURO-CORDEX downloads before using the package can find helper R scripts in `inst/scripts/`, accessible via `system.file("scripts", package = "HazardMaps")`.
Two datasets are used throughout: - **Copernicus** — regular longitude-latitude grid, ERA5-based, 195 cells, 1994–2021 - **EURO-CORDEX** — rotated curvilinear grid, RCM simulation, 1872 cells (1568 land), 1970–2005 The workflow has seven steps: 1. Ingest and prepare data 2. Select extreme-event days 3. Fit marginal distributions 4. Estimate spatial dependence 5. Fit copula and stochastic field models 6. Simulate pseudoscenarios 7. Validate with cluster-size statistics ```{r load, message = FALSE} library(HazardMaps) ``` --- ## Step 1 — Ingest and prepare data The starting point is a CF-convention NetCDF file. `hm_read_netcdf()` reads it and detects the grid type automatically — no configuration needed. ```{r ingest} wind_cop <- hm_read_netcdf( system.file("extdata", "Copernicus_dailymax_1994_2021.nc", package = "HazardMaps"), var = "i10fg" ) wind_euc <- hm_read_netcdf( system.file("extdata", "EuroCordex_wsgsmax_1970_2005.nc", package = "HazardMaps"), var = "wsgsmax" ) wind_cop$meta$grid_type wind_euc$meta$grid_type ``` `hm_standardize_coords()` flattens the coordinate arrays into a single data.frame with one row per grid cell — the format all downstream functions expect. `hm_decode_time()` converts CF-style time values (e.g. *"days since 1970-01-01"*) to R `Date` objects. ```{r standardize} wind_cop <- hm_standardize_coords(wind_cop) wind_cop <- hm_decode_time(wind_cop) wind_cop <- hm_get_grid_geometry(wind_cop) wind_euc <- hm_standardize_coords(wind_euc) wind_euc <- hm_decode_time(wind_euc) wind_euc <- hm_get_grid_geometry(wind_euc) cat("Copernicus: ", nrow(wind_cop$coords), "cells |", length(wind_cop$time), "days\n") cat("EURO-CORDEX:", nrow(wind_euc$coords), "cells |", length(wind_euc$time), "days\n") ``` `hm_plot_spatial_domain()` visualises the study area. It draws the correct cell geometry automatically — straight lines for regular grids, curvilinear lines for rotated grids. ```{r domain-cop, fig.cap = "Copernicus regular grid domain."} hm_plot_spatial_domain(wind_cop, dataset = "copernicus", title = "Copernicus domain", show_map = TRUE) ``` ```{r domain-euc, fig.cap = "EURO-CORDEX rotated grid domain."} hm_plot_spatial_domain(wind_euc, dataset = "eurocordex", title = "EURO-CORDEX domain", show_map = TRUE) ``` --- ## Step 2 — Select extreme-event days Only days with at least one grid cell at or above a threshold are kept. Everything below the threshold is noise for dependence modeling purposes. ```{r extreme-events} wind_cop_ext <- hm_select_extreme_events(wind_cop, threshold = 25) wind_euc_ext <- hm_select_extreme_events(wind_euc, threshold = 25) cat("Copernicus: ", wind_cop_ext$meta$filter$n_days_retained, "extreme-event days out of", wind_cop_ext$meta$filter$n_days_original, "\n") cat("EURO-CORDEX:", wind_euc_ext$meta$filter$n_days_retained, "extreme-event days out of", wind_euc_ext$meta$filter$n_days_original, "\n") ``` `hm_plot_exceedance()` maps how often each cell exceeded the threshold over the full period. ```{r exceedance-cop, fig.cap = "Exceedance count — Copernicus."} hm_plot_exceedance(wind_cop, threshold = 25, metric = "count", dataset = "copernicus", show_map = TRUE, title = "Wind-gust exceedance count — Copernicus") ``` ```{r exceedance-euc, fig.cap = "Exceedance count — EURO-CORDEX."} hm_plot_exceedance(wind_euc, threshold = 25, metric = "count", dataset = "eurocordex", show_map = TRUE, title = "Wind-gust exceedance count — EURO-CORDEX") ``` `hm_plot_exceedance_time()` shows whether the frequency of extreme events changes over the observation period. ```{r time-cop, fig.cap = "Extreme-event days per year — Copernicus."} hm_plot_exceedance_time(wind_cop, threshold = 25, by = "year", title = "Extreme-event days per year — Copernicus") ``` ```{r time-euc, fig.cap = "Extreme-event days per decade — EURO-CORDEX."} hm_plot_exceedance_time(wind_euc, threshold = 25, by = "decade", title = "Extreme-event days per decade — EURO-CORDEX") ``` --- ## Step 3 — Fit marginal distributions Before estimating spatial dependence, each cell needs its own marginal distribution. This separates the marginal behavior from the dependence structure — a standard approach in multivariate extreme-value analysis. `hm_to_matrix()` reshapes the data into a time-by-cell matrix. `hm_filter_land_cells()` removes columns with no valid data — mandatory for EURO-CORDEX, whose domain includes sea cells. ```{r matrix} obs_cop <- hm_to_matrix(wind_cop_ext) obs_euc <- hm_to_matrix(wind_euc_ext) obs_cop <- hm_filter_land_cells(obs_cop) obs_euc <- hm_filter_land_cells(obs_euc) cat("Copernicus matrix :", nrow(obs_cop), "days x", ncol(obs_cop), "cells\n") cat("EURO-CORDEX matrix:", nrow(obs_euc), "days x", ncol(obs_euc), "cells\n") ``` `hm_fit_marginals()` fits six candidate families to each cell by maximum likelihood and selects the best by BIC. The six families are: Weibull, Gamma, Lognormal, Gumbel, Zero-truncated Gaussian, and Zero-truncated Laplace. ```{r marginals, cache = TRUE} marginals_cop <- hm_fit_marginals(obs_cop, criterion = "bic") marginals_euc <- hm_fit_marginals(obs_euc, criterion = "bic") ``` ```{r marginals-summary} cat("Copernicus — best distribution per cell:\n") print(sort(table(marginals_cop$best_dist), decreasing = TRUE)) cat("\nEURO-CORDEX — best distribution per cell:\n") print(sort(table(marginals_euc$best_dist), decreasing = TRUE)) ```
**What to look for.** Weibull dominates in both datasets — this is expected for wind-gust extremes. The spatial map below shows which cells deviate from Weibull and why: coastal cells and Alpine valleys tend to favour Gamma or Zero-truncated Gaussian due to their different tail behavior.
```{r best-dist-cop, fig.cap = "Best-fitting distribution at each cell — Copernicus."} hm_plot_best_dist(wind_cop_ext, marginals_cop, title = "Best-fitting marginal — Copernicus", show_map = TRUE) ``` ```{r best-dist-euc, fig.cap = "Best-fitting distribution at each cell — EURO-CORDEX."} wind_euc_land <- wind_euc_ext wind_euc_land$coords <- attr(obs_euc, "coords") hm_plot_best_dist(wind_euc_land, marginals_euc, title = "Best-fitting marginal — EURO-CORDEX", show_map = TRUE) ``` ```{r dist-freq, fig.cap = "Frequency of best-fitting families across all cells."} hm_plot_dist_frequency(marginals_cop, title = "Distribution frequency — Copernicus") hm_plot_dist_frequency(marginals_euc, title = "Distribution frequency — EURO-CORDEX") ``` The Probability Integral Transform (PIT) converts each observation to the uniform scale using its fitted CDF, then to Gaussian scores via the probit transform. The Gaussian scores (`Z`) feed the spatial dependence estimation; the uniform values (`U`) feed the copula. ```{r pit} pit_cop <- hm_pit_transform(obs_cop, marginals_cop) pit_euc <- hm_pit_transform(obs_euc, marginals_euc) cat("Copernicus — cells transformed:", pit_cop$n_cells_ok, "| skipped:", pit_cop$n_cells_skipped, "\n") cat("EURO-CORDEX — cells transformed:", pit_euc$n_cells_ok, "| skipped:", pit_euc$n_cells_skipped, "\n") ``` --- ## Step 4 — Estimate spatial dependence The empirical spatial correlation is measured with Kendall's τ, which is rank-based and does not assume any distribution for the data. `hm_distance_matrix()` computes the great-circle distance in kilometres between every pair of cells. `hm_empirical_kendall()` estimates the pairwise Kendall correlation from the Gaussian scores.
**Computational note.** For large grids (> 500 cells), computing all N(N-1)/2 pairs is expensive. Use `cell_idx` to pass a representative subsample — as done here for EURO-CORDEX.
```{r kendall, cache = TRUE} distances_cop <- hm_distance_matrix(attr(obs_cop, "coords")) kendall_cop <- hm_empirical_kendall(pit_cop$Z) land_idx <- which(!is.na(marginals_euc$best_dist))[1:50] distances_euc <- hm_distance_matrix(attr(obs_euc, "coords")[land_idx, ]) kendall_euc <- hm_empirical_kendall(pit_euc$Z, cell_idx = land_idx) ``` `hm_empirical_correlogram()` bins the pairwise correlations by distance to produce a smooth summary of how correlation decays with separation. ```{r correlogram} corg_cop <- hm_empirical_correlogram(kendall_cop, distances_cop, n_bins = 20) corg_euc <- hm_empirical_correlogram(kendall_euc, distances_euc, n_bins = 15) ``` ```{r correlogram-plot, fig.cap = "Empirical spatial correlogram for both datasets."} hm_plot_correlogram(corg_cop, title = "Empirical correlogram — Copernicus") hm_plot_correlogram(corg_euc, title = "Empirical correlogram — EURO-CORDEX (50-cell subsample)") ```
**Interpretation.** Correlation is highest at short distances (~0.75–0.80) and decreases steadily with increasing separation, reaching near-zero beyond 250–300 km. This pattern confirms strong local spatial dependence and weak long-range dependence — consistent with synoptic-scale windstorm events that affect areas of 50–200 km.
--- ## Step 5 — Fit dependence models Two model families are available and compared. ### Copula models Vine copulas decompose the joint distribution of all cells into a cascade of bivariate copulas. Three structures are available: **C-vine** (one central node drives all others), **D-vine** (a chain structure), and **Gaussian** (elliptical baseline). ```{r copulas, cache = TRUE} uniform_small <- pit_cop$U[, 1:30] marginals_small <- marginals_cop[1:30, ] copula_cvine <- hm_fit_copula(uniform_small, type = "cvine") copula_dvine <- hm_fit_copula(uniform_small, type = "dvine") copula_gauss <- hm_fit_copula(uniform_small, type = "gaussian") cat("Cells:", copula_cvine$n_cells, "| Observations:", copula_cvine$n_obs, "\n") ``` ### Stochastic field model The stochastic field approach fits a parametric covariance function directly to the empirical correlogram, then generates realisations via the Karhunen-Loève Expansion (KLE). Four covariance families are compared automatically: **Matérn**, **Exponential**, **Gaussian**, and **Spherical**. ```{r sf-fit} sf_model_cop <- hm_fit_correlation_model(corg_cop) sf_model_euc <- hm_fit_correlation_model(corg_euc) cat("Copernicus — best:", sf_model_cop$best_family, "| RMSE:", round(sf_model_cop$best_rmse, 4), "\n") cat("EURO-CORDEX — best:", sf_model_euc$best_family, "| RMSE:", round(sf_model_euc$best_rmse, 4), "\n") cat("\nFamily comparison — Copernicus:\n") print(sf_model_cop$all_fits) cat("\nFitted parameters:\n") print(round(sf_model_cop$best_params, 4)) ```
**How the best family is chosen.** All four covariance families are fitted by weighted least squares to the empirical correlogram. The one with the lowest RMSE is selected automatically and stored in `model$best_family`. No manual choice is needed.
--- ## Step 6 — Simulate pseudoscenarios Each model generates a matrix of synthetic extreme-event fields. Every row is one spatially coherent scenario — a plausible simultaneous wind-gust intensity at all grid cells. ```{r sim-copula} scenarios_cvine <- hm_simulate_copula(copula_cvine, marginals_small, n_scenarios = 500L, seed = 42L) scenarios_dvine <- hm_simulate_copula(copula_dvine, marginals_small, n_scenarios = 500L, seed = 42L) scenarios_gauss <- hm_simulate_copula(copula_gauss, marginals_small, n_scenarios = 500L, seed = 42L) cat("Copula scenarios — dimensions:", dim(scenarios_cvine)[1], "x", dim(scenarios_cvine)[2], "\n") cat("Value range: [", round(min(scenarios_cvine, na.rm = TRUE), 2), ",", round(max(scenarios_cvine, na.rm = TRUE), 2), "] m/s\n") ``` ```{r sim-sf, cache = TRUE} scenarios_sf <- hm_simulate_sf_kle( sf_model_cop, distances_cop, marginals_cop, n_scenarios = 500L, var_retain = 0.95, seed = 42L ) cat("SF scenarios — dimensions:", dim(scenarios_sf)[1], "x", dim(scenarios_sf)[2], "\n") cat("KLE modes retained:", attr(scenarios_sf, "k_modes"), "| Variance explained:", round(attr(scenarios_sf, "var_explained"), 3), "\n") cat("Value range: [", round(min(scenarios_sf, na.rm = TRUE), 2), ",", round(max(scenarios_sf, na.rm = TRUE), 2), "] m/s\n") ``` --- ## Step 7 — Validate with cluster-size statistics A good dependence model should reproduce the **spatial footprint** of observed events — not just the marginal distribution of each cell in isolation. `hm_cluster_size()` counts, for each day, how many cells simultaneously exceeded the threshold. This gives a simple but powerful summary of the spatial extent of each event. ```{r cluster-obs} cluster_obs <- hm_cluster_size(obs_cop, threshold = 25, time = attr(obs_cop, "time")) cat("Observed spatial footprint (Copernicus):\n") print(summary(cluster_obs$cluster_size)) ``` The same statistic applied to the simulated scenarios reveals how well each model reproduces the observed footprint distribution. ```{r cluster-sim} cluster_cvine <- hm_cluster_size(scenarios_cvine, threshold = 25) cluster_dvine <- hm_cluster_size(scenarios_dvine, threshold = 25) cluster_gauss <- hm_cluster_size(scenarios_gauss, threshold = 25) cluster_sf <- hm_cluster_size(scenarios_sf, threshold = 25) footprint_table <- data.frame( Model = c("Observed", "C-vine", "D-vine", "Gaussian", "SF (Matérn)"), Mean = round(c(mean(cluster_obs$cluster_size), mean(cluster_cvine$cluster_size), mean(cluster_dvine$cluster_size), mean(cluster_gauss$cluster_size), mean(cluster_sf$cluster_size)), 2), Median = round(c(median(cluster_obs$cluster_size), median(cluster_cvine$cluster_size), median(cluster_dvine$cluster_size), median(cluster_gauss$cluster_size), median(cluster_sf$cluster_size)), 2), Max = c(max(cluster_obs$cluster_size), max(cluster_cvine$cluster_size), max(cluster_dvine$cluster_size), max(cluster_gauss$cluster_size), max(cluster_sf$cluster_size)) ) print(footprint_table, row.names = FALSE) ``` ```{r cluster-plot, fig.height = 5, fig.cap = "Empirical CDF of spatial footprint for observed data and four models."} all_clusters <- rbind( data.frame(model = "Observed", size = cluster_obs$cluster_size), data.frame(model = "C-vine", size = cluster_cvine$cluster_size), data.frame(model = "D-vine", size = cluster_dvine$cluster_size), data.frame(model = "Gaussian", size = cluster_gauss$cluster_size), data.frame(model = "SF (Matérn)", size = cluster_sf$cluster_size) ) all_clusters$model <- factor( all_clusters$model, levels = c("Observed", "C-vine", "D-vine", "Gaussian", "SF (Matérn)") ) ggplot2::ggplot(all_clusters, ggplot2::aes(x = size, colour = model)) + ggplot2::stat_ecdf(linewidth = 0.9) + ggplot2::scale_colour_manual( values = c("Observed" = "black", "C-vine" = "#2166AC", "D-vine" = "#4DAF4A", "Gaussian" = "#FF7F00", "SF (Matérn)" = "#E41A1C"), name = "Model" ) + ggplot2::labs( title = "Spatial footprint validation — Copernicus", x = "Number of cells \u2265 25 m/s", y = "Empirical CDF" ) + ggplot2::theme_minimal(base_size = 12) + ggplot2::theme(plot.title = ggplot2::element_text(hjust = 0.5)) ```
**How to read this plot.** A model whose CDF curve lies close to the black "Observed" curve reproduces the spatial footprint well. A curve to the left means the model generates events that are too localised; a curve to the right means events that are too widespread. Use this plot to choose the model that best captures the dependence structure for your application.
--- ## Summary ```{r summary-table, echo = FALSE} summary_df <- data.frame( Step = paste0("Step ", 1:7), Goal = c("Read and standardize a gridded dataset", "Isolate extreme-event days", "Fit a marginal distribution to each cell", "Quantify spatial correlation vs distance", "Fit copula or stochastic field model", "Generate spatially coherent scenarios", "Compare models against observed footprint"), `Key functions` = c( "`hm_read_netcdf`, `hm_standardize_coords`, `hm_decode_time`", "`hm_select_extreme_events`, `hm_to_matrix`, `hm_filter_land_cells`", "`hm_fit_marginals`, `hm_pit_transform`", "`hm_distance_matrix`, `hm_empirical_kendall`, `hm_empirical_correlogram`", "`hm_fit_copula`, `hm_fit_correlation_model`", "`hm_simulate_copula`, `hm_simulate_sf_kle`", "`hm_cluster_size`" ), check.names = FALSE ) knitr::kable(summary_df, align = "clll") ``` --- ## References Clavijo Mesa, M.V., Di Maio, F. & Zio, E. (2026a). A modeling framework for the inoperability assessment of interdependent critical infrastructures exposed to spatially distributed natural hazards. *Reliability Engineering & System Safety*, 275, 112830. Clavijo Mesa, M.V., Broggi, M., Di Maio, F. & Zio, E. (2026b). Inoperability assessment of interdependent critical infrastructures exposed to natural hazards considering climate change. *International Journal of Disaster Risk Reduction*, 141, 106172.