FLUXNET Demo Plots (with Global Maps)

Authors

Dave Moore

Eric R. Scott

1 Setup

Code
library(here)
library(dplyr)
library(tidyr)
library(ggplot2)
library(readr)
library(purrr)
library(stringr)
library(units)
library(ggnewscale)

# Extra packages needed for Eric's maps
suppressPackageStartupMessages({
  library(sf)
  library(ggtext)
  library(patchwork)
  library(colorspace)
  library(cols4all)
  library(rnaturalearth)
})

set.seed(123)

if (!params$use_terra) {
  suppressPackageStartupMessages(library(raster))
} else {
  suppressPackageStartupMessages(library(terra))
}

library(plotbiomes)

# Prevent namespace conflicts with 'raster'
select <- dplyr::select
filter <- dplyr::filter
lag    <- dplyr::lag

# Project helpers
source("R/fcn_utility_FLUXNET.R")
source("R/fcn_plot_FLUXNET.R")

# Figure dir + helper
fig_dir <- here::here("figs")
if (!dir.exists(fig_dir)) dir.create(fig_dir, recursive = TRUE)

save_if <- function(p, name, width = 7, height = 5, dpi = 300) {
  if (isTRUE(params$save_figs)) {
    ggsave(file.path(fig_dir, paste0(name, ".png")), p, width = width, height = height, dpi = dpi)
  }
  p
}

2 Load metadata

Code
metadata <- load_fluxnet_metadata()

# Ensure 'site' column exists for joins
if (!"site" %in% names(metadata)) {
  if ("SITE_ID" %in% names(metadata)) metadata <- dplyr::rename(metadata, site = SITE_ID)
  if ("SITEID"  %in% names(metadata)) metadata <- dplyr::rename(metadata, site = SITEID)
}

md_join <- metadata %>% dplyr::select(-any_of(c("SITE_ID","SITEID")))

3 Discover files and build manifest

Code
amf_files  <- discover_AMF_files(data_dir = here::here(params$data_dir_amf))
• DD / ERA5 → 246 sites, 14121 site-years across 328 files
• DD / FULLSET → 246 sites, 1931 site-years across 328 files
• DD / SUBSET → 1 sites, 17 site-years across 1 files
• HH / ERA5 → 242 sites, 13915 site-years across 323 files
• HH / FULLSET → 242 sites, 1845 site-years across 323 files
• HH / SUBSET → 1 sites, 17 site-years across 1 files
• HR / ERA5 → 4 sites, 206 site-years across 5 files
• HR / FULLSET → 4 sites, 86 site-years across 5 files
• MM / ERA5 → 246 sites, 14121 site-years across 328 files
• MM / FULLSET → 246 sites, 1931 site-years across 328 files
• MM / SUBSET → 1 sites, 17 site-years across 1 files
• WW / ERA5 → 246 sites, 14121 site-years across 328 files
• WW / FULLSET → 246 sites, 1931 site-years across 328 files
• WW / SUBSET → 1 sites, 17 site-years across 1 files
• YY / ERA5 → 246 sites, 14121 site-years across 328 files
• YY / FULLSET → 246 sites, 1931 site-years across 328 files
• YY / SUBSET → 1 sites, 17 site-years across 1 files
Code
icos_files <- discover_ICOS_files(data_dir = here::here(params$data_dir_icos))
• AUXMETEO / L2 → 70 sites, 0 total site-years across 70 files
• AUXNEE / L2 → 70 sites, 0 total site-years across 70 files
• DD / FULLSET → 149 sites, 1071 total site-years across 149 files
• DD / L2 → 70 sites, 0 total site-years across 70 files
• HH / FULLSET → 141 sites, 961 total site-years across 141 files
• HH / L2 → 70 sites, 0 total site-years across 70 files
• MM / L2 → 70 sites, 0 total site-years across 70 files
• WW / FULLSET → 149 sites, 1071 total site-years across 149 files
• WW / L2 → 70 sites, 0 total site-years across 70 files
• YY / FULLSET → 149 sites, 1071 total site-years across 149 files
• YY / L2 → 70 sites, 0 total site-years across 70 files
• YY / SUBSET → 206 sites, 1493 total site-years across 206 files
• YY_INTERIM / L2 → 39 sites, 0 total site-years across 39 files
Code
manifest <- dplyr::bind_rows(amf_files, icos_files) %>%
  dplyr::distinct(site, data_product, dataset, time_integral, start_year, end_year, .keep_all = TRUE)

