The Aggregation Matrix: Every Spatial x Temporal Combination

library(xaci)

calculate_aci() exposes two independent choices about how its output is aggregated:

Because these two axes are resolved independently — the underlying grid-cell computation feeds all of them, and granularity is applied only at the very end — every combination of the three spatial modes and four granularities is valid. This vignette builds one small synthetic dataset, computes its components once, and then walks the full 3 x 4 matrix, showing what each combination looks like.

Building one small synthetic dataset

Same pattern as vignette("xaci-full-pipeline"): a tiny 2 x 2 ERA5-like grid, plus two synthetic PSMSL tide-gauge stations.

build_synthetic_nc <- function(path, var, unit, lon, lat, time_vec, origin, vals) {
  time_hours <- as.numeric(difftime(time_vec, origin, units = "hours"))
  dim_lon  <- ncdf4::ncdim_def("longitude", "degrees_east", lon)
  dim_lat  <- ncdf4::ncdim_def("latitude", "degrees_north", lat)
  dim_time <- ncdf4::ncdim_def(
    "time", paste0("hours since ", format(origin, "%Y-%m-%d %H:%M:%S")),
    time_hours, unlim = TRUE
  )
  ncvar <- ncdf4::ncvar_def(var, unit, list(dim_lon, dim_lat, dim_time),
                            missval = NA, prec = "double")
  nc <- ncdf4::nc_create(path, list(ncvar))
  ncdf4::ncvar_put(nc, ncvar, vals)
  ncdf4::nc_close(nc)
  invisible(path)
}

build_synthetic_mask <- function(path, lon, lat) {
  dim_lon <- ncdf4::ncdim_def("longitude", "degrees_east", lon)
  dim_lat <- ncdf4::ncdim_def("latitude", "degrees_north", lat)
  var_mask <- ncdf4::ncvar_def("country", "1", list(dim_lon, dim_lat),
                               missval = NA, prec = "double")
  nc <- ncdf4::nc_create(path, list(var_mask))
  ncdf4::ncvar_put(nc, var_mask, matrix(1, length(lon), length(lat)))
  ncdf4::nc_close(nc)
  invisible(path)
}

set.seed(11)
lon      <- c(-1, 0)
lat      <- c(43, 44)
origin   <- as.POSIXct("1900-01-01 00:00:00", tz = "UTC")
time_vec <- seq(as.POSIXct("2011-01-01 00:00", tz = "UTC"),
                as.POSIXct("2014-12-31 23:00", tz = "UTC"), by = "hour")
nlo <- length(lon); nla <- length(lat); nt <- length(time_vec)

trend      <- seq_len(nt) / nt   # mild warming/drying trend
seasonal_t <- 288 + 10 * sin(2 * pi * seq_len(nt) / (24 * 365)) + 0.6 * trend

t2m_vals <- array(NA_real_, c(nlo, nla, nt))
for (i in seq_len(nlo)) for (j in seq_len(nla))
  t2m_vals[i, j, ] <- seasonal_t + (i + j) + rnorm(nt, sd = 1.5)

tp_vals <- array(0, c(nlo, nla, nt))
for (i in seq_len(nlo)) for (j in seq_len(nla)) {
  rain_hours <- rbinom(nt, 1, 0.08 * (1 - 0.3 * trend))
  tp_vals[i, j, ] <- rain_hours * rexp(nt, rate = 800)
}

u10_vals <- array(rnorm(nlo * nla * nt, mean = 3, sd = 4), c(nlo, nla, nt))
v10_vals <- array(rnorm(nlo * nla * nt, mean = 1, sd = 4), c(nlo, nla, nt))

t2m_file  <- tempfile(fileext = ".nc")
tp_file   <- tempfile(fileext = ".nc")
u10_file  <- tempfile(fileext = ".nc")
v10_file  <- tempfile(fileext = ".nc")
mask_file <- tempfile(fileext = ".nc")

