diff --git a/.Rbuildignore b/.Rbuildignore
index 3912071..e737dd8 100644
--- a/.Rbuildignore
+++ b/.Rbuildignore
@@ -1,3 +1,4 @@
^.*\.Rproj$
^\.Rproj\.user$
^\.github$
+^analysis$
diff --git a/NAMESPACE b/NAMESPACE
index 4ac80e2..937623b 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -44,6 +44,8 @@ export(generate_scenario_library)
export(make_stan_init_fn)
export(plot_main_figure)
export(plot_main_figure_split)
+export(plot_simulation_study_figure)
+export(plot_simulation_study_sens_figure)
export(pre_inference_checks)
export(prepare_stan_data_from_datasets)
export(run_simulation_study_generalized)
diff --git a/R/ploting_utils.R b/R/ploting_utils.R
index 3b2068a..60d2ce2 100644
--- a/R/ploting_utils.R
+++ b/R/ploting_utils.R
@@ -1524,3 +1524,130 @@ plot_main_figure_split <- function(
)
)
}
+
+
+# Internal core builder shared by the main and sensitivity simulation figures.
+# x_var: column to put on the x-axis ("summary_type_label" or "dist_type").
+# facet_var: column to facet by, or NULL for no faceting.
+# color_var / shape_var: columns mapped to colour and shape aesthetics.
+.sim_figure_core <- function(summary_res, base_size = 9,
+ x_var = "summary_type_label",
+ x_lab = "Summary type",
+ facet_var = "dist_type",
+ color_var = "n_datasets_bucket",
+ shape_var = "n_obs_bucket",
+ color_lab = "N datasets",
+ shape_lab = "N obs per study") {
+ dat <- .pred_buckets(summary_res)
+
+ .row <- function(data, y_col, ref_line = NULL, y_lab,
+ hide_x = FALSE, hide_strip = FALSE) {
+ p <- ggplot(data,
+ aes(x = .data[[x_var]],
+ y = .data[[y_col]],
+ color = .data[[color_var]],
+ shape = .data[[shape_var]]))
+
+ if (!is.null(ref_line))
+ p <- p + geom_hline(yintercept = ref_line, linetype = "dashed",
+ color = "red", linewidth = 0.4)
+
+ p <- p +
+ geom_point(alpha = 0.7,
+ size = 1,
+ position = position_jitterdodge(jitter.width = 0.1,
+ dodge.width = 0.4)) +
+ scale_color_aaas() +
+ labs(x = if (hide_x) NULL else x_lab,
+ y = y_lab,
+ color = color_lab, shape = shape_lab) +
+ theme_minimal(base_size = base_size) +
+ theme(
+ axis.text.x = if (hide_x) element_blank() else element_text(angle = 40, hjust = 1),
+ axis.ticks.x = if (hide_x) element_blank() else element_line(),
+ strip.text = if (hide_strip) element_blank() else element_text()
+ )
+
+ if (!is.null(facet_var))
+ p <- p + facet_wrap(reformulate(facet_var), nrow = 1,
+ labeller = label_value, scales = "free_x")
+
+ return(p)
+ }
+
+ p1 <- .row(dat, "mean_wis", ref_line = NULL, y_lab = "Mean WIS",
+ hide_x = TRUE, hide_strip = FALSE)
+ p2 <- .row(dat, "coverage_pred_median", ref_line = 0.95, y_lab = "Coverage - P50",
+ hide_x = TRUE, hide_strip = TRUE)
+ p3 <- .row(dat, "bias_pred_median", ref_line = 0, y_lab = "Bias - P50 (days)",
+ hide_x = TRUE, hide_strip = TRUE)
+ p4 <- .row(dat, "coverage_pred_q95", ref_line = 0.95, y_lab = "Coverage - P95",
+ hide_x = TRUE, hide_strip = TRUE)
+ p5 <- .row(dat, "bias_pred_q95", ref_line = 0, y_lab = "Bias - P95 (days)",
+ hide_x = FALSE, hide_strip = TRUE)
+
+ patchwork::wrap_plots(p1, p2, p3, p4, p5, ncol = 1) +
+ patchwork::plot_layout(guides = "collect") +
+ patchwork::plot_annotation(tag_levels = "A") &
+ theme(legend.position = "bottom",
+ plot.tag = element_text(face = "bold"))
+}
+
+#' Main-text simulation study figure
+#'
+#' Assembles a five-row composite figure from the simulation study summary,
+#' excluding sensitivity scenarios (\code{TauSens} / \code{Mu0Sens}).
+#'
+#' @param summary_res A data frame returned by \code{create_results_summary()}.
+#' @param base_size Base font size passed to \code{theme_minimal()}.
+#' @return A \code{patchwork} object.
+#' @export
+plot_simulation_study_figure <- function(summary_res, base_size = 9) {
+ summary_res <- summary_res %>%
+ filter(!grepl("TauSens|Mu0Sens", scenario_name))
+ fig <- .sim_figure_core(summary_res, base_size = base_size)
+ fig & scale_shape_manual(values = c("5" = 4, "10" = 3, "20" = 8, "25+" = 5))
+}
+
+#' Supplementary simulation study figure — sensitivity scenarios
+#'
+#' Same layout as \code{plot_simulation_study_figure()} but restricted to
+#' \code{TauSens} and \code{Mu0Sens} scenarios.
+#'
+#' @param summary_res A data frame returned by \code{create_results_summary()}.
+#' @param base_size Base font size passed to \code{theme_minimal()}.
+#' @return A \code{patchwork} object.
+#' @export
+plot_simulation_study_sens_figure <- function(summary_res, base_size = 9) {
+ summary_res <- summary_res %>%
+ filter(grepl("TauSens|Mu0Sens", scenario_name)) %>%
+ mutate(
+ sens_type = if_else(grepl("TauSens", scenario_name),
+ "τ sensitivity", "μ₀ sensitivity"),
+ sens_value = case_when(
+ grepl("TauSens", scenario_name) ~
+ paste0("τ = ", stringr::str_extract(scenario_name, "(?<=tau)[0-9.]+")),
+ TRUE ~
+ stringr::str_replace(
+ scenario_name,
+ "^Mu0Sens_(?:lognormal|gamma|weibull|burr12|gengamma)_(.+)_D\\d+.*",
+ "μ₀ = \\1"
+ )
+ )
+ ) %>%
+ mutate(
+ sens_value = forcats::fct_reorder(sens_value, grepl("μ", sens_value))
+ )
+
+ fig <- .sim_figure_core(summary_res, base_size = base_size,
+ x_var = "dist_type",
+ x_lab = "Distribution",
+ facet_var = NULL,
+ color_var = "sens_value",
+ shape_var = "sens_type",
+ color_lab = "Parameter value",
+ shape_lab = "Sensitivity")
+
+ fig & guides(color = guide_legend(nrow = 2, byrow = FALSE),
+ shape = guide_legend(nrow = 2, byrow = FALSE))
+}
diff --git a/R/table_utils.R b/R/table_utils.R
index e6fa70c..2f5c326 100644
--- a/R/table_utils.R
+++ b/R/table_utils.R
@@ -671,7 +671,7 @@ generate_data_table <- function(
) {
stopifnot(is.list(datasets), length(datasets) > 0L, !is.null(names(datasets)))
- col_spec <- "|C{4cm}|C{2.0cm}|C{2.5cm}|R{0.7cm}|C{5.5cm}|C{6cm}|"
+ col_spec <- "|C{4cm}|C{2.0cm}|C{2.5cm}|R{0.7cm}|C{7.3cm}|C{6cm}|"
header_cells <- paste(
"\\textbf{Reference}",
diff --git a/README.md b/README.md
index 155aa67..cbbcb62 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,31 @@
[](https://lifecycle.r-lib.org/articles/stages.html#experimental)
-Methods to compute delay distributions from summary statistics
+Bayesian hierarchical synthesis of incubation period distributions from individual-level data and published summary statistics.
+## Overview
+
+The incubation period -- the interval between pathogen exposure and symptom onset -- is a critical parameter for quarantine policy and outbreak response, yet individual-level exposure data remain scarce in the published literature. For most pathogens, only summary statistics are available, and restricting inference to individual-level data alone would leave too few datasets for reliable between-study heterogeneity estimation.
+
+**ddsynth** introduces a Bayesian hierarchical framework that jointly models individual-level observations and published summary statistics under a unified federated analysis approach. Key features:
+
+- Accurately recovers incubation period distributions across a range of data availability scenarios
+- Outperforms approaches that use summary data alone
+- Quantifies between-study heterogeneity, including by outbreak country, pathogen variant, and exposure pathway
+- Covers 18 pathogens with outbreak potential, spanning six pathogen groups
+
+## Dataset coverage
+
+The package includes a curated dataset of incubation period studies spanning outbreaks worldwide:
+
+
+
+Datasets cover six pathogen groups:
+
+
+
## Prerequisites
### R
diff --git a/analysis/run_meta_analysis.R b/analysis/run_meta_analysis.R
index 94cbe12..8efa316 100644
--- a/analysis/run_meta_analysis.R
+++ b/analysis/run_meta_analysis.R
@@ -159,18 +159,25 @@ dir.create(OUTDIR, showWarnings = FALSE, recursive = TRUE)
)
}
-# Extract tau posterior summary from the Stan lognormal fit.
-# Prefers the "all" slot (matching the unfiltered meta-analysis);
-# falls back to "filtered" if "all" is unavailable.
-.get_stan_tau <- function(main_res, pathogen_name) {
+# Extract tau posterior summary from the best-fitting Stan model.
+# Best distribution is taken from model_weights (highest stacking weight).
+# Prefers the "all" slot; falls back to "filtered" if "all" is unavailable.
+# Returns NULL when < 5 datasets were used (tau not reliably estimated).
+.get_stan_tau <- function(main_res, model_weights, pathogen_name) {
pg <- main_res[[pathogen_name]]
if (is.null(pg)) return(NULL)
- slot_nm <- if (!is.null(pg[["all"]][["lognormal"]])) "all"
- else if (!is.null(pg[["filtered"]][["lognormal"]])) "filtered"
+ # Identify best-fitting distribution for this pathogen
+ wts <- model_weights[[pathogen_name]]
+ best_dist <- if (!is.null(wts) && length(wts) > 0L) names(which.max(wts)) else "lognormal"
+
+ slot_nm <- if (!is.null(pg[["all"]][[best_dist]])) "all"
+ else if (!is.null(pg[["filtered"]][[best_dist]])) "filtered"
else return(NULL)
- sfit <- pg[[slot_nm]][["lognormal"]][["fit"]]
+ fit_obj <- pg[[slot_nm]][[best_dist]]
+ if (length(fit_obj$datasets) < 5L) return(NULL)
+ sfit <- fit_obj$fit
if (is.null(sfit)) return(NULL)
s <- tryCatch(
@@ -180,10 +187,11 @@ dir.create(OUTDIR, showWarnings = FALSE, recursive = TRUE)
if (is.null(s)) return(NULL)
list(
- median = s[1, "50%"],
- lo = s[1, "2.5%"],
- hi = s[1, "97.5%"],
- slot = slot_nm
+ median = s[1, "50%"],
+ lo = s[1, "2.5%"],
+ hi = s[1, "97.5%"],
+ slot = slot_nm,
+ best_dist = best_dist
)
}
@@ -288,13 +296,23 @@ message("\n", length(results), " pathogens analysed.")
# Generate and save forest plots
# =============================================================================
-# Load Stan results to annotate forest plots with Bayesian tau estimates.
+# Load Stan results and model weights for the tau comparison table.
stan_res_path <- here("results", "main_results.rds")
-stan_res <- if (file.exists(stan_res_path)) {
+weights_path <- here("results", "model_weights.rds")
+
+stan_res <- if (file.exists(stan_res_path)) {
message("Loading Stan results from ", stan_res_path)
readRDS(stan_res_path)
} else {
- message("main_results.rds not found — forest plots will not include Stan tau")
+ message("main_results.rds not found — Stan tau will not be reported")
+ NULL
+}
+
+model_weights <- if (file.exists(weights_path)) {
+ message("Loading model weights from ", weights_path)
+ readRDS(weights_path)
+} else {
+ message("model_weights.rds not found — defaulting to lognormal for Stan tau")
NULL
}
@@ -319,7 +337,7 @@ summary_rows <- lapply(names(results), function(nm) {
res <- results[[nm]]
m <- res$meta
- stan_tau <- if (!is.null(stan_res)) .get_stan_tau(stan_res, nm) else NULL
+ stan_tau <- if (!is.null(stan_res)) .get_stan_tau(stan_res, model_weights, nm) else NULL
data.frame(
pathogen = nm,
@@ -330,10 +348,11 @@ summary_rows <- lapply(names(results), function(nm) {
ci_upper = round(exp(m$upper.random), 2),
I2_pct = round(m$I2 * 100, 1),
metamean_tau = round(sqrt(m$tau2), 3),
- stan_tau_median = if (!is.null(stan_tau)) round(stan_tau$median, 3) else NA_real_,
- stan_tau_lo = if (!is.null(stan_tau)) round(stan_tau$lo, 3) else NA_real_,
- stan_tau_hi = if (!is.null(stan_tau)) round(stan_tau$hi, 3) else NA_real_,
- stan_slot = if (!is.null(stan_tau)) stan_tau$slot else NA_character_,
+ stan_tau_median = if (!is.null(stan_tau)) round(stan_tau$median, 3) else NA_real_,
+ stan_tau_lo = if (!is.null(stan_tau)) round(stan_tau$lo, 3) else NA_real_,
+ stan_tau_hi = if (!is.null(stan_tau)) round(stan_tau$hi, 3) else NA_real_,
+ stan_best_dist = if (!is.null(stan_tau)) stan_tau$best_dist else NA_character_,
+ stan_slot = if (!is.null(stan_tau)) stan_tau$slot else NA_character_,
stringsAsFactors = FALSE
)
})
@@ -345,9 +364,9 @@ write.csv(summary_tbl,
saveRDS(results, file.path(OUTDIR, "meta_analysis_results.rds"))
-message("\nSummary table (metamean τ vs Stan log-normal τ):\n")
+message("\nSummary table (metamean τ vs Stan τ from best-fitting distribution):\n")
print(summary_tbl[, c("pathogen", "k", "I2_pct", "metamean_tau",
- "stan_tau_median", "stan_tau_lo", "stan_tau_hi")],
+ "stan_best_dist", "stan_tau_median", "stan_tau_lo", "stan_tau_hi")],
row.names = FALSE)
message("\nAll outputs written to: ", OUTDIR)
@@ -379,3 +398,340 @@ Discrepancies can arise from:
towards zero relative to the REML estimator.
──────────────────────────────────────────────────────────────────────────────
")
+
+
+# =============================================================================
+# LaTeX table
+# =============================================================================
+
+generate_meta_analysis_table <- function(
+ summary_tbl,
+ caption = paste0(
+ "Classical meta-analysis of incubation period central estimates ",
+ "(random-effects model, log-transformed mean, \\texttt{sm = MLN}). ",
+ "$k$ = number of studies. ",
+ "Pooled mean and 95\\% confidence interval (CI) are back-transformed to days. ",
+ "$I^2$ measures the percentage of total variance due to between-study heterogeneity. ",
+ "$\\tau_{\\text{meta}}$ is the between-study SD on the log scale (REML). ",
+ "$\\tau_{\\text{Stan}}$ (median and 95\\% credible interval) is from the ",
+ "best-fitting Bayesian model (``all data'' slot), also on the log scale."
+ ),
+ label = "tab:meta_analysis"
+) {
+
+ dist_labels <- c(
+ lognormal = "Log-normal",
+ gamma = "Gamma",
+ weibull = "Weibull",
+ burr = "Burr XII",
+ gengamma = "Gen. Gamma"
+ )
+
+ pathogen_labels <- ddsynth:::.PATHOGEN_LABELS
+ group_map <- ddsynth:::.RESULT_KEY_TO_GROUP
+ group_order <- ddsynth:::.GROUP_ORDER
+
+ # Format a number: fixed decimal places, dash for NA
+ .fmt <- function(x, d = 2) ifelse(is.na(x), "--", formatC(x, digits = d, format = "f"))
+
+ # ── Build body rows grouped by epidemiological category ───────────────────
+ body_lines <- character(0L)
+ first_group <- TRUE
+
+ for (grp in group_order) {
+ keys_in_grp <- names(group_map)[group_map == grp]
+ rows_in_grp <- summary_tbl[summary_tbl$pathogen %in% keys_in_grp, , drop = FALSE]
+ if (nrow(rows_in_grp) == 0L) next
+
+ # Group header row
+ if (!first_group) body_lines <- c(body_lines, "\\midrule")
+ body_lines <- c(body_lines,
+ paste0("\\rowcolor{gray!15}",
+ "\\multicolumn{7}{l}{\\textit{", grp, "}} \\\\")
+ )
+ first_group <- FALSE
+
+ # Data rows (order matches group_order within group)
+ for (i in seq_len(nrow(rows_in_grp))) {
+ r <- rows_in_grp[i, ]
+ plabel <- pathogen_labels[r$pathogen]
+ if (is.na(plabel)) plabel <- r$pathogen
+
+ dist_lab <- dist_labels[r$stan_best_dist]
+ if (is.na(dist_lab)) dist_lab <- r$stan_best_dist
+
+ mean_ci <- paste0(.fmt(r$pooled_mean), " [", .fmt(r$ci_lower), ", ", .fmt(r$ci_upper), "]")
+ stan_ci <- if (!is.na(r$stan_tau_median))
+ paste0(.fmt(r$stan_tau_median, 3), " [", .fmt(r$stan_tau_lo, 3), ", ",
+ .fmt(r$stan_tau_hi, 3), "]")
+ else "--"
+
+ body_lines <- c(body_lines, paste0(
+ "\\quad ", plabel, " & ",
+ r$k, " & ",
+ mean_ci, " & ",
+ .fmt(r$I2_pct, 1), "\\% & ",
+ .fmt(r$metamean_tau, 3), " & ",
+ stan_ci, " & ",
+ dist_lab, " \\\\"
+ ))
+ }
+ }
+
+ # ── Column header ──────────────────────────────────────────────────────────
+ col_header <- paste0(
+ "\\rowcolor[HTML]{F0E68C}",
+ "Pathogen & $k$ & ",
+ "\\multicolumn{1}{c}{Pooled mean [95\\% CI] (days)} & ",
+ "\\multicolumn{1}{c}{$I^2$} & ",
+ "\\multicolumn{1}{c}{$\\tau_{\\text{meta}}$} & ",
+ "\\multicolumn{1}{c}{$\\tau_{\\text{Stan}}$ [95\\% CrI]} & ",
+ "\\multicolumn{1}{c}{Best model} \\\\"
+ )
+
+ # ── Assemble full table ────────────────────────────────────────────────────
+ lines <- c(
+ "% Requires \\usepackage{booktabs}, \\usepackage{longtable},",
+ "% \\usepackage[table]{xcolor} in preamble.",
+ "\\begin{longtable}{@{} l r l r r l l @{}}",
+ paste0("\\caption{", caption, "}\\label{", label, "} \\\\"),
+ "\\toprule",
+ col_header,
+ "\\midrule",
+ "\\endfirsthead",
+ "%",
+ "\\multicolumn{7}{l}{\\small\\textit{continued from previous page}} \\\\",
+ "\\toprule",
+ col_header,
+ "\\midrule",
+ "\\endhead",
+ "%",
+ "\\multicolumn{7}{r}{\\small\\textit{continued on next page}} \\\\",
+ "\\endfoot",
+ "%",
+ "\\bottomrule",
+ "\\endlastfoot",
+ "%",
+ body_lines,
+ "\\end{longtable}"
+ )
+
+ paste(lines, collapse = "\n")
+}
+
+tex <- generate_meta_analysis_table(summary_tbl)
+tex_path <- file.path(OUTDIR, "meta_analysis_table.tex")
+writeLines(tex, tex_path)
+message("LaTeX table written to: ", tex_path)
+cat(tex, "\n")
+
+
+# =============================================================================
+# Dataset composition table
+# =============================================================================
+
+# Classify a single dataset as "indiv" (D/E) or "sumstat" (A/B/C).
+.composition_type <- function(d) {
+ if (!is.null(d$freq_lower) && !is.null(d$freq_upper)) return("indiv")
+ if (!is.null(d$freq_value) && !is.null(d$freq_count)) return("indiv")
+ "sumstat"
+}
+
+# Aggregate composition stats from a list of datasets.
+# Returns list(k_total, N_total, k_indiv, N_indiv, k_sumstat, N_sumstat).
+.composition_stats <- function(datasets) {
+ k_total <- 0L; N_total <- 0L
+ k_indiv <- 0L; N_indiv <- 0L
+ k_sumstat <- 0L; N_sumstat <- 0L
+
+ for (d in datasets) {
+ n <- if (!is.null(d$n)) d$n else if (!is.null(d$freq_count)) sum(d$freq_count) else NA_integer_
+ tp <- .composition_type(d)
+ k_total <- k_total + 1L
+ N_total <- N_total + if (!is.na(n)) n else 0L
+ if (tp == "indiv") {
+ k_indiv <- k_indiv + 1L; N_indiv <- N_indiv + if (!is.na(n)) n else 0L
+ } else {
+ k_sumstat <- k_sumstat + 1L; N_sumstat <- N_sumstat + if (!is.na(n)) n else 0L
+ }
+ }
+ list(k_total=k_total, N_total=N_total, k_indiv=k_indiv,
+ N_indiv=N_indiv, k_sumstat=k_sumstat, N_sumstat=N_sumstat)
+}
+
+# Format composition stats as a LaTeX data row string (6 cells, no pathogen label).
+.fmt_comp_cells <- function(s) {
+ paste(
+ s$k_total, format(s$N_total, big.mark = ","),
+ if (s$k_indiv > 0L) s$k_indiv else "--",
+ if (s$k_indiv > 0L) format(s$N_indiv, big.mark = ",") else "--",
+ if (s$k_sumstat > 0L) s$k_sumstat else "--",
+ if (s$k_sumstat > 0L) format(s$N_sumstat, big.mark = ",") else "--",
+ sep = " & "
+ )
+}
+
+generate_dataset_composition_table <- function(
+ full_registry,
+ caption = paste0(
+ "Composition of incubation period datasets included in the analysis. ",
+ "For each pathogen (and subgroup where applicable), $k$ is the number of datasets ",
+ "and $N$ is the total number of individual observations. ",
+ "\\textit{Individual-level data} denotes datasets provided as frequency tables ",
+ "(exact or interval-censored); \\textit{summary statistics only} denotes datasets ",
+ "reported as mean $\\pm$ SD, median $\\pm$ IQR, or median $\\pm$ range."
+ ),
+ label = "tab:dataset_composition"
+) {
+ pathogen_labels <- ddsynth:::.PATHOGEN_LABELS
+ subgroup_labels <- ddsynth:::.SUBGROUP_LABELS
+ group_map <- ddsynth:::.RESULT_KEY_TO_GROUP
+ group_order <- ddsynth:::.GROUP_ORDER
+
+ # Column header (two-row: group spans on top, k/N labels below)
+ hdr1 <- paste0(
+ "\\rowcolor[HTML]{F0E68C}",
+ " & \\multicolumn{2}{c}{\\textbf{Total}} &",
+ " \\multicolumn{2}{c}{\\textbf{Individual-level}} &",
+ " \\multicolumn{2}{c}{\\textbf{Summary statistics only}} \\\\"
+ )
+ hdr2 <- paste0(
+ "\\rowcolor[HTML]{F0E68C}",
+ "\\textbf{Pathogen} & $k$ & $N$ & $k$ & $N$ & $k$ & $N$ \\\\"
+ )
+
+ body_lines <- character(0L)
+ first_group <- TRUE
+
+ # Index registry by pathogen name for quick lookup
+ reg_index <- stats::setNames(
+ lapply(full_registry, `[[`, "data"),
+ sapply(full_registry, `[[`, "name")
+ )
+
+ for (grp in group_order) {
+ keys_in_grp <- names(group_map)[group_map == grp]
+ keys_present <- intersect(keys_in_grp, names(reg_index))
+ if (length(keys_present) == 0L) next
+
+ if (!first_group) body_lines <- c(body_lines, "\\midrule")
+ body_lines <- c(body_lines,
+ paste0("\\rowcolor{gray!15}",
+ "\\multicolumn{7}{@{}l}{\\textbf{\\textit{", grp, "}}} \\\\")
+ )
+ first_group <- FALSE
+
+ first_in_group <- TRUE
+ for (nm in keys_in_grp) {
+ if (!nm %in% names(reg_index)) next
+ datasets <- reg_index[[nm]]
+ plabel <- pathogen_labels[[nm]]
+ if (is.null(plabel) || is.na(plabel)) plabel <- nm
+
+ # Overall totals for this pathogen
+ stats_all <- .composition_stats(datasets)
+
+ # Detect subgroups (NULL entries → NA, then omit)
+ sgs <- unique(na.omit(sapply(datasets, function(d) {
+ sg <- d$subgroup; if (is.null(sg)) NA_character_ else sg
+ })))
+ unclass_datasets <- Filter(function(d) is.null(d$subgroup), datasets)
+
+ # Show subgroup rows only when ≥2 distinct subgroups OR ≥1 subgroup + unclassified.
+ # Skip when all datasets belong to a single subgroup with no unclassified entries
+ # (the parent row already captures everything).
+ has_subgroups <- (length(sgs) >= 2L) ||
+ (length(sgs) == 1L && length(unclass_datasets) > 0L)
+
+ if (!first_in_group)
+ body_lines <- c(body_lines, "\\addlinespace[4pt]")
+
+ body_lines <- c(body_lines, paste0(
+ "\\textbf{", plabel, "} & ",
+ .fmt_comp_cells(stats_all), " \\\\"
+ ))
+ first_in_group <- FALSE
+
+ if (has_subgroups) {
+ for (sg in sgs) {
+ sg_datasets <- Filter(function(d) {
+ dsg <- d$subgroup; !is.null(dsg) && identical(dsg, sg)
+ }, datasets)
+ if (length(sg_datasets) == 0L) next
+ sg_stats <- .composition_stats(sg_datasets)
+ sg_lab <- if (!is.na(subgroup_labels[sg])) subgroup_labels[[sg]] else sg
+ body_lines <- c(body_lines, paste0(
+ "\\quad \\textit{", sg_lab, "} & ",
+ .fmt_comp_cells(sg_stats), " \\\\"
+ ))
+ }
+ if (length(unclass_datasets) > 0L) {
+ uc_stats <- .composition_stats(unclass_datasets)
+ body_lines <- c(body_lines, paste0(
+ "\\quad \\textit{Unclassified} & ",
+ .fmt_comp_cells(uc_stats), " \\\\"
+ ))
+ }
+ }
+ }
+ }
+
+ # Assemble full table
+ lines <- c(
+ "% Requires \\usepackage{booktabs}, \\usepackage{longtable},",
+ "% \\usepackage[table]{xcolor} in preamble.",
+ "\\begin{longtable}{@{} l r r r r r r @{}}",
+ paste0("\\caption{", caption, "}\\label{", label, "} \\\\"),
+ "\\toprule",
+ hdr1,
+ hdr2,
+ "\\midrule",
+ "\\endfirsthead",
+ "%",
+ "\\multicolumn{7}{l}{\\small\\textit{continued from previous page}} \\\\",
+ "\\toprule",
+ hdr1,
+ hdr2,
+ "\\midrule",
+ "\\endhead",
+ "%",
+ "\\multicolumn{7}{r}{\\small\\textit{continued on next page}} \\\\",
+ "\\endfoot",
+ "%",
+ "\\bottomrule",
+ "\\endlastfoot",
+ "%",
+ body_lines,
+ "\\end{longtable}"
+ )
+
+ paste(lines, collapse = "\n")
+}
+
+# Full registry covering all pathogens (for composition table)
+full_registry <- list(
+ list(name = "EVD", data = datasets_EVD),
+ list(name = "MVD", data = datasets_MVD),
+ list(name = "Lassa", data = datasets_Lassa),
+ list(name = "CCHF", data = datasets_CCHF_extended),
+ list(name = "COVID_19", data = datasets_COVID_19),
+ list(name = "SARS", data = datasets_SARS),
+ list(name = "MERS", data = datasets_MERS),
+ list(name = "Flu", data = datasets_flu),
+ list(name = "Dengue", data = datasets_Dengue),
+ list(name = "Zika", data = datasets_Zika),
+ list(name = "RVF", data = datasets_RVF),
+ list(name = "YFV", data = datasets_YFV),
+ list(name = "Nipah", data = datasets_Nipah),
+ list(name = "Mpox", data = datasets_Mpox),
+ list(name = "Measles", data = datasets_Measles),
+ list(name = "Smallpox", data = datasets_Smallpox),
+ list(name = "Cholera", data = datasets_Cholera),
+ list(name = "Typhoid", data = datasets_typhoid)
+)
+
+comp_tex <- generate_dataset_composition_table(full_registry)
+comp_tex_path <- file.path(OUTDIR, "dataset_composition_table.tex")
+writeLines(comp_tex, comp_tex_path)
+message("Dataset composition table written to: ", comp_tex_path)
+cat(comp_tex, "\n")
diff --git a/man/figures/map_figure.png b/man/figures/map_figure.png
new file mode 100644
index 0000000..42280cf
Binary files /dev/null and b/man/figures/map_figure.png differ
diff --git a/man/figures/pathogen_grouping.png b/man/figures/pathogen_grouping.png
new file mode 100644
index 0000000..4bab29a
Binary files /dev/null and b/man/figures/pathogen_grouping.png differ
diff --git a/man/plot_simulation_study_figure.Rd b/man/plot_simulation_study_figure.Rd
new file mode 100644
index 0000000..dff400d
--- /dev/null
+++ b/man/plot_simulation_study_figure.Rd
@@ -0,0 +1,20 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/ploting_utils.R
+\name{plot_simulation_study_figure}
+\alias{plot_simulation_study_figure}
+\title{Main-text simulation study figure}
+\usage{
+plot_simulation_study_figure(summary_res, base_size = 9)
+}
+\arguments{
+\item{summary_res}{A data frame returned by \code{create_results_summary()}.}
+
+\item{base_size}{Base font size passed to \code{theme_minimal()}.}
+}
+\value{
+A \code{patchwork} object.
+}
+\description{
+Assembles a five-row composite figure from the simulation study summary,
+excluding sensitivity scenarios (\code{TauSens} / \code{Mu0Sens}).
+}
diff --git a/man/plot_simulation_study_sens_figure.Rd b/man/plot_simulation_study_sens_figure.Rd
new file mode 100644
index 0000000..cc8d993
--- /dev/null
+++ b/man/plot_simulation_study_sens_figure.Rd
@@ -0,0 +1,20 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/ploting_utils.R
+\name{plot_simulation_study_sens_figure}
+\alias{plot_simulation_study_sens_figure}
+\title{Supplementary simulation study figure — sensitivity scenarios}
+\usage{
+plot_simulation_study_sens_figure(summary_res, base_size = 9)
+}
+\arguments{
+\item{summary_res}{A data frame returned by \code{create_results_summary()}.}
+
+\item{base_size}{Base font size passed to \code{theme_minimal()}.}
+}
+\value{
+A \code{patchwork} object.
+}
+\description{
+Same layout as \code{plot_simulation_study_figure()} but restricted to
+\code{TauSens} and \code{Mu0Sens} scenarios.
+}
diff --git a/tests/testthat/test-datasets.R b/tests/testthat/test-datasets.R
index cca9ba3..f4cface 100644
--- a/tests/testthat/test-datasets.R
+++ b/tests/testthat/test-datasets.R
@@ -1,24 +1,35 @@
-# Smoke tests for built-in datasets: datasets_Nipah, datasets_MVD, datasets_EVD
-# Checks structure, recognised formats, and round-trip through prepare_stan_data_from_datasets().
+# Smoke tests for all built-in datasets.
+# Checks: correct type, expected entry count, all recognised summary formats,
+# and a round-trip through prepare_stan_data_from_datasets().
# ---------------------------------------------------------------------------
-# Helper: verify every entry has at least one recognised summary-stat combination
+# Helpers
# ---------------------------------------------------------------------------
+
.check_recognised <- function(ds, ds_name) {
for (nm in names(ds)) {
d <- ds[[nm]]
- has_range <- !is.null(d$median) && !is.null(d$min) && !is.null(d$max)
- has_iqr <- !is.null(d$median) && !is.null(d$Q1) && !is.null(d$Q3)
+ has_range <- !is.null(d$median) && !is.null(d$min) && !is.null(d$max)
+ has_iqr <- !is.null(d$median) && !is.null(d$Q1) && !is.null(d$Q3)
has_mean <- !is.null(d$mean) && !is.null(d$sd)
has_freq4 <- !is.null(d$freq_value) && !is.null(d$freq_count)
- has_freq5 <- !is.null(d$freq_lower) && !is.null(d$freq_upper) && !is.null(d$freq_count)
+ has_freq5 <- !is.null(d$freq_lower) && !is.null(d$freq_upper) &&
+ !is.null(d$freq_count)
expect_true(has_range || has_iqr || has_mean || has_freq4 || has_freq5,
info = paste(ds_name, nm, "has no recognised format"))
}
}
+.round_trip <- function(ds, expected_n, ds_name) {
+ sd <- suppressWarnings(prepare_stan_data_from_datasets(ds, dist_type = 1))
+ expect_equal(sd$n_datasets, expected_n,
+ info = paste(ds_name, "n_datasets mismatch"))
+ expect_true(all(sd$n_obs > 0),
+ info = paste(ds_name, "has zero-n entry"))
+}
+
# ---------------------------------------------------------------------------
-# datasets_Nipah
+# datasets_Nipah (11 entries)
# ---------------------------------------------------------------------------
test_that("datasets_Nipah is a named list with 11 entries", {
@@ -31,13 +42,11 @@ test_that("every datasets_Nipah entry has a recognised summary-stat format", {
})
test_that("datasets_Nipah round-trips through prepare_stan_data_from_datasets", {
- sd <- prepare_stan_data_from_datasets(datasets_Nipah, dist_type = 1)
- expect_equal(sd$n_datasets, 11L)
- expect_true(all(sd$n_obs > 0))
+ .round_trip(datasets_Nipah, 11L, "datasets_Nipah")
})
# ---------------------------------------------------------------------------
-# datasets_MVD
+# datasets_MVD (2 entries)
# ---------------------------------------------------------------------------
test_that("datasets_MVD is a named list with 2 entries", {
@@ -50,13 +59,11 @@ test_that("every datasets_MVD entry has a recognised summary-stat format", {
})
test_that("datasets_MVD round-trips through prepare_stan_data_from_datasets", {
- sd <- suppressWarnings(prepare_stan_data_from_datasets(datasets_MVD, dist_type = 1))
- expect_equal(sd$n_datasets, 2L)
- expect_true(all(sd$n_obs > 0))
+ .round_trip(datasets_MVD, 2L, "datasets_MVD")
})
# ---------------------------------------------------------------------------
-# datasets_EVD
+# datasets_EVD (11 entries)
# ---------------------------------------------------------------------------
test_that("datasets_EVD is a named list with 11 entries", {
@@ -69,23 +76,296 @@ test_that("every datasets_EVD entry has a recognised summary-stat format", {
})
test_that("datasets_EVD round-trips through prepare_stan_data_from_datasets", {
- sd <- prepare_stan_data_from_datasets(datasets_EVD, dist_type = 1)
- expect_equal(sd$n_datasets, 11L)
- expect_true(all(sd$n_obs > 0))
+ .round_trip(datasets_EVD, 11L, "datasets_EVD")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_SARS (22 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_SARS is a named list with 22 entries", {
+ expect_type(datasets_SARS, "list")
+ expect_equal(length(datasets_SARS), 22L)
+})
+
+test_that("every datasets_SARS entry has a recognised summary-stat format", {
+ .check_recognised(datasets_SARS, "datasets_SARS")
+})
+
+test_that("datasets_SARS round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_SARS, 22L, "datasets_SARS")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_MERS (11 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_MERS is a named list with 11 entries", {
+ expect_type(datasets_MERS, "list")
+ expect_equal(length(datasets_MERS), 11L)
+})
+
+test_that("every datasets_MERS entry has a recognised summary-stat format", {
+ .check_recognised(datasets_MERS, "datasets_MERS")
+})
+
+test_that("datasets_MERS round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_MERS, 11L, "datasets_MERS")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_Lassa (1 entry)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_Lassa is a named list with 1 entry", {
+ expect_type(datasets_Lassa, "list")
+ expect_equal(length(datasets_Lassa), 1L)
+})
+
+test_that("every datasets_Lassa entry has a recognised summary-stat format", {
+ .check_recognised(datasets_Lassa, "datasets_Lassa")
+})
+
+test_that("datasets_Lassa round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_Lassa, 1L, "datasets_Lassa")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_Measles (12 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_Measles is a named list with 12 entries", {
+ expect_type(datasets_Measles, "list")
+ expect_equal(length(datasets_Measles), 12L)
+})
+
+test_that("every datasets_Measles entry has a recognised summary-stat format", {
+ .check_recognised(datasets_Measles, "datasets_Measles")
+})
+
+test_that("datasets_Measles round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_Measles, 12L, "datasets_Measles")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_Mpox (16 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_Mpox is a named list with 16 entries", {
+ expect_type(datasets_Mpox, "list")
+ expect_equal(length(datasets_Mpox), 16L)
+})
+
+test_that("every datasets_Mpox entry has a recognised summary-stat format", {
+ .check_recognised(datasets_Mpox, "datasets_Mpox")
+})
+
+test_that("datasets_Mpox round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_Mpox, 16L, "datasets_Mpox")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_Cholera (16 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_Cholera is a named list with 16 entries", {
+ expect_type(datasets_Cholera, "list")
+ expect_equal(length(datasets_Cholera), 16L)
+})
+
+test_that("every datasets_Cholera entry has a recognised summary-stat format", {
+ .check_recognised(datasets_Cholera, "datasets_Cholera")
+})
+
+test_that("datasets_Cholera round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_Cholera, 16L, "datasets_Cholera")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_RVF (2 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_RVF is a named list with 2 entries", {
+ expect_type(datasets_RVF, "list")
+ expect_equal(length(datasets_RVF), 2L)
+})
+
+test_that("every datasets_RVF entry has a recognised summary-stat format", {
+ .check_recognised(datasets_RVF, "datasets_RVF")
+})
+
+test_that("datasets_RVF round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_RVF, 2L, "datasets_RVF")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_CCHF (6 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_CCHF is a named list with 6 entries", {
+ expect_type(datasets_CCHF, "list")
+ expect_equal(length(datasets_CCHF), 6L)
+})
+
+test_that("every datasets_CCHF entry has a recognised summary-stat format", {
+ .check_recognised(datasets_CCHF, "datasets_CCHF")
+})
+
+test_that("datasets_CCHF round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_CCHF, 6L, "datasets_CCHF")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_COVID_19 (51 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_COVID_19 is a named list with 51 entries", {
+ expect_type(datasets_COVID_19, "list")
+ expect_equal(length(datasets_COVID_19), 51L)
+})
+
+test_that("every datasets_COVID_19 entry has a recognised summary-stat format", {
+ .check_recognised(datasets_COVID_19, "datasets_COVID_19")
+})
+
+test_that("datasets_COVID_19 round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_COVID_19, 51L, "datasets_COVID_19")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_Dengue (14 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_Dengue is a named list with 14 entries", {
+ expect_type(datasets_Dengue, "list")
+ expect_equal(length(datasets_Dengue), 14L)
+})
+
+test_that("every datasets_Dengue entry has a recognised summary-stat format", {
+ .check_recognised(datasets_Dengue, "datasets_Dengue")
+})
+
+test_that("datasets_Dengue round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_Dengue, 14L, "datasets_Dengue")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_YFV (3 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_YFV is a named list with 3 entries", {
+ expect_type(datasets_YFV, "list")
+ expect_equal(length(datasets_YFV), 3L)
+})
+
+test_that("every datasets_YFV entry has a recognised summary-stat format", {
+ .check_recognised(datasets_YFV, "datasets_YFV")
+})
+
+test_that("datasets_YFV round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_YFV, 3L, "datasets_YFV")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_flu (14 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_flu is a named list with 14 entries", {
+ expect_type(datasets_flu, "list")
+ expect_equal(length(datasets_flu), 14L)
+})
+
+test_that("every datasets_flu entry has a recognised summary-stat format", {
+ .check_recognised(datasets_flu, "datasets_flu")
+})
+
+test_that("datasets_flu round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_flu, 14L, "datasets_flu")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_typhoid (22 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_typhoid is a named list with 22 entries", {
+ expect_type(datasets_typhoid, "list")
+ expect_equal(length(datasets_typhoid), 22L)
+})
+
+test_that("every datasets_typhoid entry has a recognised summary-stat format", {
+ .check_recognised(datasets_typhoid, "datasets_typhoid")
+})
+
+test_that("datasets_typhoid round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_typhoid, 22L, "datasets_typhoid")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_Smallpox (4 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_Smallpox is a named list with 4 entries", {
+ expect_type(datasets_Smallpox, "list")
+ expect_equal(length(datasets_Smallpox), 4L)
+})
+
+test_that("every datasets_Smallpox entry has a recognised summary-stat format", {
+ .check_recognised(datasets_Smallpox, "datasets_Smallpox")
+})
+
+test_that("datasets_Smallpox round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_Smallpox, 4L, "datasets_Smallpox")
+})
+
+# ---------------------------------------------------------------------------
+# datasets_Zika (2 entries)
+# ---------------------------------------------------------------------------
+
+test_that("datasets_Zika is a named list with 2 entries", {
+ expect_type(datasets_Zika, "list")
+ expect_equal(length(datasets_Zika), 2L)
+})
+
+test_that("every datasets_Zika entry has a recognised summary-stat format", {
+ .check_recognised(datasets_Zika, "datasets_Zika")
+})
+
+test_that("datasets_Zika round-trips through prepare_stan_data_from_datasets", {
+ .round_trip(datasets_Zika, 2L, "datasets_Zika")
})
# ---------------------------------------------------------------------------
-# source fields (all datasets)
+# Source fields: all datasets
# ---------------------------------------------------------------------------
test_that("every source field that is present is a non-empty character string", {
- all_ds <- list(Nipah = datasets_Nipah, MVD = datasets_MVD, EVD = datasets_EVD)
+ all_ds <- list(
+ Nipah = datasets_Nipah,
+ MVD = datasets_MVD,
+ EVD = datasets_EVD,
+ SARS = datasets_SARS,
+ MERS = datasets_MERS,
+ Lassa = datasets_Lassa,
+ Measles = datasets_Measles,
+ Mpox = datasets_Mpox,
+ Cholera = datasets_Cholera,
+ RVF = datasets_RVF,
+ CCHF = datasets_CCHF,
+ COVID_19 = datasets_COVID_19,
+ Dengue = datasets_Dengue,
+ YFV = datasets_YFV,
+ flu = datasets_flu,
+ typhoid = datasets_typhoid,
+ Smallpox = datasets_Smallpox,
+ Zika = datasets_Zika
+ )
for (ds_name in names(all_ds)) {
for (nm in names(all_ds[[ds_name]])) {
src <- all_ds[[ds_name]][[nm]]$source
if (!is.null(src)) {
expect_type(src, "character")
- expect_gt(nchar(src), 0)
+ expect_gt(nchar(src), 0,
+ label = paste(ds_name, nm, "source"))
}
}
}
diff --git a/tests/testthat/test-should-attempt-gg.R b/tests/testthat/test-should-attempt-gg.R
new file mode 100644
index 0000000..d7068dd
--- /dev/null
+++ b/tests/testthat/test-should-attempt-gg.R
@@ -0,0 +1,153 @@
+# Tests for should_attempt_gg() -----------------------------------------------
+
+# Helpers ---------------------------------------------------------------------
+
+# Frequency-table dataset (type-4): CV ≈ 0.24, "rich"
+.gg_freq <- function(n = 30) {
+ list(freq_value = c(5, 7, 9, 11, 13),
+ freq_count = c(3, 8, 12, 8, 3), n = n)
+}
+
+# Interval-censored dataset (type-5): "rich"
+.gg_icens <- function(n = 30) {
+ list(freq_lower = c(4, 6, 8, 10, 12),
+ freq_upper = c(6, 8, 10, 12, 14),
+ freq_count = c(3, 8, 12, 8, 3), n = n)
+}
+
+# Range dataset (type-1): CV ≈ 0.22, not "rich"
+.gg_range <- function(n = 30) {
+ list(median = 9, min = 5, max = 13, n = n)
+}
+
+# IQR dataset (type-2): CV ≈ 0.30, not "rich"
+.gg_iqr <- function(n = 30) {
+ list(median = 9, Q1 = 6.3, Q3 = 11.7, n = n)
+}
+
+# Mean+SD dataset (type-3): not "rich"
+.gg_meansd <- function(cv = 0.30, mean = 9, n = 30) {
+ list(mean = mean, sd = mean * cv, n = n)
+}
+
+# ── 1. Returns TRUE when richness and spread are both acceptable ──────────────
+
+test_that("returns TRUE with sufficient rich datasets and low CV spread", {
+ ds <- list(d1 = .gg_freq(), d2 = .gg_freq(), d3 = .gg_range())
+ # rich_frac = 2/3 ≈ 0.67 >= 0.30; all CVs similar => spread << 2.5
+ expect_true(should_attempt_gg(ds, verbose = FALSE))
+})
+
+test_that("returns TRUE with only frequency-table datasets", {
+ ds <- list(d1 = .gg_freq(), d2 = .gg_freq())
+ expect_true(should_attempt_gg(ds, verbose = FALSE))
+})
+
+test_that("type-5 interval-censored datasets count as rich", {
+ ds <- list(d1 = .gg_icens(), d2 = .gg_range())
+ # rich_frac = 0.5 >= 0.30
+ expect_true(should_attempt_gg(ds, verbose = FALSE))
+})
+
+test_that("returns TRUE for a single dataset (no spread check possible)", {
+ ds <- list(d1 = .gg_freq())
+ expect_true(should_attempt_gg(ds, verbose = FALSE))
+})
+
+# ── 2. Returns FALSE when rich fraction is too low ───────────────────────────
+
+test_that("returns FALSE when no datasets are rich (all type-1/2/3)", {
+ ds <- list(d1 = .gg_range(), d2 = .gg_range(), d3 = .gg_iqr(), d4 = .gg_meansd())
+ # rich_frac = 0 < 0.30
+ expect_false(should_attempt_gg(ds, verbose = FALSE))
+})
+
+test_that("returns FALSE when rich fraction is below custom threshold", {
+ ds <- list(d1 = .gg_freq(), d2 = .gg_range(), d3 = .gg_range(), d4 = .gg_range())
+ # rich_frac = 0.25; default min_rich_fraction = 0.30 → FALSE
+ expect_false(should_attempt_gg(ds, verbose = FALSE))
+})
+
+test_that("returns TRUE when rich fraction exactly meets the threshold", {
+ # 1 of 3 datasets is rich: 1/3 ≈ 0.33 >= 0.30
+ ds <- list(d1 = .gg_freq(), d2 = .gg_range(), d3 = .gg_range())
+ expect_true(should_attempt_gg(ds, verbose = FALSE))
+})
+
+test_that("custom min_rich_fraction threshold is respected", {
+ ds <- list(d1 = .gg_freq(), d2 = .gg_range()) # rich_frac = 0.5
+ expect_true( should_attempt_gg(ds, min_rich_fraction = 0.40, verbose = FALSE))
+ expect_false(should_attempt_gg(ds, min_rich_fraction = 0.60, verbose = FALSE))
+})
+
+# ── 3. Returns FALSE when CV spread is too large ─────────────────────────────
+
+test_that("returns FALSE when CV spread exceeds threshold", {
+ # d1 CV ≈ 0.05 (concentrated), d2 CV ≈ 0.69 (dispersed) → spread ≈ 13.9
+ d_conc <- list(freq_value = c(8, 9, 10), freq_count = c(1, 8, 1), n = 10)
+ d_disp <- list(freq_value = c(2, 9, 20), freq_count = c(3, 4, 3), n = 10)
+ ds <- list(d1 = d_conc, d2 = d_disp)
+ expect_false(should_attempt_gg(ds, verbose = FALSE))
+})
+
+test_that("custom cv_spread_threshold is respected", {
+ # spread ≈ 2; just straddles default of 2.5
+ d1 <- list(freq_value = c(7, 9, 11), freq_count = c(2, 6, 2), n = 10) # CV ≈ 0.19
+ d2 <- list(freq_value = c(4, 9, 14), freq_count = c(2, 6, 2), n = 10) # CV ≈ 0.38
+ ds <- list(d1 = d1, d2 = d2)
+ expect_true( should_attempt_gg(ds, cv_spread_threshold = 3.0, verbose = FALSE))
+ expect_false(should_attempt_gg(ds, cv_spread_threshold = 1.5, verbose = FALSE))
+})
+
+# ── 4. CV spread check is skipped for a single valid CV ───────────────────────
+
+test_that("spread check is skipped when only one dataset has a valid CV", {
+ # Only one CV computable; no spread can be formed → proceed to richness check
+ ds <- list(
+ d1 = .gg_freq(),
+ d2 = list(n = 10) # no stat fields → NA CV
+ )
+ expect_true(should_attempt_gg(ds, verbose = FALSE))
+})
+
+# ── 5. Verbose messaging ──────────────────────────────────────────────────────
+
+test_that("verbose=TRUE emits SKIP when richness is too low", {
+ ds <- list(d1 = .gg_range(), d2 = .gg_range())
+ expect_message(should_attempt_gg(ds, verbose = TRUE), "SKIP")
+})
+
+test_that("verbose=TRUE emits SKIP when CV spread is too large", {
+ d_conc <- list(freq_value = c(8, 9, 10), freq_count = c(1, 8, 1), n = 10)
+ d_disp <- list(freq_value = c(2, 9, 20), freq_count = c(3, 4, 3), n = 10)
+ ds <- list(d1 = d_conc, d2 = d_disp)
+ expect_message(should_attempt_gg(ds, verbose = TRUE), "SKIP")
+})
+
+test_that("verbose=TRUE emits OK when all checks pass", {
+ ds <- list(d1 = .gg_freq(), d2 = .gg_freq())
+ expect_message(should_attempt_gg(ds, verbose = TRUE), "OK")
+})
+
+test_that("verbose=FALSE suppresses all messages", {
+ ds <- list(d1 = .gg_range(), d2 = .gg_range())
+ expect_no_message(should_attempt_gg(ds, verbose = FALSE))
+})
+
+# ── 6. NA handling ────────────────────────────────────────────────────────────
+
+test_that("datasets with no recognisable format produce NA CV without error", {
+ ds <- list(
+ d1 = .gg_freq(),
+ d2 = .gg_freq(),
+ d3 = list(n = 10) # no stat fields
+ )
+ expect_true(should_attempt_gg(ds, verbose = FALSE))
+})
+
+test_that("all-NA CVs bypass the spread check and proceed to richness", {
+ # All entries have unrecognised format → no valid CV → no spread check
+ ds <- list(d1 = list(n = 10), d2 = list(n = 15))
+ # rich_frac = 0 < 0.30 → FALSE (fails richness, not spread)
+ expect_false(should_attempt_gg(ds, verbose = FALSE))
+})
diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R
index 929727a..6e968b5 100644
--- a/tests/testthat/test-utils.R
+++ b/tests/testthat/test-utils.R
@@ -12,3 +12,57 @@ test_that("check_scalar rejects non-scalars", {
expect_error(ddsynth:::check_scalar("a"), "single finite")
expect_silent(ddsynth:::check_scalar(3.14))
})
+
+# make_stan_init_fn -----------------------------------------------------------
+
+test_that("make_stan_init_fn returns a function", {
+ sd <- suppressWarnings(
+ prepare_stan_data_from_datasets(
+ list(d1 = list(median = 7, min = 3, max = 14, n = 20)),
+ dist_type = 1
+ )
+ )
+ init_fn <- make_stan_init_fn(sd)
+ expect_true(is.function(init_fn))
+})
+
+test_that("make_stan_init_fn closure returns a list with required names", {
+ sd <- suppressWarnings(
+ prepare_stan_data_from_datasets(
+ list(d1 = list(median = 7, min = 3, max = 14, n = 20),
+ d2 = list(median = 9, min = 5, max = 16, n = 30)),
+ dist_type = 1
+ )
+ )
+ init <- make_stan_init_fn(sd)()
+ expect_named(init, c("mu0", "log_tau", "log_phi", "log_kappa", "loc_d_raw"),
+ ignore.order = TRUE)
+})
+
+test_that("make_stan_init_fn initialises parameters from stan_data", {
+ sd <- suppressWarnings(
+ prepare_stan_data_from_datasets(
+ list(d1 = list(median = 7, min = 3, max = 14, n = 20),
+ d2 = list(median = 9, min = 5, max = 16, n = 30)),
+ dist_type = 1
+ )
+ )
+ init <- make_stan_init_fn(sd)()
+ expect_equal(init$mu0, sd$mu0_mean)
+ expect_equal(init$log_tau, sd$log_tau_mean)
+ expect_equal(init$log_phi, sd$log_phi_mean)
+ expect_equal(init$log_kappa, sd$log_kappa_mean)
+})
+
+test_that("make_stan_init_fn sets loc_d_raw to zero vector of length n_datasets", {
+ sd <- suppressWarnings(
+ prepare_stan_data_from_datasets(
+ list(d1 = list(median = 7, min = 3, max = 14, n = 20),
+ d2 = list(median = 9, min = 5, max = 16, n = 30),
+ d3 = list(mean = 8, sd = 2, n = 25)),
+ dist_type = 1
+ )
+ )
+ init <- make_stan_init_fn(sd)()
+ expect_equal(init$loc_d_raw, rep(0.0, 3L))
+})