manifest %>% dplyr::count(dataset, time_integral)
# A tibble: 25 × 3
   dataset time_integral     n
   <chr>   <chr>         <int>
 1 ERA5    DD              328
 2 ERA5    HH              323
 3 ERA5    HR                5
 4 ERA5    MM              328
 5 ERA5    WW              328
 6 ERA5    YY              328
 7 FULLSET DD              432
 8 FULLSET HH              420
 9 FULLSET HR                4
10 FULLSET MM              283
# ℹ 15 more rows

4 Load annual FULLSET and join metadata

Code
annual_data <- manifest %>%
  dplyr::filter(time_integral == "YY", dataset == "FULLSET") %>%
  load_fluxnet_data() %>%
  dplyr::mutate(across(where(is.numeric), ~na_if(.x, -9999))) %>%
  dplyr::mutate(year = as.integer(TIMESTAMP), .before = TIMESTAMP) %>%
  dplyr::left_join(md_join, by = "site")

unique(annual_data$site)
  [1] "AR-Bal" "AR-CCa" "AR-CCg" "AR-TF1" "AR-TF2" "BR-CST" "BR-Npw" "CA-ARB"
  [9] "CA-ARF" "CA-BOU" "CA-Gro" "CA-HPC" "CA-LP1" "CA-MA1" "CA-MA2" "CA-MA3"
 [17] "CA-Man" "CA-NS1" "CA-NS2" "CA-NS3" "CA-NS4" "CA-NS5" "CA-NS6" "CA-NS7"
 [25] "CA-Qc2" "CA-Qfo" "CA-SCB" "CA-SCC" "CA-SF1" "CA-SF2" "CA-SMC" "MX-Tes"
 [33] "US-A32" "US-A74" "US-ALQ" "US-AR1" "US-AR2" "US-ARM" "US-ARb" "US-ARc"
 [41] "US-ASH" "US-ASM" "US-Akn" "US-BRG" "US-BZB" "US-BZF" "US-BZS" "US-BZo"
 [49] "US-Bar" "US-Bi1" "US-Bi2" "US-CAK" "US-CF1" "US-CF2" "US-CF3" "US-CF4"
 [57] "US-CGG" "US-CRK" "US-CRT" "US-CS1" "US-CS2" "US-CS3" "US-CS4" "US-CS5"
 [65] "US-CS6" "US-CS8" "US-CdM" "US-Cms" "US-Cop" "US-Cst" "US-DFC" "US-DFK"
 [73] "US-DPW" "US-DS1" "US-DS2" "US-DS3" "US-Dmg" "US-EA5" "US-EA6" "US-EDN"
 [81] "US-EML" "US-Elm" "US-Esm" "US-EvM" "US-Fcr" "US-Fmf" "US-Fuf" "US-Fwf"
 [89] "US-GLE" "US-HB1" "US-HB2" "US-HB3" "US-HB4" "US-HVs" "US-HWB" "US-Ha1"
 [97] "US-Hn2" "US-Hn3" "US-Ho2" "US-Hsm" "US-ICh" "US-ICs" "US-ICt" "US-Jo1"