build_synthetic_nc(t2m_file, "t2m", "K",     lon, lat, time_vec, origin, t2m_vals)
build_synthetic_nc(tp_file,  "tp",  "m",     lon, lat, time_vec, origin, tp_vals)
build_synthetic_nc(u10_file, "u10", "m s-1", lon, lat, time_vec, origin, u10_vals)
build_synthetic_nc(v10_file, "v10", "m s-1", lon, lat, time_vec, origin, v10_vals)
build_synthetic_mask(mask_file, lon, lat)

month_frac <- c("0417", "125", "2083", "2917", "375", "4583",
                "5417", "625", "7083", "7917", "875", "9583")
build_synthetic_psmsl_station <- function(dir, station_id, years,
                                          base_level, trend_mm_per_year) {
  lines <- character(0)
  for (y in years) {
    for (m in seq_len(12)) {
      level <- base_level + trend_mm_per_year * (y - years[1]) + rnorm(1, sd = 15)
      lines <- c(lines, sprintf("%d.%s;%.1f;0;000", y, month_frac[m], level))
    }
  }
  writeLines(lines, file.path(dir, paste0(station_id, ".txt")))
}

psmsl_dir <- tempfile("psmsl_")
dir.create(psmsl_dir)
build_synthetic_psmsl_station(psmsl_dir, 1,  2011:2014, base_level = 7020, trend_mm_per_year = 3)
build_synthetic_psmsl_station(psmsl_dir, 61, 2011:2014, base_level = 6980, trend_mm_per_year = 4)

# See vignette("xaci-components") for why reference_period needs >= 3 years.
reference_period <- c("2011-01-01", "2013-12-31")  # 3 years
study_period      <- c("2011-01-01", "2014-12-31")  # 4 years

cache_dir <- tempfile("aci_cache_")
dir.create(cache_dir)

Step 1 — compute every component once

The six components (t90, t10, precipitation, drought, wind, sealevel) are computed from the raw ERA5/PSMSL data before any spatial or temporal aggregation is applied. Caching them with save = TRUE lets every combination explored below reuse the exact same computation — this is the same save / computed_components pattern shown in vignette("xaci-components"), just used here across the whole matrix instead of a single re-run:

invisible(calculate_aci(
  country_abbrev           = "FRA",
  study_period               = study_period,
  reference_period            = reference_period,
  temperature_data_path      = t2m_file,
  precipitation_data_path    = tp_file,
  wind_u10_data_path          = u10_file,
  wind_v10_data_path          = v10_file,
  mask_data_path               = mask_file,
  sealevel_dir                  = psmsl_dir,
  granularity                    = "month",   # irrelevant here: not cached
  area                            = TRUE,       # irrelevant here: not cached
  admin_level                     = NULL,
  save                             = TRUE,
  save_dir                         = cache_dir,
  computed_components              = FALSE
))

Note the two comments above: granularity and area/admin_level are not part of what gets cached at this step — only the raw, unstandardised components are (see ?calculate_aci, “computed_components” section). Every call below reuses this same cache via computed_components = TRUE, and can freely vary spatial mode and granularity without recomputing anything from the NetCDF/PSMSL files.

Pre-seeding the administrative cache (no network)

Administrative aggregation normally needs build_admin_mask() / assign_sealevel_to_admin(), which download real GADM polygons and a coastline layer over the network (see vignette("xaci-admin-levels")). To demonstrate the administrative spatial mode here without network access, we save hand-built admin_mask / admin_assignment objects directly at the cache path calculate_aci() expects — the exact same trick vignette("xaci-admin-levels") uses at the component level, just applied one level up so calculate_aci() itself can be called directly:

admin_level <- 1

synthetic_admin_mask <- list(
  units   = c("A", "B"),
  lon     = lon,
  lat     = lat,
  weights = list(
    `1` = c(A = 1, B = 0),   # cell (lon[1], lat[1])
    `2` = c(A = 0, B = 1),   # cell (lon[1], lat[2])
    `3` = c(A = 1, B = 0),   # cell (lon[2], lat[1])
    `4` = c(A = 0, B = 1)    # cell (lon[2], lat[2])
  )
)
synthetic_admin_assignment <- list(
  station_ids = list(A = 1, B = 61),   # PSMSL IDs: Brest -> A, Marseille -> B
  factors     = c(A = 0.6, B = 0.3)    # coastal fraction per region
)

