Skip to content

Commit 1e19bd3

Browse files
authored
Merge pull request #4040 from ayushman1210/automated-reporting-and-visualization
feat: implement automated reporting and visualization layer
2 parents d9d5380 + 6db9bd6 commit 1e19bd3

20 files changed

Lines changed: 547 additions & 99 deletions

docker/depends/pecan_package_dependencies.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@
481481
"purrr",">= 0.2.3","base/workflow","Imports",FALSE
482482
"purrr",">= 0.2.3","modules/data.atmosphere","Imports",FALSE
483483
"pwr","*","modules/rtm","Suggests",FALSE
484+
"quarto","*","modules/benchmark","Suggests",FALSE
484485
"R.utils","*","base/db","Imports",FALSE
485486
"randomForest","*","modules/assim.sequential","Suggests",FALSE
486487
"randtoolbox","*","base/utils","Suggests",FALSE

modules/benchmark/DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Imports:
4646
zoo,
4747
yaml
4848
Suggests:
49+
quarto,
4950
PEcAn.data.land,
5051
testthat (>= 2.0.0)
5152
License: BSD_3_clause + file LICENSE

modules/benchmark/NAMESPACE

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export(clean_settings_BRR)
1313
export(create_BRR)
1414
export(define_benchmark)
1515
export(format_wide2long)
16+
export(generate_validation_report)
1617
export(load_and_map_data)
1718
export(load_csv)
1819
export(load_data)
@@ -42,10 +43,6 @@ export(read_settings_BRR)
4243
export(register_metric)
4344
export(run_benchmark)
4445
importFrom(dplyr,rename)
45-
importFrom(ggplot2,geom_path)
46-
importFrom(ggplot2,geom_point)
47-
importFrom(ggplot2,ggplot)
48-
importFrom(ggplot2,labs)
4946
importFrom(magrittr,"%>%")
5047
importFrom(rlang,.data)
5148
importFrom(yaml,read_yaml)

modules/benchmark/R/align_data.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,4 @@ align_data <- function(model.calc, obvs.calc, var, align_method = "match_timeste
117117
}
118118

