Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b6d18a1
feat: implement automated reporting and visualization layer
ayushman1210 Jun 30, 2026
0a3b5e4
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Jun 30, 2026
5e01b22
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Jul 3, 2026
1f2609f
Fix: Serialize benchmark_results to RDS to prevent Quarto YAML parsin…
ayushman1210 Jul 3, 2026
4da8557
Fix CI: Add quarto dependency, update tests, and sync Roxygen docs
ayushman1210 Jul 6, 2026
8efa210
Feat: Add uncertainty ribbon and error bars to timeseries plot
ayushman1210 Jul 6, 2026
9635b36
chore: remove local scratch script from tracking
ayushman1210 Jul 6, 2026
4113e58
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Jul 8, 2026
c92ff29
Update modules/benchmark/tests/testthat/test-visualization.R
ayushman1210 Jul 14, 2026
958f37d
Refine validation toolkit based on maintainer feedback
ayushman1210 Jul 14, 2026
1ee57d5
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Jul 20, 2026
3278216
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Jul 21, 2026
99e6e19
fix(benchmark): update docs, dependencies, and unit tests for timeser…
ayushman1210 Jul 24, 2026
776c019
refactor(benchmark): delegate plot coverage to metric_Coverage, reuse…
ayushman1210 Jul 24, 2026
92604f8
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Jul 28, 2026
6788ce4
Merge branch 'develop' into automated-reporting-and-visualization
dlebauer Jul 30, 2026
7990ebc
adressed review comments on metric coverage.PMU plot label and docs
ayushman1210 Aug 4, 2026
b53fd2c
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Aug 4, 2026
87dc809
document unit arg in timeseries plot
divine7022 Aug 10, 2026
8e6df46
document unit arg in residual plot
divine7022 Aug 10, 2026
a578ba4
document unit arg in scatter plot
divine7022 Aug 10, 2026
f2d6f0c
regenerate timeseries plot man page
divine7022 Aug 10, 2026
ca72148
regenerate residual plot man page
divine7022 Aug 10, 2026
12563ab
regenerate scatter plot man page
divine7022 Aug 10, 2026
6f43127
regenerate compute_metrics man page
divine7022 Aug 10, 2026
88fee32
use .data pronoun for label in timeseries plot annotation
divine7022 Aug 10, 2026
b77afa9
use .data pronoun for label in residual plot annotation
divine7022 Aug 10, 2026
73319c7
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Aug 10, 2026
3097520
Merge branch 'develop' into automated-reporting-and-visualization
dlebauer Aug 10, 2026
eeec791
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Aug 11, 2026
6db9bd6
Merge branch 'develop' into automated-reporting-and-visualization
ayushman1210 Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker/depends/pecan_package_dependencies.csv
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@
"purrr",">= 0.2.3","base/workflow","Imports",FALSE
"purrr",">= 0.2.3","modules/data.atmosphere","Imports",FALSE
"pwr","*","modules/rtm","Suggests",FALSE
"quarto","*","modules/benchmark","Suggests",FALSE
"R.utils","*","base/db","Imports",FALSE
"randomForest","*","modules/assim.sequential","Suggests",FALSE
"randtoolbox","*","base/utils","Suggests",FALSE
Expand Down
1 change: 1 addition & 0 deletions modules/benchmark/DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Imports:
zoo,
yaml
Suggests:
quarto,
PEcAn.data.land,
testthat (>= 2.0.0)
License: BSD_3_clause + file LICENSE
Expand Down
5 changes: 1 addition & 4 deletions modules/benchmark/NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export(clean_settings_BRR)
export(create_BRR)
export(define_benchmark)
export(format_wide2long)
export(generate_validation_report)
export(load_and_map_data)
export(load_csv)
export(load_data)
Expand Down Expand Up @@ -42,10 +43,6 @@ export(read_settings_BRR)
export(register_metric)
export(run_benchmark)
importFrom(dplyr,rename)
importFrom(ggplot2,geom_path)
importFrom(ggplot2,geom_point)
importFrom(ggplot2,ggplot)
importFrom(ggplot2,labs)
importFrom(magrittr,"%>%")
importFrom(rlang,.data)
importFrom(yaml,read_yaml)
2 changes: 1 addition & 1 deletion modules/benchmark/R/align_data.R
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,4 @@ align_data <- function(model.calc, obvs.calc, var, align_method = "match_timeste
}