[105] "US-Jo2" "US-KFS" "US-KLS" "US-KPL" "US-KS1" "US-KS2" "US-KS3" "US-Kon"
[113] "US-LA3" "US-LS2" "US-Lin" "US-MMS" "US-MOz" "US-Me1" "US-Me2" "US-Me3"
[121] "US-Me6" "US-Mo1" "US-Mo2" "US-Mo3" "US-Mpj" "US-Myb" "US-NC1" "US-NGC"
[129] "US-Ne1" "US-ONA" "US-ORv" "US-OWC" "US-Oho" "US-PAS" "US-PFL" "US-PFb"
[137] "US-PFc" "US-PFd" "US-PFe" "US-PFg" "US-PFh" "US-PFi" "US-PFj" "US-PFk"
[145] "US-PFm" "US-PFn" "US-PFp" "US-PFq" "US-PFr" "US-PFt" "US-PSH" "US-PSL"
[153] "US-Pnp" "US-Prr" "US-RGA" "US-RGB" "US-RGF" "US-RGW" "US-RGo" "US-RRC"
[161] "US-Rms" "US-Ro1" "US-Ro2" "US-Ro3" "US-Ro4" "US-Ro5" "US-Ro6" "US-Rwe"
[169] "US-Rwf" "US-Rws" "US-SP1" "US-SRC" "US-SRG" "US-SRM" "US-SRS" "US-SSH"
[177] "US-Sag" "US-Seg" "US-Ses" "US-Sne" "US-Snf" "US-Srr" "US-StJ" "US-Tw1"
[185] "US-Tw3" "US-Tw4" "US-UC1" "US-UC2" "US-UM3" "US-UTB" "US-Var" "US-Vcm"
[193] "US-Vcp" "US-WPT" "US-Whs" "US-Wi0" "US-Wi1" "US-Wi3" "US-Wi4" "US-Wi5"
[201] "US-Wi6" "US-Wi7" "US-Wi8" "US-Wi9" "US-Wjs" "US-Wkg" "US-YK2" "US-xAB"
[209] "US-xAE" "US-xBA" "US-xBL" "US-xBN" "US-xBR" "US-xCL" "US-xCP" "US-xDC"
[217] "US-xDJ" "US-xDL" "US-xDS" "US-xGR" "US-xHA" "US-xHE" "US-xJE" "US-xJR"
[225] "US-xKA" "US-xKZ" "US-xMB" "US-xML" "US-xNG" "US-xNQ" "US-xRM" "US-xSB"
[233] "US-xSC" "US-xSE" "US-xSJ" "US-xSL" "US-xSR" "US-xST" "US-xTA" "US-xTL"
[241] "US-xTR" "US-xUK" "US-xUN" "US-xWD" "US-xYE" "AR-SLu" "AR-Vir" "AT-Neu"
[249] "AU-ASM" "AU-Ade" "AU-Cpr" "AU-Cum" "AU-DaP" "AU-DaS" "AU-Dry" "AU-Emr"
[257] "AU-Fog" "AU-GWW" "AU-Gin" "AU-How" "AU-Lox" "AU-RDF" "AU-Rig" "AU-Rob"
[265] "AU-Stp" "AU-TTE" "AU-Tum" "AU-Wac" "AU-Whr" "AU-Wom" "BE-Bra" "BE-Lon"
[273] "BE-Vie" "BR-Sa1" "BR-Sa3" "CA-SF3" "CA-TP1" "CA-TP2" "CA-TP3" "CA-TP4"
[281] "CA-TPD" "CG-Tch" "CH-Cha" "CH-Dav" "CH-Fru" "CH-Oe2" "CN-Dan" "CN-Du2"
[289] "CN-Ha2" "CN-HaM" "CN-Qia" "CZ-BK1" "CZ-BK2" "CZ-wet" "DE-Akm" "DE-Geb"
[297] "DE-Gri" "DE-Hai" "DE-Kli" "DE-Lkb" "DE-Obe" "DE-RuS" "DE-Seh" "DE-SfN"
[305] "DE-Tha" "DE-Zrk" "DK-Eng" "DK-Fou" "DK-Sor" "ES-Amo" "ES-LgS" "FI-Hyy"
[313] "FI-Jok" "FI-Let" "FI-Sod" "GF-Guy" "GH-Ank" "GL-NuF" "GL-ZaH" "IT-Col"
[321] "IT-Cp2" "IT-Isp" "IT-La2" "IT-Lav" "IT-Ren" "IT-Ro1" "IT-Ro2" "IT-SR2"
[329] "IT-SRo" "IT-Tor" "JP-MBF" "PA-SPn" "RU-Che" "RU-Cok" "RU-Ha1" "SD-Dem"
[337] "SJ-Adv" "US-Blo" "US-GBT" "US-Goo" "US-IB2" "US-LWW" "US-Me4" "US-Me5"
[345] "US-NR1" "US-Ne3" "US-Sta" "US-Syv" "US-Ton" "US-Tw2" "US-Twt" "US-UMB"
[353] "US-UMd" "US-WCr" "US-Wi2" "ZM-Mon"

5 Global maps (Eric)

Code
# Standardize flux column names to a common set so the map code is agnostic
pick_first <- function(cands, nms) {
  hit <- cands[cands %in% nms]
  if (length(hit) == 0) NA_character_ else hit[1]
}

