From ad1242e0df78365e7dd0d4d9cd0daa5b9dddb237 Mon Sep 17 00:00:00 2001 From: Akash-paluvai <2023too2027@gmail.com> Date: Wed, 8 Apr 2026 23:51:20 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20ERA5=20CDS=20=E2=86=94=20CF?= =?UTF-8?q?=20variable=20name=20mapping=20utility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R/Ameriflux_met_ensemble.R | 2 +- .../R/check_met_coverage_for_fallback.R | 5 +- .../data.atmosphere/R/era5_cf_varname_map.R | 104 ++++++ .../tests/testthat/test-era5-cf-varname-map.R | 326 ++++++++++++++++++ 4 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 modules/data.atmosphere/R/era5_cf_varname_map.R create mode 100644 modules/data.atmosphere/tests/testthat/test-era5-cf-varname-map.R diff --git a/modules/data.atmosphere/R/Ameriflux_met_ensemble.R b/modules/data.atmosphere/R/Ameriflux_met_ensemble.R index 9981772629d..90b7711cf63 100644 --- a/modules/data.atmosphere/R/Ameriflux_met_ensemble.R +++ b/modules/data.atmosphere/R/Ameriflux_met_ensemble.R @@ -184,7 +184,7 @@ AmeriFlux_met_ensemble <- function(site_id, verbose = verbose ) - fill_vars <- coverage_results$fill_vars + fill_vars <- coverage_results$fill_vars_cds if (length(fill_vars) > 0) { start_year <- lubridate::year(as.Date(start_date)) diff --git a/modules/data.atmosphere/R/check_met_coverage_for_fallback.R b/modules/data.atmosphere/R/check_met_coverage_for_fallback.R index f8187469364..51ff5a7efb6 100644 --- a/modules/data.atmosphere/R/check_met_coverage_for_fallback.R +++ b/modules/data.atmosphere/R/check_met_coverage_for_fallback.R @@ -69,8 +69,11 @@ check_met_coverage_for_fallback <- function(cf_file, fill_vars <- c(fill_vars, "volumetric_soil_water_layer_1") } + fill_vars_cf <- unname(cds_to_cf_varnames(fill_vars)) + list( - fill_vars = fill_vars, + fill_vars_cds = fill_vars, + fill_vars_cf = fill_vars_cf, coverage = list( rg = rg_coverage, par = par_coverage, diff --git a/modules/data.atmosphere/R/era5_cf_varname_map.R b/modules/data.atmosphere/R/era5_cf_varname_map.R new file mode 100644 index 00000000000..5f87744da73 --- /dev/null +++ b/modules/data.atmosphere/R/era5_cf_varname_map.R @@ -0,0 +1,104 @@ +#' ERA5 CDS-to-CF variable name map +#' +#' Internal mapping between ERA5 CDS variable names and CF standard names +#' produced by extract.nc.ERA5(). +#' +#' Only variables relevant to the AmeriFlux gap-fill fallback pipeline are +#' included. Variables not listed here are not handled by this pipeline. +#' +#' @noRd +era5_cds_to_cf_varnames <- c( + "surface_solar_radiation_downwards" = + "surface_downwelling_shortwave_flux_in_air", + "volumetric_soil_water_layer_1" = + "volume_fraction_of_condensed_water_in_soil" +) + +#' Translate ERA5 CDS variable names to CF standard names +#' +#' @description +#' Translate ERA5 CDS variable names to CF standard names. +#' +#' @param cds_vars Character vector of ERA5 CDS API variable names. +#' +#' @return Named character vector the same length as `cds_vars`. Names are +#' the input CDS names; values are the corresponding CF standard names as +#' written by [extract.nc.ERA5()]. Any input name absent from the internal +#' map produces a [PEcAn.logger::logger.warn()] and an `NA` at that +#' position. Returns `character(0)` when `cds_vars` is empty. +#' +#' @details +#' Covers only the ERA5 variables used in the AmeriFlux gap-fill fallback +#' pipeline. Unknown names are warned and returned as `NA` rather than +#' silently dropped so that callers encounter the failure immediately instead +#' of producing a silent no-fill condition downstream. +#' +#' @seealso [cf_to_cds_varnames()] +#' @noRd +cds_to_cf_varnames <- function(cds_vars) { + if (length(cds_vars) == 0L) { + return(character(0)) + } + + result <- era5_cds_to_cf_varnames[cds_vars] + unknown <- cds_vars[is.na(result)] + + if (length(unknown) > 0L) { + msg <- paste( + "cds_to_cf_varnames: no CF mapping for CDS variable(s):", + paste(unknown, collapse = ", "), + "returning NA for those entries" + ) + warning(msg, call. = FALSE) + PEcAn.logger::logger.warn(msg) + + } + + result +} + + +#' Translate CF standard variable names to ERA5 CDS names +#' +#' @param cf_vars Character vector of CF standard variable names. +#' +#' @return Named character vector the same length as `cf_vars`. Names are +#' the input CF names; values are the corresponding CDS API names. Any +#' input name absent from the internal map produces a +#' [PEcAn.logger::logger.warn()] and an `NA` at that position. Returns +#' `character(0)` when `cf_vars` is empty. +#' +#' @details +#' Reverse of [cds_to_cf_varnames()]. Covers only the ERA5 variables used in +#' the AmeriFlux gap-fill fallback pipeline. Unknown names warn and return +#' `NA` rather than being silently dropped. +#' +#' @seealso [cds_to_cf_varnames()] +#' @noRd +cf_to_cds_varnames <- function(cf_vars) { + if (length(cf_vars) == 0L) { + return(character(0)) + } + + # Reverse map built at call time — one source of truth in + # era5_cds_to_cf_varnames; no second hardcoded vector to keep in sync. + reverse_map <- stats::setNames( + names(era5_cds_to_cf_varnames), + unname(era5_cds_to_cf_varnames) + ) + + result <- reverse_map[cf_vars] + unknown <- cf_vars[is.na(result)] + + if (length(unknown) > 0L) { + msg <- paste( + "cf_to_cds_varnames: no CDS mapping for CF variable(s):", + paste(unknown, collapse = ", "), + "- returning NA for those entries" + ) + warning(msg, call. = FALSE) + PEcAn.logger::logger.warn(msg) + } + + result +} \ No newline at end of file diff --git a/modules/data.atmosphere/tests/testthat/test-era5-cf-varname-map.R b/modules/data.atmosphere/tests/testthat/test-era5-cf-varname-map.R new file mode 100644 index 00000000000..b5ef1ab9d29 --- /dev/null +++ b/modules/data.atmosphere/tests/testthat/test-era5-cf-varname-map.R @@ -0,0 +1,326 @@ +# Tests for internal ERA5 CDS <-> CF variable name translation utilities. +# Functions under test: cds_to_cf_varnames(), cf_to_cds_varnames() +# Return-value contract for: check_met_coverage_for_fallback() + +# Internal functions are not exported; access via ::: in tests. + +# --------------------------------------------------------------------------- +# cds_to_cf_varnames — forward direction +# --------------------------------------------------------------------------- + +test_that("known CDS name for radiation returns correct CF name", { + result <- PEcAn.data.atmosphere:::cds_to_cf_varnames( + "surface_solar_radiation_downwards" + ) + expect_identical( + unname(result), + "surface_downwelling_shortwave_flux_in_air" + ) +}) + +test_that("known CDS name for soil water returns correct CF name", { + result <- PEcAn.data.atmosphere:::cds_to_cf_varnames( + "volumetric_soil_water_layer_1" + ) + expect_identical( + unname(result), + "volume_fraction_of_condensed_water_in_soil" + ) +}) + +test_that("output names equal the input CDS names", { + input <- c("surface_solar_radiation_downwards", + "volumetric_soil_water_layer_1") + result <- PEcAn.data.atmosphere:::cds_to_cf_varnames(input) + expect_identical(names(result), input) +}) + +test_that("multiple CDS names translate correctly and preserve order", { + input <- c("volumetric_soil_water_layer_1", + "surface_solar_radiation_downwards") + result <- PEcAn.data.atmosphere:::cds_to_cf_varnames(input) + expect_identical( + unname(result), + c("volume_fraction_of_condensed_water_in_soil", + "surface_downwelling_shortwave_flux_in_air") + ) +}) + +test_that("unknown CDS name produces a warning and returns NA", { + expect_warning( + result <- PEcAn.data.atmosphere:::cds_to_cf_varnames( + "not_a_real_cds_variable" + ), + regexp = "no CF mapping" + ) + expect_true(is.na(unname(result))) +}) + +test_that("unknown CDS name does not corrupt translation of known names in same call", { + expect_warning( + result <- PEcAn.data.atmosphere:::cds_to_cf_varnames( + c("surface_solar_radiation_downwards", "not_a_real_cds_variable") + ), + regexp = "no CF mapping" + ) + expect_identical( + unname(result[[1]]), + "surface_downwelling_shortwave_flux_in_air" + ) + expect_true(is.na(unname(result[[2]]))) +}) + +test_that("empty input to cds_to_cf_varnames returns character(0) without warning", { + expect_no_warning( + result <- PEcAn.data.atmosphere:::cds_to_cf_varnames(character(0)) + ) + expect_identical(result, character(0)) +}) + +# --------------------------------------------------------------------------- +# cf_to_cds_varnames — reverse direction +# --------------------------------------------------------------------------- + +test_that("known CF radiation name reverse-translates to correct CDS name", { + result <- PEcAn.data.atmosphere:::cf_to_cds_varnames( + "surface_downwelling_shortwave_flux_in_air" + ) + expect_identical( + unname(result), + "surface_solar_radiation_downwards" + ) +}) + +test_that("known CF soil water name reverse-translates to correct CDS name", { + result <- PEcAn.data.atmosphere:::cf_to_cds_varnames( + "volume_fraction_of_condensed_water_in_soil" + ) + expect_identical( + unname(result), + "volumetric_soil_water_layer_1" + ) +}) + +test_that("output names of cf_to_cds_varnames equal the input CF names", { + input <- c("surface_downwelling_shortwave_flux_in_air", + "volume_fraction_of_condensed_water_in_soil") + result <- PEcAn.data.atmosphere:::cf_to_cds_varnames(input) + expect_identical(names(result), input) +}) + +test_that("unknown CF name produces a warning and returns NA", { + expect_warning( + result <- PEcAn.data.atmosphere:::cf_to_cds_varnames( + "not_a_real_cf_variable" + ), + regexp = "no CDS mapping" + ) + expect_true(is.na(unname(result))) +}) + +test_that("empty input to cf_to_cds_varnames returns character(0) without warning", { + expect_no_warning( + result <- PEcAn.data.atmosphere:::cf_to_cds_varnames(character(0)) + ) + expect_identical(result, character(0)) +}) + +# --------------------------------------------------------------------------- +# Round-trip consistency +# --------------------------------------------------------------------------- + +test_that("round-trip cds_to_cf then cf_to_cds recovers original name", { + original <- "surface_solar_radiation_downwards" + cf_name <- unname( + PEcAn.data.atmosphere:::cds_to_cf_varnames(original) + ) + restored <- unname( + PEcAn.data.atmosphere:::cf_to_cds_varnames(cf_name) + ) + expect_identical(restored, original) +}) + +test_that("round-trip cf_to_cds then cds_to_cf recovers original name", { + original <- "volume_fraction_of_condensed_water_in_soil" + cds_name <- unname( + PEcAn.data.atmosphere:::cf_to_cds_varnames(original) + ) + restored <- unname( + PEcAn.data.atmosphere:::cds_to_cf_varnames(cds_name) + ) + expect_identical(restored, original) +}) + +test_that("every entry in era5_cds_to_cf_varnames survives a full round-trip", { + map <- PEcAn.data.atmosphere:::era5_cds_to_cf_varnames + for (cds_name in names(map)) { + cf_name <- unname(PEcAn.data.atmosphere:::cds_to_cf_varnames(cds_name)) + restored <- unname(PEcAn.data.atmosphere:::cf_to_cds_varnames(cf_name)) + expect_identical( + restored, cds_name, + label = paste("round-trip failed for CDS name:", cds_name) + ) + } +}) + +# --------------------------------------------------------------------------- +# check_met_coverage_for_fallback return structure +# --------------------------------------------------------------------------- +# These tests verify that the return value of the updated function has the +# correct shape. They exercise the function via a real minimal NetCDF file +# so the file-open and attribute-reading paths are covered. + +test_that("return list has exactly the three expected names", { + tmp <- withr::local_tempfile(fileext = ".nc") + + time_dim <- ncdf4::ncdim_def("time", "hours since 2010-01-01", 1:8) + rg_var <- ncdf4::ncvar_def( + "surface_downwelling_shortwave_flux_in_air", "W m-2", + list(time_dim), missval = -9999 + ) + nc <- ncdf4::nc_create(tmp, list(rg_var)) + ncdf4::ncvar_put(nc, rg_var, rep(-9999, 8)) + ncdf4::nc_close(nc) + + result <- PEcAn.data.atmosphere:::check_met_coverage_for_fallback(tmp) + + expect_named(result, c("fill_vars_cds", "fill_vars_cf", "coverage")) +}) + +test_that("coverage sub-list has rg, par, and swc entries", { + tmp <- withr::local_tempfile(fileext = ".nc") + + time_dim <- ncdf4::ncdim_def("time", "hours since 2010-01-01", 1:8) + rg_var <- ncdf4::ncvar_def( + "surface_downwelling_shortwave_flux_in_air", "W m-2", + list(time_dim), missval = -9999 + ) + nc <- ncdf4::nc_create(tmp, list(rg_var)) + ncdf4::ncvar_put(nc, rg_var, rep(-9999, 8)) + ncdf4::nc_close(nc) + + result <- PEcAn.data.atmosphere:::check_met_coverage_for_fallback(tmp) + + expect_named(result$coverage, c("rg", "par", "swc")) +}) + +test_that("fill_vars_cds and fill_vars_cf are parallel when coverage is zero", { + tmp <- withr::local_tempfile(fileext = ".nc") + + time_dim <- ncdf4::ncdim_def("time", "hours since 2010-01-01", 1:8) + rg_var <- ncdf4::ncvar_def( + "surface_downwelling_shortwave_flux_in_air", "W m-2", + list(time_dim), missval = -9999 + ) + swc_var <- ncdf4::ncvar_def( + "volume_fraction_of_condensed_water_in_soil", "m3 m-3", + list(time_dim), missval = -9999 + ) + nc <- ncdf4::nc_create(tmp, list(rg_var, swc_var)) + ncdf4::ncvar_put(nc, rg_var, rep(-9999, 8)) + ncdf4::ncvar_put(nc, swc_var, rep(-9999, 8)) + ncdf4::nc_close(nc) + + result <- PEcAn.data.atmosphere:::check_met_coverage_for_fallback(tmp) + + expect_identical(length(result$fill_vars_cds), length(result$fill_vars_cf)) + expect_gt(length(result$fill_vars_cds), 0L) +}) + +test_that("fill_vars_cf entries are the correct CF translations of fill_vars_cds", { + tmp <- withr::local_tempfile(fileext = ".nc") + + time_dim <- ncdf4::ncdim_def("time", "hours since 2010-01-01", 1:8) + rg_var <- ncdf4::ncvar_def( + "surface_downwelling_shortwave_flux_in_air", "W m-2", + list(time_dim), missval = -9999 + ) + swc_var <- ncdf4::ncvar_def( + "volume_fraction_of_condensed_water_in_soil", "m3 m-3", + list(time_dim), missval = -9999 + ) + nc <- ncdf4::nc_create(tmp, list(rg_var, swc_var)) + ncdf4::ncvar_put(nc, rg_var, rep(-9999, 8)) + ncdf4::ncvar_put(nc, swc_var, rep(-9999, 8)) + ncdf4::nc_close(nc) + + result <- PEcAn.data.atmosphere:::check_met_coverage_for_fallback(tmp) + + expected_cf <- unname( + PEcAn.data.atmosphere:::cds_to_cf_varnames(result$fill_vars_cds) + ) + expect_identical(result$fill_vars_cf, expected_cf) +}) + +test_that("fill_vars_cds is empty when all variables have full coverage", { + tmp <- withr::local_tempfile(fileext = ".nc") + + time_dim <- ncdf4::ncdim_def("time", "hours since 2010-01-01", 1:8) + rg_var <- ncdf4::ncvar_def( + "surface_downwelling_shortwave_flux_in_air", "W m-2", + list(time_dim), missval = -9999 + ) + swc_var <- ncdf4::ncvar_def( + "volume_fraction_of_condensed_water_in_soil", "m3 m-3", + list(time_dim), missval = -9999 + ) + nc <- ncdf4::nc_create(tmp, list(rg_var, swc_var)) + ncdf4::ncvar_put(nc, rg_var, seq(100, 800, length.out = 8)) + ncdf4::ncvar_put(nc, swc_var, rep(0.25, 8)) + ncdf4::nc_close(nc) + + result <- PEcAn.data.atmosphere:::check_met_coverage_for_fallback(tmp) + + expect_identical(result$fill_vars_cds, character(0)) + expect_identical(result$fill_vars_cf, character(0)) +}) + +test_that("PAR never appears in fill_vars_cds even when PAR coverage is zero", { + tmp <- withr::local_tempfile(fileext = ".nc") + + time_dim <- ncdf4::ncdim_def("time", "hours since 2010-01-01", 1:8) + par_var <- ncdf4::ncvar_def( + "surface_downwelling_photosynthetic_photon_flux_in_air", + "mol m-2 s-1", + list(time_dim), missval = -9999 + ) + rg_var <- ncdf4::ncvar_def( + "surface_downwelling_shortwave_flux_in_air", "W m-2", + list(time_dim) + ) + nc <- ncdf4::nc_create(tmp, list(par_var, rg_var)) + ncdf4::ncvar_put(nc, par_var, rep(-9999, 8)) + ncdf4::ncvar_put(nc, rg_var, seq(100, 800, length.out = 8)) + ncdf4::nc_close(nc) + + result <- PEcAn.data.atmosphere:::check_met_coverage_for_fallback(tmp) + + expect_false( + any(grepl("photosynthetic|par", result$fill_vars_cds, ignore.case = TRUE)) + ) + expect_false( + any(grepl("photosynthetic|par", result$fill_vars_cf, ignore.case = TRUE)) + ) +}) + +test_that("radiation CDS name in fill_vars_cds when Rg coverage is zero", { + tmp <- withr::local_tempfile(fileext = ".nc") + + time_dim <- ncdf4::ncdim_def("time", "hours since 2010-01-01", 1:8) + rg_var <- ncdf4::ncvar_def( + "surface_downwelling_shortwave_flux_in_air", "W m-2", + list(time_dim), missval = -9999 + ) + nc <- ncdf4::nc_create(tmp, list(rg_var)) + ncdf4::ncvar_put(nc, rg_var, rep(-9999, 8)) + ncdf4::nc_close(nc) + + result <- PEcAn.data.atmosphere:::check_met_coverage_for_fallback(tmp) + + expect_true( + "surface_solar_radiation_downwards" %in% result$fill_vars_cds + ) + expect_true( + "surface_downwelling_shortwave_flux_in_air" %in% result$fill_vars_cf + ) +}) \ No newline at end of file From 2c299a24d2bc253202edc318e60e4011b824713c Mon Sep 17 00:00:00 2001 From: Akash-paluvai <2023too2027@gmail.com> Date: Sun, 19 Apr 2026 16:50:02 +0530 Subject: [PATCH 2/2] =?UTF-8?q?Refactor=20ERA5=E2=86=94CF=20mapping=20to?= =?UTF-8?q?=20use=20generic=20translate=5Fmet=5Fvarnames()=20and=20table-b?= =?UTF-8?q?ased=20era5=5Fcds=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R/check_met_coverage_for_fallback.R | 3 +- .../data.atmosphere/R/era5_cf_varname_map.R | 104 ------------------ .../R/pecan_standard_met_table.R | 44 ++++---- .../R/translate_met_varnames.R | 89 +++++++++++++++ .../tests/testthat/test-era5-cf-varname-map.R | 60 +++++----- 5 files changed, 141 insertions(+), 159 deletions(-) delete mode 100644 modules/data.atmosphere/R/era5_cf_varname_map.R create mode 100644 modules/data.atmosphere/R/translate_met_varnames.R diff --git a/modules/data.atmosphere/R/check_met_coverage_for_fallback.R b/modules/data.atmosphere/R/check_met_coverage_for_fallback.R index 51ff5a7efb6..5a30e7bcfd7 100644 --- a/modules/data.atmosphere/R/check_met_coverage_for_fallback.R +++ b/modules/data.atmosphere/R/check_met_coverage_for_fallback.R @@ -9,7 +9,8 @@ #' #' @return list with: #' \itemize{ -#' \item fill_vars: ERA5 variables to request +#' \item fill_vars_cds: ERA5 CDS variable names to request +#' \item fill_vars_cf: CF standard names corresponding to \code{fill_vars_cds} #' \item coverage: named list of coverage fractions #' } #' diff --git a/modules/data.atmosphere/R/era5_cf_varname_map.R b/modules/data.atmosphere/R/era5_cf_varname_map.R deleted file mode 100644 index 5f87744da73..00000000000 --- a/modules/data.atmosphere/R/era5_cf_varname_map.R +++ /dev/null @@ -1,104 +0,0 @@ -#' ERA5 CDS-to-CF variable name map -#' -#' Internal mapping between ERA5 CDS variable names and CF standard names -#' produced by extract.nc.ERA5(). -#' -#' Only variables relevant to the AmeriFlux gap-fill fallback pipeline are -#' included. Variables not listed here are not handled by this pipeline. -#' -#' @noRd -era5_cds_to_cf_varnames <- c( - "surface_solar_radiation_downwards" = - "surface_downwelling_shortwave_flux_in_air", - "volumetric_soil_water_layer_1" = - "volume_fraction_of_condensed_water_in_soil" -) - -#' Translate ERA5 CDS variable names to CF standard names -#' -#' @description -#' Translate ERA5 CDS variable names to CF standard names. -#' -#' @param cds_vars Character vector of ERA5 CDS API variable names. -#' -#' @return Named character vector the same length as `cds_vars`. Names are -#' the input CDS names; values are the corresponding CF standard names as -#' written by [extract.nc.ERA5()]. Any input name absent from the internal -#' map produces a [PEcAn.logger::logger.warn()] and an `NA` at that -#' position. Returns `character(0)` when `cds_vars` is empty. -#' -#' @details -#' Covers only the ERA5 variables used in the AmeriFlux gap-fill fallback -#' pipeline. Unknown names are warned and returned as `NA` rather than -#' silently dropped so that callers encounter the failure immediately instead -#' of producing a silent no-fill condition downstream. -#' -#' @seealso [cf_to_cds_varnames()] -#' @noRd -cds_to_cf_varnames <- function(cds_vars) { - if (length(cds_vars) == 0L) { - return(character(0)) - } - - result <- era5_cds_to_cf_varnames[cds_vars] - unknown <- cds_vars[is.na(result)] - - if (length(unknown) > 0L) { - msg <- paste( - "cds_to_cf_varnames: no CF mapping for CDS variable(s):", - paste(unknown, collapse = ", "), - "returning NA for those entries" - ) - warning(msg, call. = FALSE) - PEcAn.logger::logger.warn(msg) - - } - - result -} - - -#' Translate CF standard variable names to ERA5 CDS names -#' -#' @param cf_vars Character vector of CF standard variable names. -#' -#' @return Named character vector the same length as `cf_vars`. Names are -#' the input CF names; values are the corresponding CDS API names. Any -#' input name absent from the internal map produces a -#' [PEcAn.logger::logger.warn()] and an `NA` at that position. Returns -#' `character(0)` when `cf_vars` is empty. -#' -#' @details -#' Reverse of [cds_to_cf_varnames()]. Covers only the ERA5 variables used in -#' the AmeriFlux gap-fill fallback pipeline. Unknown names warn and return -#' `NA` rather than being silently dropped. -#' -#' @seealso [cds_to_cf_varnames()] -#' @noRd -cf_to_cds_varnames <- function(cf_vars) { - if (length(cf_vars) == 0L) { - return(character(0)) - } - - # Reverse map built at call time — one source of truth in - # era5_cds_to_cf_varnames; no second hardcoded vector to keep in sync. - reverse_map <- stats::setNames( - names(era5_cds_to_cf_varnames), - unname(era5_cds_to_cf_varnames) - ) - - result <- reverse_map[cf_vars] - unknown <- cf_vars[is.na(result)] - - if (length(unknown) > 0L) { - msg <- paste( - "cf_to_cds_varnames: no CDS mapping for CF variable(s):", - paste(unknown, collapse = ", "), - "- returning NA for those entries" - ) - warning(msg, call. = FALSE) - PEcAn.logger::logger.warn(msg) - } - - result -} \ No newline at end of file diff --git a/modules/data.atmosphere/R/pecan_standard_met_table.R b/modules/data.atmosphere/R/pecan_standard_met_table.R index cb29ff0e71c..198e8d99ca9 100644 --- a/modules/data.atmosphere/R/pecan_standard_met_table.R +++ b/modules/data.atmosphere/R/pecan_standard_met_table.R @@ -2,25 +2,25 @@ #' #' @export pecan_standard_met_table <- tibble::tribble( - ~`cf_standard_name` , ~units , ~is_required, ~bety , ~isimip , ~cruncep , ~narr , ~ameriflux , ~era5 , - "air_temperature" , "K" , TRUE, "airT" , "tasAdjust" , "tair" , "air" , "TA (C)" , "t2m" , - "air_temperature_max" , "K" , FALSE, NA , "tasmaxAdjust" , NA , "tmax" , NA , NA , - "air_temperature_min" , "K" , FALSE, NA , "tasminAdjust" , NA , "tmin" , NA , NA , - "air_pressure" , "Pa" , TRUE, "air_pressure" , NA , NA , NA , "PRESS (KPa)" , "sp" , - "dew_point_temperature" , "K" , FALSE, NA , NA , NA , NA , NA , "d2m" , - "mole_fraction_of_carbon_dioxide_in_air" , "1" , FALSE, NA , NA , NA , NA , "CO2" , NA , - "moisture_content_of_soil_layer" , "kg m-2" , FALSE, NA , NA , NA , NA , NA , NA , - "soil_temperature" , "K" , FALSE, "soilT" , NA , NA , NA , "TS1 *(NOT DONE)*" , NA , - "relative_humidity" , "%" , FALSE, "relative_humidity" , "rhurs" , NA , "rhum" , "RH" , NA , - "specific_humidity" , "1" , TRUE, "specific_humidity" , NA , "qair" , "shum" , "CALC(RH)" , NA , - "water_vapor_saturation_deficit" , "Pa" , FALSE, "VPD" , NA , NA , NA , "VPD *(NOT DONE)*" , NA , - "surface_downwelling_longwave_flux_in_air" , "W m-2" , TRUE, "same" , "rldsAdjust" , "lwdown" , "dlwrf" , "Rgl" , "strd", - "surface_downwelling_shortwave_flux_in_air" , "W m-2" , TRUE, "solar_radiation" , "rsdsAdjust" , "swdown" , "dswrf" , "Rg" , "ssrd", - "surface_downwelling_photosynthetic_photon_flux_in_air" , "mol m-2 s-1" , FALSE, "PAR" , NA , NA , NA , "PAR *(NOT DONE)*" , NA , - "precipitation_flux" , "kg m-2 s-1" , TRUE, "cccc" , "prAdjust" , "rain" , "acpc" , "PREC (mm/s)" , "tp" , - "wind_to_direction" , "degrees" , FALSE, "wind_direction" , NA , NA , NA , "WD" , NA , - "wind_speed" , "m s-1" , FALSE, "Wspd" , NA , NA , NA , "WS" , NA , - "eastward_wind" , "m s-1" , TRUE, "eastward_wind" , NA , NA , NA , "CALC(WS+WD)" , "u10" , - "northward_wind" , "m s-1" , TRUE, "northward_wind" , NA , NA , NA , "CALC(WS+WD)" , "v10" , - "volume_fraction_of_condensed_water_in_soil" , "1" , FALSE, "soilM" , NA , NA , NA , "SWC_1" , "swvl1" -) + ~`cf_standard_name` , ~units , ~is_required, ~bety , ~isimip , ~cruncep , ~narr , ~ameriflux , ~era5 , ~era5_cds, + "air_temperature" , "K" , TRUE, "airT" , "tasAdjust" , "tair" , "air" , "TA (C)" , "t2m" , NA, + "air_temperature_max" , "K" , FALSE, NA , "tasmaxAdjust" , NA , "tmax" , NA , NA , NA, + "air_temperature_min" , "K" , FALSE, NA , "tasminAdjust" , NA , "tmin" , NA , NA , NA, + "air_pressure" , "Pa" , TRUE, "air_pressure" , NA , NA , NA , "PRESS (KPa)" , "sp" , NA, + "dew_point_temperature" , "K" , FALSE, NA , NA , NA , NA , NA , "d2m" , NA, + "mole_fraction_of_carbon_dioxide_in_air" , "1" , FALSE, NA , NA , NA , NA , "CO2" , NA , NA, + "moisture_content_of_soil_layer" , "kg m-2" , FALSE, NA , NA , NA , NA , NA , NA , NA, + "soil_temperature" , "K" , FALSE, "soilT" , NA , NA , NA , "TS1 *(NOT DONE)*" , NA , NA, + "relative_humidity" , "%" , FALSE, "relative_humidity" , "rhurs" , NA , "rhum" , "RH" , NA , NA, + "specific_humidity" , "1" , TRUE, "specific_humidity" , NA , "qair" , "shum" , "CALC(RH)" , NA , NA, + "water_vapor_saturation_deficit" , "Pa" , FALSE, "VPD" , NA , NA , NA , "VPD *(NOT DONE)*" , NA , NA, + "surface_downwelling_longwave_flux_in_air" , "W m-2" , TRUE, "same" , "rldsAdjust" , "lwdown" , "dlwrf" , "Rgl" , "strd" , NA, + "surface_downwelling_shortwave_flux_in_air" , "W m-2" , TRUE, "solar_radiation" , "rsdsAdjust" , "swdown" , "dswrf" , "Rg" , "ssrd" , "surface_solar_radiation_downwards", + "surface_downwelling_photosynthetic_photon_flux_in_air" , "mol m-2 s-1" , FALSE, "PAR" , NA , NA , NA , "PAR *(NOT DONE)*" , NA , NA, + "precipitation_flux" , "kg m-2 s-1" , TRUE, "cccc" , "prAdjust" , "rain" , "acpc" , "PREC (mm/s)" , "tp" , NA, + "wind_to_direction" , "degrees" , FALSE, "wind_direction" , NA , NA , NA , "WD" , NA , NA, + "wind_speed" , "m s-1" , FALSE, "Wspd" , NA , NA , NA , "WS" , NA , NA, + "eastward_wind" , "m s-1" , TRUE, "eastward_wind" , NA , NA , NA , "CALC(WS+WD)" , "u10" , NA, + "northward_wind" , "m s-1" , TRUE, "northward_wind" , NA , NA , NA , "CALC(WS+WD)" , "v10" , NA, + "volume_fraction_of_condensed_water_in_soil" , "1" , FALSE, "soilM" , NA , NA , NA , "SWC_1" , "swvl1" , "volumetric_soil_water_layer_1" +) \ No newline at end of file diff --git a/modules/data.atmosphere/R/translate_met_varnames.R b/modules/data.atmosphere/R/translate_met_varnames.R new file mode 100644 index 00000000000..798a19cf051 --- /dev/null +++ b/modules/data.atmosphere/R/translate_met_varnames.R @@ -0,0 +1,89 @@ +#' Translate meteorological variable names between naming conventions +#' +#' Looks up variable names in one column of the PEcAn standard meteorology +#' table and returns the corresponding names from another column. +#' +#' @param vars Character vector of variable names to translate. +#' @param from Character scalar. Column in \code{table} containing the +#' input naming convention. +#' @param to Character scalar. Column in \code{table} containing the +#' output naming convention. +#' @param table Data frame used as the lookup source. Defaults to +#' \code{pecan_standard_met_table}. +#' +#' @return Named character vector of translated variable names. Names are +#' the input values; elements are the mapped values. Variables not found +#' in \code{table[[from]]} return \code{NA} and emit a log warning. +#' Returns \code{character(0)} for empty input. +#' +#' @details +#' Rows where \code{table[[from]]} is \code{NA} or empty string are excluded +#' from the lookup. Translation is not guaranteed to be one-to-one; ensure +#' uniqueness in \code{table[[to]]} if round-trip mapping is required. +#' +#' @examples +#' # ERA5 CDS name to CF standard name +#' translate_met_varnames( +#' "surface_solar_radiation_downwards", +#' from = "era5_cds", +#' to = "cf_standard_name" +#' ) +#' +#' # CF standard name to ERA5 CDS name +#' translate_met_varnames( +#' "surface_downwelling_shortwave_flux_in_air", +#' from = "cf_standard_name", +#' to = "era5_cds" +#' ) +#' +#' @export +translate_met_varnames <- function(vars, + from, + to, + table = pecan_standard_met_table) { + if (length(vars) == 0L) return(character(0)) + + lookup <- table |> + dplyr::select(dplyr::all_of(c(from, to))) |> + dplyr::filter(!is.na(.data[[from]]), .data[[from]] != "") + + result <- lookup[[to]] + names(result) <- lookup[[from]] + + translated <- result[vars] + + unknown <- vars[is.na(translated)] + if (length(unknown) > 0L) { + PEcAn.logger::logger.warn( + "translate_met_varnames: no", to, "mapping for", from, + "variable(s):", paste(unknown, collapse = ", "), + "-- returning NA for those entries" + ) + } + + translated +} + + +#' Translate ERA5 CDS API names to CF standard names +#' +#' @param cds_vars Character vector of ERA5 CDS API variable names. +#' @return Named character vector of CF standard names. +#' @noRd +cds_to_cf_varnames <- function(cds_vars) { + translate_met_varnames(cds_vars, + from = "era5_cds", + to = "cf_standard_name") +} + + +#' Translate CF standard names to ERA5 CDS API names +#' +#' @param cf_vars Character vector of CF standard names. +#' @return Named character vector of ERA5 CDS variable names. +#' @noRd +cf_to_cds_varnames <- function(cf_vars) { + translate_met_varnames(cf_vars, + from = "cf_standard_name", + to = "era5_cds") +} \ No newline at end of file diff --git a/modules/data.atmosphere/tests/testthat/test-era5-cf-varname-map.R b/modules/data.atmosphere/tests/testthat/test-era5-cf-varname-map.R index b5ef1ab9d29..3923c0c54d2 100644 --- a/modules/data.atmosphere/tests/testthat/test-era5-cf-varname-map.R +++ b/modules/data.atmosphere/tests/testthat/test-era5-cf-varname-map.R @@ -1,9 +1,8 @@ -# Tests for internal ERA5 CDS <-> CF variable name translation utilities. -# Functions under test: cds_to_cf_varnames(), cf_to_cds_varnames() +# Tests for meteorological variable name translation utilities. +# Functions under test: translate_met_varnames(), +# cds_to_cf_varnames(), cf_to_cds_varnames() # Return-value contract for: check_met_coverage_for_fallback() -# Internal functions are not exported; access via ::: in tests. - # --------------------------------------------------------------------------- # cds_to_cf_varnames — forward direction # --------------------------------------------------------------------------- @@ -46,22 +45,16 @@ test_that("multiple CDS names translate correctly and preserve order", { ) }) -test_that("unknown CDS name produces a warning and returns NA", { - expect_warning( - result <- PEcAn.data.atmosphere:::cds_to_cf_varnames( - "not_a_real_cds_variable" - ), - regexp = "no CF mapping" +test_that("unknown CDS name returns NA", { + result <- PEcAn.data.atmosphere:::cds_to_cf_varnames( + "not_a_real_cds_variable" ) expect_true(is.na(unname(result))) }) test_that("unknown CDS name does not corrupt translation of known names in same call", { - expect_warning( - result <- PEcAn.data.atmosphere:::cds_to_cf_varnames( - c("surface_solar_radiation_downwards", "not_a_real_cds_variable") - ), - regexp = "no CF mapping" + result <- PEcAn.data.atmosphere:::cds_to_cf_varnames( + c("surface_solar_radiation_downwards", "not_a_real_cds_variable") ) expect_identical( unname(result[[1]]), @@ -108,12 +101,9 @@ test_that("output names of cf_to_cds_varnames equal the input CF names", { expect_identical(names(result), input) }) -test_that("unknown CF name produces a warning and returns NA", { - expect_warning( - result <- PEcAn.data.atmosphere:::cf_to_cds_varnames( - "not_a_real_cf_variable" - ), - regexp = "no CDS mapping" +test_that("unknown CF name returns NA", { + result <- PEcAn.data.atmosphere:::cf_to_cds_varnames( + "not_a_real_cf_variable" ) expect_true(is.na(unname(result))) }) @@ -151,16 +141,22 @@ test_that("round-trip cf_to_cds then cds_to_cf recovers original name", { expect_identical(restored, original) }) -test_that("every entry in era5_cds_to_cf_varnames survives a full round-trip", { - map <- PEcAn.data.atmosphere:::era5_cds_to_cf_varnames - for (cds_name in names(map)) { - cf_name <- unname(PEcAn.data.atmosphere:::cds_to_cf_varnames(cds_name)) - restored <- unname(PEcAn.data.atmosphere:::cf_to_cds_varnames(cf_name)) - expect_identical( - restored, cds_name, - label = paste("round-trip failed for CDS name:", cds_name) - ) - } +test_that("pecan_standard_met_table has era5_cds column", { + expect_true("era5_cds" %in% names(pecan_standard_met_table)) +}) + +test_that("era5_cds column has correct value for shortwave radiation", { + row <- pecan_standard_met_table[ + pecan_standard_met_table$cf_standard_name == + "surface_downwelling_shortwave_flux_in_air", ] + expect_identical(row$era5_cds, "surface_solar_radiation_downwards") +}) + +test_that("era5_cds column has correct value for soil moisture", { + row <- pecan_standard_met_table[ + pecan_standard_met_table$cf_standard_name == + "volume_fraction_of_condensed_water_in_soil", ] + expect_identical(row$era5_cds, "volumetric_soil_water_layer_1") }) # --------------------------------------------------------------------------- @@ -299,7 +295,7 @@ test_that("PAR never appears in fill_vars_cds even when PAR coverage is zero", { any(grepl("photosynthetic|par", result$fill_vars_cds, ignore.case = TRUE)) ) expect_false( - any(grepl("photosynthetic|par", result$fill_vars_cf, ignore.case = TRUE)) + any(grepl("photosynthetic|par", result$fill_vars_cf, ignore.case = TRUE)) ) })