119119
return(dat)
120-
} # align_data
120+
} # align_data
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
##' Generate Validation Benchmark Report
2+
##'
3+
##' @param benchmark_results A list containing `metrics` (data.frame), `aligned_data` (data.frame), and `plots` (list of ggplot objects) returned by the validation pipeline.
4+
##' @param output_file The path where the compiled report should be saved (e.g., "validation_report.html").
5+
##' @param template The path to the Quarto template. Defaults to the one provided in the package `inst/reports/Validation_report.qmd`.
6+
##'
7+
##' @author PEcAn Project
8+
##' @export
9+
generate_validation_report <- function(benchmark_results, output_file = "Validation_report.html", template = NULL) {
10+
PEcAn.logger::logger.info("Generating Validation Benchmark Report...")
11+
12+
if (is.null(template)) {
13+
template <- system.file("reports", "Validation_report.qmd", package = "PEcAn.benchmark")
14+
if (template == "") {
15+
# Fallback for development mode
16+
template <- file.path(getwd(), "inst", "reports", "Validation_report.qmd")
17+
}
18+
}
19+
20+
if (!file.exists(template)) {
21+
PEcAn.logger::logger.severe("Template file not found:", template)
22+
}
23+
24+
if (!requireNamespace("quarto", quietly = TRUE)) {
25+
PEcAn.logger::logger.severe("The 'quarto' package is required to generate the report.")
26+
}
27+
28+
# Ensure absolute paths
29+
output_file <- normalizePath(output_file, mustWork = FALSE)
30+
output_dir <- dirname(output_file)
31+
32+
if (!dir.exists(output_dir)) {
33+
dir.create(output_dir, recursive = TRUE)
34+
}
35+
36+
# Copy template to output directory to avoid permission issues in system folders
37+
temp_qmd <- file.path(output_dir, basename(template))
38+
file.copy(template, temp_qmd, overwrite = TRUE)
39+
40+
# Quarto execute_params are converted to YAML. Complex R objects like ggplots
41+
# cannot be passed via YAML. We must save them to an RDS and pass the path.
42+
results_rds <- file.path(output_dir, "benchmark_results.rds")
43+
saveRDS(benchmark_results, results_rds)
44+
45+
# Render the document
46+
tryCatch({
47+
quarto::quarto_render(
48+
input = temp_qmd,
49+
output_file = basename(output_file),
50+
execute_params = list(benchmark_results = results_rds)
51+
)
52+
53+
PEcAn.logger::logger.info("Validation report successfully generated at:", output_file)
54+
}, error = function(e) {
55+
PEcAn.logger::logger.severe("Failed to render validation report:", e$message)
56+
}, finally = {
57+
# Clean up the temporary template file
58+
if (file.exists(temp_qmd)) {
59+
file.remove(temp_qmd)
60+
}
61+
})
62+
63+
return(invisible(output_file))
64+
}
Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
11
#' @name metric_Coverage
22
#' @title Prediction Interval Coverage
33
#' @export
4-
#' @param dat dataframe with columns `model_q025` and `model_q975`
4+
#' @param dat dataframe with columns `model_q05` and `model_q95` (or `model_q025` and `model_q975`)
55
#' @param ... ignored
6-
#' @return A numeric value representing the fraction of observations that fall within the 95% prediction interval.
6+
#' @return A numeric value representing the fraction of observations that fall within the prediction interval.
77
#' @details
88
#' Measures the fraction of observations that fall within the model's
9-
#' stated 95% prediction interval.
9+
#' stated prediction interval (defaults to 90% interval via `model_q05`/`model_q95`, or 95% via `model_q025`/`model_q975`).
1010