nm <- names(annual_data)
col_NEE  <- pick_first(c("NEE_VUT_MEAN",   "NEE_VUT_REF"),    nm)
col_GPP  <- pick_first(c("GPP_NT_VUT_MEAN","GPP_NT_VUT_REF"), nm)
col_RECO <- pick_first(c("RECO_NT_VUT_MEAN","RECO_NT_VUT_REF"), nm)

missing <- c(
  if (is.na(col_NEE))  "NEE (VUT_MEAN or VUT_REF)"  else NULL,
  if (is.na(col_GPP))  "GPP (NT_VUT_MEAN or NT_VUT_REF)" else NULL,
  if (is.na(col_RECO)) "RECO (NT_VUT_MEAN or NT_VUT_REF)" else NULL
)
if (length(missing) > 0) {
  stop("Missing required flux columns in annual_data: ", paste(missing, collapse = ", "))
}

annual_std <- annual_data %>%
  dplyr::rename(
    NEE_VUT_MEAN     = dplyr::all_of(col_NEE),
    GPP_NT_VUT_MEAN  = dplyr::all_of(col_GPP),
    RECO_NT_VUT_MEAN = dplyr::all_of(col_RECO)
  )

vars_of_interest <- c("GPP_NT_VUT_MEAN","NEE_VUT_MEAN","RECO_NT_VUT_MEAN")

5.1 Site map by record length

Code
world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf") |>
  dplyr::filter(continent != "Antarctica")

site_yrs <- annual_std |>
  dplyr::group_by(site) |>
  dplyr::summarize(n_yrs = dplyr::n_distinct(year), .groups = "drop") |>
  dplyr::left_join(metadata, dplyr::join_by(site)) |>
  dplyr::filter(!is.na(LOCATION_LONG), !is.na(LOCATION_LAT)) |>
  sf::st_as_sf(coords = c("LOCATION_LONG","LOCATION_LAT"), crs = 4326)

# BBoxes from sites (avoid typos like 'Portugul')
bbox_na <- site_yrs |>
  dplyr::filter(COUNTRY %in% c("USA","United States of America","Canada","Mexico","Panama","Costa Rica")) |>
  sf::st_bbox()

bbox_eu <- site_yrs |>
  dplyr::filter(COUNTRY %in% c(
    "Belgium","Switzerland","Czechia","Germany","Denmark","Spain","Finland",
    "France","Italy","Netherlands","Norway","Portugal","Sweden","United Kingdom",
    "UK","Ireland","Austria","Poland"
  )) |>
  sf::st_bbox()

base <- ggplot() +
  geom_sf(data = world, fill = "white") +
  geom_sf(data = site_yrs, aes(color = n_yrs), size = 0.5) +
  scale_color_continuous_sequential(palette = "Viridis", rev = FALSE, end = 0.9) +
  theme_void() +
  theme(panel.background = element_rect(fill = "lightblue", color = "black"))

p_world <- base + coord_sf(expand = FALSE)
p_na    <- base + coord_sf(xlim = c(bbox_na["xmin"], bbox_na["xmax"]),
                           ylim = c(bbox_na["ymin"], bbox_na["ymax"]))
p_eu    <- base + coord_sf(xlim = c(bbox_eu["xmin"], bbox_eu["xmax"]),
                           ylim = c(bbox_eu["ymin"], bbox_eu["ymax"]))

p_site_years <- p_world / (p_eu | p_na) +
  patchwork::plot_layout(widths = c(1, 0.4, 0.6), guides = "collect") &
  labs(color = "# yrs")

save_if(p_site_years, "eric_n_yrs_site_map", width = 10, height = 8)

Sites colored by number of years of data; main panel plus focus on Europe and North America.
Code
p_site_years

Sites colored by number of years of data; main panel plus focus on Europe and North America.

5.2 Site map by IGBP

Code
metadata_sf <- metadata |>
  dplyr::filter(!is.na(LOCATION_LONG), !is.na(LOCATION_LAT)) |>
  sf::st_as_sf(coords = c("LOCATION_LONG","LOCATION_LAT"), crs = 4326)