return(dat)
} # align_data
} # align_data
64 changes: 64 additions & 0 deletions modules/benchmark/R/generate_validation_report.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
##' Generate Validation Benchmark Report
##'
##' @param benchmark_results A list containing `metrics` (data.frame), `aligned_data` (data.frame), and `plots` (list of ggplot objects) returned by the validation pipeline.
##' @param output_file The path where the compiled report should be saved (e.g., "validation_report.html").
##' @param template The path to the Quarto template. Defaults to the one provided in the package `inst/reports/Validation_report.qmd`.
##'
##' @author PEcAn Project
##' @export
generate_validation_report <- function(benchmark_results, output_file = "Validation_report.html", template = NULL) {
PEcAn.logger::logger.info("Generating Validation Benchmark Report...")

if (is.null(template)) {
template <- system.file("reports", "Validation_report.qmd", package = "PEcAn.benchmark")
if (template == "") {
# Fallback for development mode
template <- file.path(getwd(), "inst", "reports", "Validation_report.qmd")
}
}

if (!file.exists(template)) {
PEcAn.logger::logger.severe("Template file not found:", template)
}

if (!requireNamespace("quarto", quietly = TRUE)) {
PEcAn.logger::logger.severe("The 'quarto' package is required to generate the report.")
}

# Ensure absolute paths
output_file <- normalizePath(output_file, mustWork = FALSE)
output_dir <- dirname(output_file)

if (!dir.exists(output_dir)) {
dir.create(output_dir, recursive = TRUE)
}

# Copy template to output directory to avoid permission issues in system folders
temp_qmd <- file.path(output_dir, basename(template))
file.copy(template, temp_qmd, overwrite = TRUE)

# Quarto execute_params are converted to YAML. Complex R objects like ggplots
# cannot be passed via YAML. We must save them to an RDS and pass the path.
results_rds <- file.path(output_dir, "benchmark_results.rds")
saveRDS(benchmark_results, results_rds)

# Render the document
tryCatch({
quarto::quarto_render(
input = temp_qmd,
output_file = basename(output_file),
execute_params = list(benchmark_results = results_rds)
)

PEcAn.logger::logger.info("Validation report successfully generated at:", output_file)
}, error = function(e) {
PEcAn.logger::logger.severe("Failed to render validation report:", e$message)
}, finally = {
# Clean up the temporary template file
if (file.exists(temp_qmd)) {
file.remove(temp_qmd)
}
})

return(invisible(output_file))
}
28 changes: 20 additions & 8 deletions modules/benchmark/R/metric_Coverage.R
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
#' @name metric_Coverage
#' @title Prediction Interval Coverage
#' @export
#' @param dat dataframe with columns `model_q025` and `model_q975`
#' @param dat dataframe with columns `model_q05` and `model_q95` (or `model_q025` and `model_q975`)
#' @param ... ignored
#' @return A numeric value representing the fraction of observations that fall within the 95% prediction interval.
#' @return A numeric value representing the fraction of observations that fall within the prediction interval.
#' @details
#' Measures the fraction of observations that fall within the model's
#' stated 95% prediction interval.
#' stated prediction interval (defaults to 90% interval via `model_q05`/`model_q95`, or 95% via `model_q025`/`model_q975`).