admin_tag <- sprintf("FRA_L%d", admin_level)
saveRDS(synthetic_admin_mask,
       file.path(cache_dir, paste0("admin_mask_", admin_tag, ".rds")))
saveRDS(synthetic_admin_assignment,
       file.path(cache_dir, paste0("admin_assignment_", admin_tag, ".rds")))

With both .rds files in place, any calculate_aci(..., admin_level = 1, computed_components = TRUE) call below will pick them up directly instead of trying to build them — no network call, no geodata/rnaturalearth lookup.

The three spatial modes, one temporal granularity

Fixing granularity = "month", here is what each spatial mode returns:

national_monthly <- calculate_aci(
  country_abbrev = "FRA", study_period = study_period, reference_period = reference_period,
  temperature_data_path      = t2m_file,
  precipitation_data_path    = tp_file,
  wind_u10_data_path          = u10_file,
  wind_v10_data_path          = v10_file,
  mask_data_path               = mask_file,
  granularity = "month", area = TRUE, admin_level = NULL,
  computed_components = TRUE, load_dir = cache_dir
)
class(national_monthly); dim(national_monthly)
#> [1] "data.frame"
#> [1] 48  7
head(national_monthly, 2)
#>            drought wind precipitation       t10        t90   sealevel        ACI
#> 2011-01 -0.9954027    0     0.9008374 0.9203157 -0.8431515  0.6340295 -0.3329282
#> 2011-02 -1.0492241    0     0.9040871 0.9443331 -1.0830902 -1.1163822 -0.4607378
grid_monthly <- calculate_aci(
  country_abbrev = "FRA", study_period = study_period, reference_period = reference_period,
  temperature_data_path      = t2m_file,
  precipitation_data_path    = tp_file,
  wind_u10_data_path          = u10_file,
  wind_v10_data_path          = v10_file,
  mask_data_path               = mask_file,
  granularity = "month", area = FALSE, admin_level = NULL, max_dist_km = 800,
  computed_components = TRUE, load_dir = cache_dir
)
class(grid_monthly); dim(grid_monthly$ACI)   # [lon x lat x time]
#> [1] "list"
#> [1]  2  2 48
admin_monthly <- calculate_aci(
  country_abbrev = "FRA", study_period = study_period, reference_period = reference_period,
  temperature_data_path      = t2m_file,
  precipitation_data_path    = tp_file,
  wind_u10_data_path          = u10_file,
  wind_v10_data_path          = v10_file,
  mask_data_path               = mask_file,
  granularity = "month", admin_level = admin_level,
  computed_components = TRUE, load_dir = cache_dir, save_dir = cache_dir
)
class(admin_monthly); dim(admin_monthly)
#> [1] "data.frame"
#> [1] 48 14
head(admin_monthly[, c("ACI_A", "ACI_B")], 2)
#>              ACI_A      ACI_B
#> 2011-01  0.4551911 -0.7979944
#> 2011-02 -0.3902822 -0.4622599

Same underlying components, three genuinely different shapes: a data.frame with one row per month nationally, a [lon x lat x time] array list on the raw grid, and a data.frame with one <component>_<unit> column pair per administrative unit.

The four granularities, one spatial mode

Fixing the national scalar mode (area = TRUE), here is the same computation reported at each temporal granularity:

granularities <- c("month", "season", "semester", "year")
national_by_granularity <- lapply(granularities, function(g) {
  calculate_aci(
    country_abbrev = "FRA", study_period = study_period, reference_period = reference_period,
    temperature_data_path            = t2m_file,
    precipitation_data_path        = tp_file,
    wind_u10_data_path                    = u10_file,
    wind_v10_data_path                    = v10_file,
    mask_data_path                             = mask_file,
    granularity = g, area = TRUE, admin_level = NULL,
    computed_components = TRUE, load_dir = cache_dir
  )
})
names(national_by_granularity) <- granularities