1111
metric_Coverage <- function(dat, ...) {
12-
if (!"model_q025" %in% names(dat) || !"model_q975" %in% names(dat)) {
13-
PEcAn.logger::logger.severe("Metric Coverage requires 'model_q025' and 'model_q975' columns in the dataset.")
12+
q_low <- NULL
13+
q_high <- NULL
14+
15+
if (all(c("model_q05", "model_q95") %in% names(dat))) {
16+
q_low <- dat$model_q05
17+
q_high <- dat$model_q95
18+
} else if (all(c("model_q025", "model_q975") %in% names(dat))) {
19+
q_low <- dat$model_q025
20+
q_high <- dat$model_q975
21+
} else {
22+
PEcAn.logger::logger.severe("Metric Coverage requires quantile columns ('model_q05'/'model_q95' or 'model_q025'/'model_q975') in the dataset.")
1423
}
1524

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

18-
valid <- !is.na(dat$obvs) & !is.na(dat$model_q025) & !is.na(dat$model_q975)
19-
covered <- dat$obvs[valid] >= dat$model_q025[valid] & dat$obvs[valid] <= dat$model_q975[valid]
20-
27+
valid <- !is.na(dat$obvs) & !is.na(q_low) & !is.na(q_high)
28+
if (!any(valid)) {
29+
return(NA_real_)
30+
}
31+
32+
covered <- dat$obvs[valid] >= q_low[valid] & dat$obvs[valid] <= q_high[valid]
2133
return(mean(covered))
2234
}

modules/benchmark/R/metric_residual_plot.R

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,105 @@
22
##'
33
##' @param metric_dat dataframe to plot, with at least columns `time`, `model`, `obvs`
44
##' @param var variable name, used as plot title
5+
##' @param unit measurement unit for the variable, added to the y-axis label; NULL to omit
56
##' @param filename path to save plot, or NA to not save
67
##' @param draw.plot logical: Return the plot object?
78
##'
89
##' @author Betsy Cowdery
910
##' @export
10-
metric_residual_plot <- function(metric_dat, var, filename = NA, draw.plot = is.na(filename)) {
11+
metric_residual_plot <- function(metric_dat, var, unit = NULL, filename = NA, draw.plot = is.na(filename)) {
1112
PEcAn.logger::logger.info("Metric: Residual Plot")
1213

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

17-
p <- ggplot2::ggplot(data = metric_dat, ggplot2::aes(x = .data$time))
18-
p <- p + ggplot2::geom_path(ggplot2::aes(y = .data$zeros), colour = "#666666", size = 2, linetype = 2, lineend = "round")
19-
p <- p + ggplot2::geom_point(ggplot2::aes(y = .data$diff), size = 4, colour = "#619CFF")
20-
p <- p + ggplot2::labs(title = var, x = "years", y = "abs(model - observation)")
16+
if (!"time" %in% colnames(metric_dat)) {
17+
metric_dat$time <- seq_len(nrow(metric_dat))
18+
} else {
19+
date.time <- try(as.Date(as.character(metric_dat$time)), silent = TRUE)
20+
if (!inherits(date.time, "try-error") && !all(is.na(date.time))) {
21+
metric_dat$time <- date.time
22+
}
23+
}
24+
25+
# Calculate residuals (Model - Observation)
26+
metric_dat$diff <- metric_dat$model - metric_dat$obvs
27+
28+
is_multi_site <- "site" %in% colnames(metric_dat) && length(unique(metric_dat$site)) > 1
29+
is_multi_var <- "variable" %in% colnames(metric_dat) && length(unique(metric_dat$variable)) > 1
30+
31+
if (!"site" %in% colnames(metric_dat)) metric_dat$site <- "All"
32+
if (!"variable" %in% colnames(metric_dat)) metric_dat$variable <- var
33+
34+
facet_groups <- split(metric_dat, list(metric_dat$site, metric_dat$variable), drop = TRUE)
35+
36+
fit_results <- lapply(names(facet_groups), function(g) {
37+
sub_dat <- facet_groups[[g]]
38+
39+
time_num <- as.numeric(sub_dat$time)
40+
if (inherits(sub_dat$time, "POSIXt")) {
41+
time_num <- time_num / 86400
42+
}
43+
time_num <- time_num - min(time_num, na.rm = TRUE)
44+
sub_dat$time_num <- time_num
45+
46+
fit <- try(stats::lm(diff ~ time_num, data = sub_dat), silent = TRUE)
47+
if (!inherits(fit, "try-error") && length(stats::coef(fit)) == 2 && !is.na(stats::coef(fit)[2])) {
48+
intercept <- stats::coef(fit)[1]
49+
slope <- stats::coef(fit)[2]
50+
label_str <- sprintf("Intercept: %.3f\nSlope: %.4f / day", intercept, slope)
51+
sub_dat$trend <- stats::fitted(fit)
52+
} else {
53+
label_str <- "Trend: N/A"
54+
sub_dat$trend <- NA_real_
55+
}
56+
57+
annot <- data.frame(
58+
site = sub_dat$site[1],
59+
variable = sub_dat$variable[1],
60+
label = label_str
61+
)
62+
63+
list(sub_dat = sub_dat, annot = annot)
64+
})
65+
66+
metric_dat <- do.call(rbind, lapply(fit_results, `[[`, "sub_dat"))
67+
annotations <- do.call(rbind, lapply(fit_results, `[[`, "annot"))
68+
69+
ylab <- if (is.null(unit)) "residual (model - obs)" else sprintf("residual (%s)", unit)
70+
71+
p <- ggplot2::ggplot(data = metric_dat, ggplot2::aes(x = .data$time, y = .data$diff)) +
72+
ggplot2::geom_hline(yintercept = 0, colour = "#666666", linewidth = 1, linetype = 2) +
73+
ggplot2::geom_point(size = 2, alpha = 0.7, colour = "#619CFF") +
74+
ggplot2::geom_line(ggplot2::aes(y = .data$trend), colour = "#FF3333", linetype = "dashed", na.rm = TRUE) +
75+
ggplot2::labs(title = var, x = "time", y = ylab, colour = NULL, fill = NULL) +
76+
ggplot2::theme_minimal(base_size = 12)
77+
78+
# Add per-panel annotations
79+
p <- p + ggplot2::geom_label(
80+
data = annotations,
81+
ggplot2::aes(x = -Inf, y = Inf, label = .data$label),
82+
hjust = -0.05, vjust = 1.1,
83+
inherit.aes = FALSE,
84+
alpha = 0.8
85+
)
86+
87+
# Add facets if applicable
88+
if (is_multi_site && is_multi_var) {
89+
p <- p + ggplot2::facet_wrap(~ variable + site, scales = "free_y")
90+
} else if (is_multi_site) {
91+
p <- p + ggplot2::facet_wrap(~ site, scales = "free_y")
92+
} else if (is_multi_var) {
93+
p <- p + ggplot2::facet_wrap(~ variable, scales = "free_y")
94+
}
2195

2296
if (!is.na(filename)) {
2397
grDevices::pdf(filename, width = 10, height = 6)
24-
plot(p)
98+
print(p)
2599
grDevices::dev.off()
26100
}
27101

28102
if (draw.plot) {
29103
return(p)
30104
}
31-
} # metric_residual_plot
105+
invisible(p)
106+
} # metric_residual_plot
Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,36 @@
11
##' Scatter Plot
22
##'
33
##' @param metric_dat dataframe to plot, with at least columns `model` and `obvs`
4-
##' @param var ignored
4+
##' @param var title for the plot
5+
##' @param unit measurement unit for the variable, added to the axis labels; NULL to omit
56
##' @param filename path to save plot, or NA to not save
67
##' @param draw.plot logical: Return the plot object?
78
##'
89
##' @author Betsy Cowdery
910
##' @export
10-
11-
metric_scatter_plot <- function(metric_dat, var, filename = NA, draw.plot = is.na(filename)) {
11+
metric_scatter_plot <- function(metric_dat, var, unit = NULL, filename = NA, draw.plot = is.na(filename)) {
1212
PEcAn.logger::logger.info("Metric: Scatter Plot")
1313

14-
p <- ggplot2::ggplot(data = metric_dat)
15-
p <- p + ggplot2::geom_point(ggplot2::aes(x = .data$model, y = .data$obvs), size = 4)
16-
p <- p + ggplot2::geom_abline(slope = 1, intercept = 0, colour = "#666666",
17-
size = 2, linetype = 2)
14+
metric_dat <- as.data.frame(metric_dat)
15+
16+
xlab <- if (is.null(unit)) "modeled" else sprintf("modeled (%s)", unit)
17+
ylab <- if (is.null(unit)) "observed" else sprintf("observed (%s)", unit)
18+
19+
p <- ggplot2::ggplot(data = metric_dat, ggplot2::aes(x = .data$model, y = .data$obvs)) +
20+
ggplot2::geom_point(size = 2, alpha = 0.7, colour = "#619CFF") +
21+
ggplot2::geom_abline(slope = 1, intercept = 0, colour = "#666666",
22+
linewidth = 1, linetype = 2) +
23+
ggplot2::labs(title = var, x = xlab, y = ylab, colour = NULL, fill = NULL) +
24+
ggplot2::theme_minimal(base_size = 12)
1825

1926
if (!is.na(filename)) {
2027
grDevices::pdf(filename, width = 10, height = 6)
21-
plot(p)
28+
print(p)
2229
grDevices::dev.off()
2330
}
2431

2532
if (draw.plot) {
2633
return(p)
2734
}
28-
35+
invisible(p)
2936
} # metric_scatter_plot

0 commit comments

Comments
 (0)