base2 <- ggplot() +
  geom_sf(data = world, fill = "white") +
  geom_sf(data = metadata_sf, aes(color = IGBP), size = 0.5, key_glyph = "rect") +
  scale_color_discrete_qualitative() +
  theme_void() +
  theme(panel.background = element_rect(fill = "lightblue", color = "black"))

p_world2 <- base2 + coord_sf(expand = FALSE)
p_na2    <- base2 + coord_sf(xlim = c(bbox_na["xmin"], bbox_na["xmax"]),
                             ylim = c(bbox_na["ymin"], bbox_na["ymax"]))
p_eu2    <- base2 + coord_sf(xlim = c(bbox_eu["xmin"], bbox_eu["xmax"]),
                             ylim = c(bbox_eu["ymin"], bbox_eu["ymax"]))

p_igbp <- p_world2 / (p_eu2 | p_na2) +
  patchwork::plot_layout(widths = c(1, 0.4, 0.6), guides = "collect") &
  labs(color = "IGBP")

save_if(p_igbp, "eric_igbp_site_map", width = 10, height = 8)

Sites colored by IGBP class; main panel plus focus on Europe and North America.
Code
p_igbp

Sites colored by IGBP class; main panel plus focus on Europe and North America.

5.3 Current vs historical flux maps

Code
annual_hist <- annual_std |>
  dplyr::select(site, year, dplyr::all_of(vars_of_interest)) |>
  dplyr::filter(year < max(year, na.rm = TRUE)) |>
  dplyr::summarize(dplyr::across(dplyr::all_of(vars_of_interest), ~mean(.x, na.rm = TRUE)),
                   .by = site)

annual_curr <- annual_std |>
  dplyr::select(site, year, dplyr::all_of(vars_of_interest)) |>
  dplyr::filter(year == max(year, na.rm = TRUE)) |>
  dplyr::summarize(dplyr::across(dplyr::all_of(vars_of_interest), ~mean(.x, na.rm = TRUE)),
                   .by = site)

plot_df <- dplyr::bind_rows(
  dplyr::mutate(annual_hist, period = "hist"),
  dplyr::mutate(annual_curr, period = "curr")
) |>
  tidyr::pivot_longer(c(-site, -period)) |>
  tidyr::pivot_wider(names_from = period, values_from = value) |>
  dplyr::mutate(diff = curr - hist) |>
  tidyr::pivot_wider(names_from = name, values_from = c(hist, curr, diff)) |>
  dplyr::left_join(metadata |> dplyr::select(site, LOCATION_LAT, LOCATION_LONG), dplyr::join_by(site)) |>
  dplyr::filter(is.finite(LOCATION_LONG), is.finite(LOCATION_LAT)) |>
  sf::st_as_sf(coords = c("LOCATION_LONG","LOCATION_LAT"), crs = 4326)

make_pair <- function(var){
  lab <- stringr::str_extract(var, "GPP|NEE|RECO")
  var_hist <- paste0("hist_", var)
  var_diff <- paste0("diff_", var)

  p1 <- plot_df |>
    dplyr::filter(dplyr::if_all(dplyr::all_of(var_hist), is.finite)) |>
    ggplot() +
    geom_sf(data = world, fill = "white") +
    geom_sf(aes(color = .data[[var_hist]]), size = 0.4, na.rm = TRUE) +
    labs(color = paste0(lab, " gC m<sup>-2</sup> y<sup>-1</sup>")) +
    coord_sf(expand = FALSE) +
    theme_void() +
    theme(legend.title = ggtext::element_markdown(),
          legend.position = "bottom",
          legend.key.width = grid::unit(1, "null"),
          panel.background = element_rect(fill = "lightblue", color = "black"))

  if (lab == "NEE") {
    p1 <- p1 + cols4all::scale_color_continuous_c4a_div("met.troy")
  } else {
    p1 <- p1 + scale_color_continuous_sequential(palette = "Viridis")
  }

  p2 <- plot_df |>
    dplyr::filter(dplyr::if_all(dplyr::all_of(var_diff), is.finite)) |>
    ggplot() +
    geom_sf(data = world, fill = "white") +
    geom_sf(aes(color = .data[[var_diff]]), size = 0.4, na.rm = TRUE) +
    scale_color_continuous_diverging() +
    labs(color = paste0(lab, " gC m<sup>-2</sup> y<sup>-1</sup>")) +
    coord_sf(expand = FALSE) +
    theme_void() +
    theme(legend.title = ggtext::element_markdown(),
          legend.position = "bottom",
          legend.key.width = grid::unit(1, "null"),
          panel.background = element_rect(fill = "lightblue", color = "black"))

  p1 | p2
}