sapply(national_by_granularity, nrow)   # number of periods per granularity
#>    month   season semester     year 
#>       48       17        8        4
national_by_granularity$semester
#>            drought      wind precipitation         t10        t90   sealevel        ACI
#> 2011-S1 -1.0726834 0.0000000    0.71191686  0.97759636 -1.0646249 -0.2435785 -0.4714814
#> 2011-S2 -0.2508652 0.0000000    0.12721814  0.98284998 -1.0032526 -0.1374611 -0.4110081
#> 2012-S1  0.6393322 0.0000000   -0.01142285 -0.10235882  0.2376682  0.2216346  0.1946660
#> 2012-S2 -0.8133445 0.0000000   -0.24741900 -0.01973684  0.1508050  0.3067992 -0.1593965
#> 2013-S1  0.4333512 0.0000000   -0.70049401 -0.87523753  0.8269567  0.0219439  0.2768154
#> 2013-S2  1.0642097 0.0000000    0.12020086 -0.96311313  0.8524477 -0.1693381  0.5704046
#> 2014-S1  2.8006632 0.2327829   -1.00193126 -1.81931870  1.8426589  0.4725578  1.1130777
#> 2014-S2  1.8220896 0.1969534    0.10514547 -2.23259299  2.5165369  1.3834795  1.3750027

The full matrix

Putting both axes together: 3 spatial modes x 4 granularities = 12 calls, all reusing the same cache from Step 1:

spatial_modes <- list(
  national = list(area = TRUE,  admin_level = NULL),
  grid     = list(area = FALSE, admin_level = NULL),
  admin    = list(area = TRUE,  admin_level = admin_level)  # area ignored when admin_level is set
)

matrix_results <- list()
for (mode_name in names(spatial_modes)) {
  mode <- spatial_modes[[mode_name]]
  for (g in granularities) {
    key <- paste(mode_name, g, sep = "_")
    matrix_results[[key]] <- calculate_aci(
      country_abbrev   = "FRA", study_period = study_period, reference_period = reference_period,
      temperature_data_path                  = t2m_file,
      precipitation_data_path            = tp_file,
      wind_u10_data_path                              = u10_file,
      wind_v10_data_path                              = v10_file,
      mask_data_path                                           = mask_file,
      granularity      = g, area = mode$area, admin_level = mode$admin_level,
      max_dist_km      = 800,
      computed_components = TRUE, load_dir = cache_dir, save_dir = cache_dir
    )
  }
}

summarise_result <- function(res) {
  if (is.data.frame(res)) {
    data.frame(class = "data.frame", n_periods = nrow(res), n_columns = ncol(res))
  } else {
    data.frame(class = "grid list",
              n_periods = length(res$time),
              n_columns = paste(dim(res$ACI)[1:2], collapse = " x "))
  }
}

overview <- do.call(rbind, lapply(names(matrix_results), function(key) {
  parts <- strsplit(key, "_")[[1]]
  cbind(spatial = parts[1], granularity = parts[2], summarise_result(matrix_results[[key]]))
}))
rownames(overview) <- NULL
overview
#>     spatial granularity      class n_periods n_columns
#> 1  national       month data.frame        48         7
#> 2  national      season data.frame        17         7
#> 3  national    semester data.frame         8         7
#> 4  national        year data.frame         4         7
#> 5      grid       month  grid list        48     2 x 2
#> 6      grid      season  grid list        17     2 x 2
#> 7      grid    semester  grid list         8     2 x 2
#> 8      grid        year  grid list         4     2 x 2
#> 9     admin       month data.frame        48        14
#> 10    admin      season data.frame        17        14
#> 11    admin    semester data.frame         8        14
#> 12    admin        year data.frame         4        14

Twelve calls, twelve distinct shapes, all derived from the same six cached components — only the last, cheap aggregation step differs between them.

Mapping one of the grid-cell combinations

As a concrete illustration, plot_aci_map() (see vignette("xaci-visualization")) works directly on any grid-cell entry of the matrix above, whatever its granularity:

plot_aci_map(matrix_results$grid_season, variable = "ACI", time_index = "mean",
            borders = FALSE, title = "Mean seasonal ACI (grid-cell mode)")
Map of the mean seasonal ACI over the synthetic 2x2 grid

plot of chunk unnamed-chunk-10

Wrap-up