metric_Coverage <- function(dat, ...) {
if (!"model_q025" %in% names(dat) || !"model_q975" %in% names(dat)) {
PEcAn.logger::logger.severe("Metric Coverage requires 'model_q025' and 'model_q975' columns in the dataset.")
q_low <- NULL
q_high <- NULL

if (all(c("model_q05", "model_q95") %in% names(dat))) {
q_low <- dat$model_q05
q_high <- dat$model_q95
} else if (all(c("model_q025", "model_q975") %in% names(dat))) {
q_low <- dat$model_q025
q_high <- dat$model_q975
} else {
PEcAn.logger::logger.severe("Metric Coverage requires quantile columns ('model_q05'/'model_q95' or 'model_q025'/'model_q975') in the dataset.")
}

PEcAn.logger::logger.info("Metric: Prediction Interval Coverage")

valid <- !is.na(dat$obvs) & !is.na(dat$model_q025) & !is.na(dat$model_q975)
covered <- dat$obvs[valid] >= dat$model_q025[valid] & dat$obvs[valid] <= dat$model_q975[valid]

valid <- !is.na(dat$obvs) & !is.na(q_low) & !is.na(q_high)
if (!any(valid)) {
return(NA_real_)
}

covered <- dat$obvs[valid] >= q_low[valid] & dat$obvs[valid] <= q_high[valid]
return(mean(covered))
}
95 changes: 85 additions & 10 deletions modules/benchmark/R/metric_residual_plot.R
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,105 @@
##'
##' @param metric_dat dataframe to plot, with at least columns `time`, `model`, `obvs`
##' @param var variable name, used as plot title
##' @param unit measurement unit for the variable, added to the y-axis label; NULL to omit
##' @param filename path to save plot, or NA to not save
##' @param draw.plot logical: Return the plot object?
##'
##' @author Betsy Cowdery
##' @export
metric_residual_plot <- function(metric_dat, var, filename = NA, draw.plot = is.na(filename)) {
metric_residual_plot <- function(metric_dat, var, unit = NULL, filename = NA, draw.plot = is.na(filename)) {
PEcAn.logger::logger.info("Metric: Residual Plot")

metric_dat$time <- lubridate::year(as.Date(as.character(metric_dat$time), format = "%Y"))
metric_dat$diff <- abs(metric_dat$model - metric_dat$obvs)
metric_dat$zeros <- rep(0, length(metric_dat$time))
metric_dat <- as.data.frame(metric_dat)

p <- ggplot2::ggplot(data = metric_dat, ggplot2::aes(x = .data$time))
p <- p + ggplot2::geom_path(ggplot2::aes(y = .data$zeros), colour = "#666666", size = 2, linetype = 2, lineend = "round")
p <- p + ggplot2::geom_point(ggplot2::aes(y = .data$diff), size = 4, colour = "#619CFF")
p <- p + ggplot2::labs(title = var, x = "years", y = "abs(model - observation)")
if (!"time" %in% colnames(metric_dat)) {
metric_dat$time <- seq_len(nrow(metric_dat))
} else {
date.time <- try(as.Date(as.character(metric_dat$time)), silent = TRUE)
if (!inherits(date.time, "try-error") && !all(is.na(date.time))) {
metric_dat$time <- date.time
}
}

# Calculate residuals (Model - Observation)
metric_dat$diff <- metric_dat$model - metric_dat$obvs
Comment thread
ayushman1210 marked this conversation as resolved.

is_multi_site <- "site" %in% colnames(metric_dat) && length(unique(metric_dat$site)) > 1
is_multi_var <- "variable" %in% colnames(metric_dat) && length(unique(metric_dat$variable)) > 1

if (!"site" %in% colnames(metric_dat)) metric_dat$site <- "All"
if (!"variable" %in% colnames(metric_dat)) metric_dat$variable <- var

facet_groups <- split(metric_dat, list(metric_dat$site, metric_dat$variable), drop = TRUE)

fit_results <- lapply(names(facet_groups), function(g) {
sub_dat <- facet_groups[[g]]

time_num <- as.numeric(sub_dat$time)
if (inherits(sub_dat$time, "POSIXt")) {
time_num <- time_num / 86400
}
time_num <- time_num - min(time_num, na.rm = TRUE)
sub_dat$time_num <- time_num

fit <- try(stats::lm(diff ~ time_num, data = sub_dat), silent = TRUE)
Comment thread
ayushman1210 marked this conversation as resolved.
if (!inherits(fit, "try-error") && length(stats::coef(fit)) == 2 && !is.na(stats::coef(fit)[2])) {
intercept <- stats::coef(fit)[1]
slope <- stats::coef(fit)[2]
label_str <- sprintf("Intercept: %.3f\nSlope: %.4f / day", intercept, slope)
sub_dat$trend <- stats::fitted(fit)
} else {
label_str <- "Trend: N/A"
sub_dat$trend <- NA_real_
}

annot <- data.frame(
site = sub_dat$site[1],
variable = sub_dat$variable[1],
label = label_str
)

list(sub_dat = sub_dat, annot = annot)
})

metric_dat <- do.call(rbind, lapply(fit_results, `[[`, "sub_dat"))
annotations <- do.call(rbind, lapply(fit_results, `[[`, "annot"))

ylab <- if (is.null(unit)) "residual (model - obs)" else sprintf("residual (%s)", unit)

p <- ggplot2::ggplot(data = metric_dat, ggplot2::aes(x = .data$time, y = .data$diff)) +
ggplot2::geom_hline(yintercept = 0, colour = "#666666", linewidth = 1, linetype = 2) +
ggplot2::geom_point(size = 2, alpha = 0.7, colour = "#619CFF") +
ggplot2::geom_line(ggplot2::aes(y = .data$trend), colour = "#FF3333", linetype = "dashed", na.rm = TRUE) +
ggplot2::labs(title = var, x = "time", y = ylab, colour = NULL, fill = NULL) +
ggplot2::theme_minimal(base_size = 12)

# Add per-panel annotations
p <- p + ggplot2::geom_label(
data = annotations,
ggplot2::aes(x = -Inf, y = Inf, label = .data$label),
hjust = -0.05, vjust = 1.1,
inherit.aes = FALSE,
alpha = 0.8
)

# Add facets if applicable
if (is_multi_site && is_multi_var) {
p <- p + ggplot2::facet_wrap(~ variable + site, scales = "free_y")
} else if (is_multi_site) {
p <- p + ggplot2::facet_wrap(~ site, scales = "free_y")
} else if (is_multi_var) {
p <- p + ggplot2::facet_wrap(~ variable, scales = "free_y")
}

if (!is.na(filename)) {
grDevices::pdf(filename, width = 10, height = 6)
plot(p)
print(p)
grDevices::dev.off()
}

if (draw.plot) {
return(p)
}
} # metric_residual_plot
invisible(p)
} # metric_residual_plot
25 changes: 16 additions & 9 deletions modules/benchmark/R/metric_scatter_plot.R
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
##' Scatter Plot
##'
##' @param metric_dat dataframe to plot, with at least columns `model` and `obvs`
##' @param var ignored
##' @param var title for the plot
##' @param unit measurement unit for the variable, added to the axis labels; NULL to omit
##' @param filename path to save plot, or NA to not save
##' @param draw.plot logical: Return the plot object?
##'
##' @author Betsy Cowdery
##' @export

metric_scatter_plot <- function(metric_dat, var, filename = NA, draw.plot = is.na(filename)) {
metric_scatter_plot <- function(metric_dat, var, unit = NULL, filename = NA, draw.plot = is.na(filename)) {
PEcAn.logger::logger.info("Metric: Scatter Plot")

p <- ggplot2::ggplot(data = metric_dat)
p <- p + ggplot2::geom_point(ggplot2::aes(x = .data$model, y = .data$obvs), size = 4)
p <- p + ggplot2::geom_abline(slope = 1, intercept = 0, colour = "#666666",
size = 2, linetype = 2)
metric_dat <- as.data.frame(metric_dat)

xlab <- if (is.null(unit)) "modeled" else sprintf("modeled (%s)", unit)
ylab <- if (is.null(unit)) "observed" else sprintf("observed (%s)", unit)

p <- ggplot2::ggplot(data = metric_dat, ggplot2::aes(x = .data$model, y = .data$obvs)) +
ggplot2::geom_point(size = 2, alpha = 0.7, colour = "#619CFF") +
ggplot2::geom_abline(slope = 1, intercept = 0, colour = "#666666",
linewidth = 1, linetype = 2) +
ggplot2::labs(title = var, x = xlab, y = ylab, colour = NULL, fill = NULL) +
ggplot2::theme_minimal(base_size = 12)

if (!is.na(filename)) {
grDevices::pdf(filename, width = 10, height = 6)
plot(p)
print(p)
grDevices::dev.off()
}

if (draw.plot) {
return(p)
}

invisible(p)
} # metric_scatter_plot
Loading
Loading