p_list <- lapply(vars_of_interest, make_pair)
p_fluxmaps <- patchwork::wrap_plots(p_list, ncol = 1) + patchwork::plot_annotation(tag_levels = "a")
Code
save_if(p_fluxmaps, "eric_flux_maps", width = 10, height = 8)

Fluxes averaged over all prior years (left in each row) and the current year minus prior mean (right).
Code
p_fluxmaps

Fluxes averaged over all prior years (left in each row) and the current year minus prior mean (right).

6 IGBP aggregated flux summaries

Code
p_igbp_nee  <- plot_flux_by_igbp(annual_data, "NEE_VUT_REF")$composite_plot
save_if(p_igbp_nee, "igbp_nee")

Code
p_igbp_gpp  <- plot_flux_by_igbp(annual_data, "GPP_NT_VUT_REF")$composite_plot
save_if(p_igbp_gpp, "igbp_gpp")

Code
p_igbp_reco <- plot_flux_by_igbp(annual_data, "RECO_NT_VUT_REF")$composite_plot
save_if(p_igbp_reco, "igbp_reco")

7 Time-sliced comparison (5-yr bins)

Code
p_timeslice <- plot_flux_by_igbp_timeslice_grouped(annual_data, flux_var = "NEE_VUT_REF")$flux_plot
save_if(p_timeslice, "timeslice_nee")

8 Interannual boxplots by IGBP groups

Code
boxplots <- plot_flux_box_by_group(annual_data, flux_var = "RECO_NT_VUT_REF", y_mode = "squish")
save_if(boxplots$Forest,        "box_forest")

Code
save_if(boxplots$ShrubOpens,    "box_shrubopens")

Code
save_if(boxplots$GrassCropsWet, "box_grasscropswet")

9 Median time series by IGBP

Code
save_if(plot_flux_timeseries_by_igbp(annual_data, "NEE_VUT_REF"), "ts_nee")

Code
save_if(plot_flux_timeseries_by_igbp(annual_data, "GPP_NT_VUT_REF"), "ts_gpp")

Code
save_if(plot_flux_timeseries_by_igbp(annual_data, "RECO_NT_VUT_REF"), "ts_reco")

Code
save_if(plot_flux_timeseries_by_igbp(annual_data, "WUE"), "ts_wue")

10 Latitudinal summaries

Code
save_if(plot_latitudinal_flux(annual_data, metadata, "NEE_VUT_REF"),     "lat_nee")

Code
save_if(plot_latitudinal_flux(annual_data, metadata, "GPP_NT_VUT_REF"),  "lat_gpp")

Code
save_if(plot_latitudinal_flux(annual_data, metadata, "RECO_NT_VUT_REF"), "lat_reco")

11 Climate vs Flux quicklooks

Code
cp <- plot_annual_fluxnet_data(annual_data)
save_if(cp$precip_vs_nee, "precip_vs_nee")

Code
save_if(cp$temp_vs_gpp,   "temp_vs_gpp")

Code
save_if(PlotXY_annual(annual_data, "GPP_NT_VUT_REF", "LE_F_MDS"), "xy_gpp_le")

Code
save_if(PlotXY_annual(annual_data, "NEE_VUT_REF",     "LE_F_MDS"), "xy_nee_le")

12 Site-level climate & flux summaries (tower)

Code
climate_summary <- annual_data %>%
  dplyr::group_by(site) %>%
  dplyr::summarize(
    mean_precip = mean(P_F, na.rm = TRUE),       # mm/yr (tower)
    mean_temp   = mean(TA_F, na.rm = TRUE),      # °C (tower)
    mean_GPP    = mean(GPP_NT_VUT_REF, na.rm = TRUE),
    mean_NEE    = mean(NEE_VUT_REF, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  dplyr::mutate(
    mean_precip_cm = set_units(mean_precip, "mm") |> set_units("cm"),  # mm → cm
    NEE_sign = ifelse(mean_NEE < 0, "Sink (NEE < 0)", "Source (NEE ≥ 0)")
  ) %>%
  dplyr::left_join(metadata, by = "site")

head(climate_summary)
# A tibble: 6 × 20
  site   mean_precip mean_temp mean_GPP mean_NEE mean_precip_cm NEE_sign SITE_ID
  <chr>        <dbl>     <dbl>    <dbl>    <dbl>           [cm] <chr>    <chr>  
1 AR-Bal        433.     13.3     1733.  -161.             43.3 Sink (N… AR-Bal 
2 AR-CCa       3415.     16.8     1160.  -145.            342.  Sink (N… AR-CCa 
3 AR-CCg       2795.     15.3     1387.   -44.1           280.  Sink (N… AR-CCg 
4 AR-SLu        361.     19.7     2578. -1730.             36.1 Sink (N… <NA>   
5 AR-TF1       3430.      6.44     624.     1.86          343.  Source … AR-TF1 
6 AR-TF2       3785.      5.60     289.   -17.1           378.  Sink (N… AR-TF2 
# ℹ 12 more variables: SITE_NAME <chr>, COUNTRY <chr>, STATE <chr>, IGBP <chr>,
#   LOCATION_LAT <dbl>, LOCATION_LONG <dbl>, LOCATION_ELEV <dbl>,
#   CLIMATE_KOEPPEN <chr>, MAT <dbl>, MAP <dbl>, DATA_SOURCE <chr>,
#   SITEID <chr>

13 WorldClim extraction at tower locations

Code
wc <- readRDS(here::here(params$worldclim_rds))

# --- Choose lat/lon columns and drop NAs before extraction ---
lon_candidates <- c("LOCATION_LONG","LONGITUDE","Longitude","lon","Lon","LON")
lat_candidates <- c("LOCATION_LAT","LATITUDE","Latitude","lat","Lat","LAT")

lon_col <- lon_candidates[lon_candidates %in% names(climate_summary)][1]
lat_col <- lat_candidates[lat_candidates %in% names(climate_summary)][1]

if (is.na(lon_col) || is.na(lat_col)) {
  stop("Could not find longitude/latitude columns in climate_summary. ",
       "Looked for: ", paste(lon_candidates, collapse=", "), " / ",
       paste(lat_candidates, collapse=", "))
}

cs_coords <- climate_summary %>%
  dplyr::rename(LON = !!lon_col, LAT = !!lat_col) %>%
  dplyr::filter(is.finite(LON), is.finite(LAT))

n_dropped <- nrow(climate_summary) - nrow(cs_coords)
if (n_dropped > 0) message("WorldClim extraction: dropped ", n_dropped, " site(s) with missing/invalid coordinates.")

# Decide extraction engine: use terra if requested OR if object is a SpatRaster
use_terra_extraction <- isTRUE(params$use_terra) || inherits(wc, "SpatRaster")

if (use_terra_extraction) {
  # --- terra path ---
  if (!requireNamespace("terra", quietly = TRUE)) {
    stop("terra is required for SpatRaster extraction. Install with install.packages('terra') ",
         "or set params$use_terra: false and provide a RasterStack/Brick.")
  }
  site_pts <- terra::vect(
    cs_coords,
    geom = c("LON", "LAT"),
    crs  = "EPSG:4326"
  )
  wc_vals <- terra::extract(wc, site_pts)
  names(wc_vals) <- make.names(names(wc_vals))
  cs_coords$MAT_WorldClim <- wc_vals[[grep("bio_1", names(wc_vals))[1]]]
  cs_coords$MAP_WorldClim <- wc_vals[[grep("bio_12", names(wc_vals))[1]]]
} else {
  # --- raster fallback ---
  if (inherits(wc, "SpatRaster")) {
    wc_try <- try(suppressWarnings(raster::stack(wc)), silent = TRUE)
    if (inherits(wc_try, "try-error")) {
      wc_try <- try(suppressWarnings(as(wc, "Raster")), silent = TRUE)
    }
    if (inherits(wc_try, "try-error")) {
      stop("Could not coerce SpatRaster -> Raster for raster::extract. ",
           "Set params$use_terra: true to use terra extraction.")
    }
    wc <- wc_try
  }
  sp_pts <- sp::SpatialPoints(
    coords = cs_coords[,c("LON","LAT")],
    proj4string = sp::CRS("+proj=longlat +datum=WGS84 +no_defs")
  )
  wc_vals <- raster::extract(wc, sp_pts)
  nm <- colnames(wc_vals)
  mat_col <- nm[grepl("bio_1", nm)]
  map_col <- nm[grepl("bio_12", nm)]
  cs_coords$MAT_WorldClim <- wc_vals[, mat_col][,1]
  cs_coords$MAP_WorldClim <- wc_vals[, map_col][,1]
}

# Merge extracted values back into full climate_summary (by site)
if (!all(c("site") %in% names(cs_coords))) {
  cs_coords$site <- climate_summary$site[match(rownames(cs_coords), rownames(climate_summary))]
}
climate_summary <- climate_summary %>%
  dplyr::select(-dplyr::any_of(c("MAT_WorldClim","MAP_WorldClim"))) %>%
  dplyr::left_join(cs_coords %>% dplyr::select(site, MAT_WorldClim, MAP_WorldClim), by = "site")

# Convert to Whittaker axes
climate_summary <- climate_summary %>%
  dplyr::mutate(
    MAT_WorldClim_C  = MAT_WorldClim ,  # °C
    MAP_WorldClim_cm = MAP_WorldClim / 10   # mm → cm
  )

14 Tower vs WorldClim checks

Code
p_wc_mat <- ggplot(climate_summary, aes(x = MAT_WorldClim_C, y = mean_temp)) +
  geom_point(size = 2.5, alpha = 0.8) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed") +
  labs(x = "WorldClim MAT (°C)", y = "Observed Site MAT (°C)") +
  theme_classic()
save_if(p_wc_mat, "wc_vs_site_mat")

Code
p_wc_map <- ggplot(climate_summary, aes(x = MAP_WorldClim, y = mean_precip)) +
  geom_point(size = 2.5, alpha = 0.8) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed") +
  labs(x = "WorldClim MAP (mm)", y = "Observed Site MAP (mm)") +
  theme_classic()
save_if(p_wc_map, "wc_vs_site_map")

15 Whittaker biomes with site overlays

Code
data("Whittaker_biomes", package = "plotbiomes")

my_15_colors <- c(
  "#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#66a61e",
  "#e6ab02", "#a6761d", "#666666", "#1f78b4", "#b2df8a",
  "#fb9a99", "#fdbf6f", "#cab2d6", "#ffff99", "#8dd3c7"
)

p_whittaker <- ggplot() +
  geom_polygon(
    data = Whittaker_biomes,
    aes(x = temp_c, y = precp_cm, group = biome, fill = biome),
    color = "grey80", alpha = 0.4
  ) +
  scale_fill_brewer(palette = "BrBG", name = "Biome") +
  ggnewscale::new_scale_fill() +
  geom_point(
    data = climate_summary,
    aes(x = MAT_WorldClim_C, y = MAP_WorldClim_cm, color = IGBP),
    size = 2.8, alpha = 0.9
  ) +
  scale_color_manual(values = my_15_colors, drop = FALSE, name = "IGBP") +
  labs(x = "Temperature (°C)", y = "Precipitation (cm yr\u207B\u00B9)") +
  theme_classic()
save_if(p_whittaker, "whittaker_igbp")

Code
p_whittaker_nee <- ggplot() +
  geom_polygon(
    data = Whittaker_biomes,
    aes(x = temp_c, y = precp_cm, group = biome, fill = biome),
    color = "grey80", alpha = 0.4
  ) +
  scale_fill_brewer(palette = "BrBG", name = "Biome") +
  ggnewscale::new_scale_fill() +
  geom_point(
    data = climate_summary,
    aes(x = MAT_WorldClim_C, y = MAP_WorldClim_cm,
        size = abs(mean_NEE), fill = NEE_sign),
    shape = 21, color = "black", alpha = 0.9
  ) +
  scale_fill_manual(
    values = c("Sink (NEE < 0)" = "#1b9e77",
               "Source (NEE ≥ 0)" = "#d95f02"),
    name = "NEE Sign"
  ) +
  scale_size_continuous(name = "|NEE|", range = c(1, 8)) +
  labs(x = "Temperature (°C)", y = "Precipitation (cm yr\u207B\u00B9)") +
  theme_classic()
save_if(p_whittaker_nee, "whittaker_nee")