## ----setup, include=FALSE----------------------------------------------------- knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4.2, fig.align = "center", dpi = 150, out.width = "100%", message = FALSE, warning = FALSE ) ## Consistent figure styling across the vignette ------------------------- if (requireNamespace("ggplot2", quietly = TRUE)) { old_theme <- ggplot2::theme_set( ggplot2::theme_minimal(base_size = 11) + ggplot2::theme( panel.grid.minor = ggplot2::element_blank(), panel.grid.major = ggplot2::element_line(linewidth = 0.3, colour = "#dde3e1"), axis.title = ggplot2::element_text(colour = "#4a5451"), axis.text = ggplot2::element_text(colour = "#4a5451"), strip.text = ggplot2::element_text(face = "bold", colour = "#1c2321"), plot.title = ggplot2::element_text(face = "bold", colour = "#1c2321"), legend.position = "bottom" ) ) } ## Vignette-local adapter: retain every required design identifier even when ## a pipeline release returns only scores or a subset of identifier columns. attach_hrri_ids <- function(scores, id) { scores <- as.data.frame(scores) id <- as.data.frame(id) keys <- c("plot", "depth", "plant_id", "time") if (anyDuplicated(names(scores)) || anyDuplicated(names(id))) { stop("Score and identifier tables must have unique column names.") } missing_keys <- setdiff(keys, names(id)) if (length(missing_keys)) { stop("sim$id is missing: ", paste(missing_keys, collapse = ", ")) } if (!nrow(id) || nrow(scores) != nrow(id)) { stop("Expected one pipeline score row per sim$id row.") } if (!"RRI" %in% names(scores) || !is.numeric(scores[["RRI"]])) { stop("Pipeline scores must contain a numeric RRI column.") } make_key <- function(x) { if (anyNA(x[keys])) stop("Observation identifiers cannot be missing.") values <- lapply(x[keys], as.character) if (any(vapply(values, function(v) any(grepl("\034", v, fixed = TRUE)), logical(1)))) { stop("Observation identifiers contain the reserved key separator.") } do.call(paste, c(values, list(sep = "\034"))) } id_key <- make_key(id) if (anyDuplicated(id_key)) { stop("sim$id must uniquely identify plot x depth x plant x time rows.") } ## Put scores in the original simulator order whenever usable keys exist. if (all(keys %in% names(scores))) { score_key <- make_key(scores) if (anyDuplicated(score_key)) stop("Duplicate observation keys in scores.") idx <- match(id_key, score_key) alignment <- "observation keys" } else if ("row_id" %in% names(id) && "row_id" %in% names(scores)) { id_row <- as.character(id[["row_id"]]) score_row <- as.character(scores[["row_id"]]) if (anyNA(id_row) || anyNA(score_row) || anyDuplicated(id_row) || anyDuplicated(score_row)) { stop("row_id must be unique and nonmissing for key-based alignment.") } idx <- match(id_row, score_row) alignment <- "row_id" } else { ## Scores-only releases must preserve their input row order. Row count ## equality alone cannot prove this; do not sort either table beforehand. idx <- seq_len(nrow(id)) alignment <- "input row order (pipeline contract)" } if (anyNA(idx)) stop("Some simulator identifiers have no matching scores.") scores <- scores[idx, , drop = FALSE] shared <- intersect(c(keys, "row_id"), intersect(names(scores), names(id))) for (nm in shared) { if (!identical(as.character(scores[[nm]]), as.character(id[[nm]]))) { stop("Conflicting or misaligned identifier column: ", nm) } } ## Rebuild identifiers once; never create depth.x/depth.y pairs. out <- cbind(id[keys], scores[setdiff(names(scores), keys)]) rownames(out) <- NULL attr(out, "id_alignment") <- alignment out } finite_mean <- function(x) { x <- x[is.finite(x)] if (length(x)) mean(x) else NA_real_ } ## ----simulate----------------------------------------------------------------- library(HRRI) packageVersion("HRRI") ## Compatibility shim ----------------------------------------------------- ## rri_pipeline() is the convenience wrapper around rri_pipeline_st(). ## If the *installed* HRRI predates the wrapper, define an equivalent local ## version so this vignette knits against either release. Reinstall the ## package (see README) to use the exported function directly. if (!exists("rri_pipeline", mode = "function")) { message("Installed HRRI has no rri_pipeline(); using a vignette-local wrapper.") rri_pipeline <- function(dat = NULL, soil = NULL, plant = NULL, micro = NULL, id = NULL, domain_weights = c(Physio = 0.4, Soil = 0.35, Micro = 0.25), ...) { stopifnot(setequal(names(domain_weights), c("Physio", "Soil", "Micro"))) w <- domain_weights[c("Physio", "Soil", "Micro")] w <- w / sum(w) res <- rri_pipeline_st( ROS_flux = plant, Eh_stability = soil, micro_data = micro, id = id, w1 = unname(w[1]), w2 = unname(w[2]), w3 = unname(w[3]), ... ) res$scores <- res$row_scores res } } ## Reproducible 1-cycle flood-drain experiment ## n_plot=2, n_depth=2, n_plant=3, n_time=30 -> 360 rows sim <- simulate_redox_holobiont( n_plot = 2, n_depth = 2, n_plant = 3, n_time = 30, p_micro = 20, seed = 42, scenario = "flood_drain", n_cycles = 1, disturbance_strength = 0.70, history_strength = 0.55, decoupling = 0.20 ) ## Top-level structure names(sim) nrow(sim$id) # one row per plot × depth × plant × time ## ----design------------------------------------------------------------------- head(sim$id[, c("plot","depth","plant_id","time","cycle","phase","WFPS")]) ## ----soil--------------------------------------------------------------------- head(sim$soil_data[, c("EAC","EDC","Cacc_EAC","Cacc_total","Cacc_fraction", "FeIII_poor_crystalline_mmol_kg", "FeII_mmol_kg","Eh","pH")]) ## ----conservation------------------------------------------------------------- ## Maximum absolute error should be < 0.01 mmol kg-1 sim$conservation_checks ## ----plant-------------------------------------------------------------------- head(sim$plant_data[, c("SPAD","FvFm","ROL","ROS_load","aerenchyma")]) ## ----genes-------------------------------------------------------------------- ## 18 genes spanning Fe-cycling, denitrification, nitrification, ## methanogenesis, and sulfur cycling colnames(sim$micro_gene_abundance) summary(sim$micro_gene_abundance[, "mcrA"]) # methanogenesis gene ## ----cacc--------------------------------------------------------------------- ## Subset one plot-depth unit for illustration idx <- sim$id$plot == "P1" & sim$id$depth == "D1" & sim$id$plant_id == "Plant1" sdf <- sim$soil_data[idx, ] ## Define reservoir specifications ## (Q_col names must match columns in sdf) res_spec <- list( reactive_FeIII = list( Q_col = "FeIII_poor_crystalline_mmol_kg", alpha = "alpha_accept", # column name: per-row connectivity k = "k_accept_h", # column name: per-row kinetics type = "EAC" ), crystalline_FeIII = list( Q_col = "FeIII_crystalline_mmol_kg", alpha = 0.20, # attenuated connectivity for crystalline phases k = 0.008, # h-1: slow exchange (goethite/hematite) type = "EAC" ), FeII_pool = list( Q_col = "FeII_mmol_kg", alpha = "alpha_donate", k = "k_donate_h", type = "EDC" ) ) ## tau = 24 h (diurnal event timescale) cap <- rri_accessible_capacity(sdf, res_spec, tau = 24, normalise = FALSE, return_components = TRUE) ## Per-component summary (returned because return_components = TRUE) cap$components ## Mean accessible vs. total inventory cat("Mean Cacc_raw:", mean(cap$cacc_raw, na.rm=TRUE), "mmol e- kg-1\n") cat("Mean fraction :", mean(cap$cacc_fraction, na.rm=TRUE), "\n") if ("ck_limited" %in% names(cap) && length(cap$ck_limited)) { cat("CK-limited rows:", sum(cap$ck_limited, na.rm=TRUE), "/", sum(!is.na(cap$ck_limited)), "classified rows\n") } else { cat("CK-limited classification is not returned by this HRRI version.\n") } ## ----tau_sweep---------------------------------------------------------------- tau_vals <- c(1, 6, 24, 72, 168, 720) # 1 h to 30 d cacc_tau <- sapply(tau_vals, function(tt) { r <- rri_accessible_capacity(sdf, res_spec, tau = tt, normalise = FALSE) mean(r$cacc_raw, na.rm = TRUE) }) data.frame(tau_h = tau_vals, Cacc_mean = round(cacc_tau, 2)) ## ----rri_pipeline------------------------------------------------------------- rri_out <- rri_pipeline( plant = sim$ROS_flux, soil = sim$Eh_stability, micro = log1p(sim$micro_gene_abundance), id = sim$id, mode = "snapshot", scaling = "pnorm", direction_anchor_phys = "FvFm", direction_anchor_soil = "Eh", direction_anchor_micro = "mtrA", domain_weights = c(Physio=0.35, Soil=0.40, Micro=0.25) ) ## Align once and reuse this identifier-complete table downstream. rri_scored <- attach_hrri_ids(rri_out$row_scores, sim$id) attr(rri_scored, "id_alignment") summary(rri_scored$RRI) head(rri_scored[, c("plot", "depth", "plant_id", "time", "RRI", "Physio", "Soil", "Micro")]) ## ----validation--------------------------------------------------------------- ## rri_scored is aligned to sim$id, and hence to its latent_truth vector. truth <- sim$latent_truth if (!is.numeric(truth) || length(truth) != nrow(rri_scored)) { stop("latent_truth must be a numeric vector with one value per sim$id row.") } ## One independent experimental unit = one plot x depth x plant trajectory. traj <- interaction(rri_scored$plot, rri_scored$depth, rri_scored$plant_id, drop = TRUE) acc <- rri_accuracy( score = rri_scored$RRI, target = truth, cluster = traj, n_boot = 500, n_perm = 500, seed = 42 ) acc ## ----validation_decomp-------------------------------------------------------- acc$decomposition[, c("component", "percent")] ## Exactness check: the residual is numerical noise, not a rounding allowance. c(mse = attr(acc$decomposition, "mse"), residual = attr(acc$decomposition, "residual")) ## ----validation_figure, fig.width=9.5, fig.height=7.5, out.width="100%"------- plot_rri_accuracy(acc, score_label = "RRI", target_label = "Prescribed target") ## ----recovery----------------------------------------------------------------- ## Step 1 — use the aligned score table created in the pipeline chunk. ## Step 2 — aggregate to one row per plot × depth × time (mean over plants). ## The data-frame method avoids formula-level complete-case filtering. ## An all-missing group remains NA; it is not replaced by zero. rri_agg <- stats::aggregate( x = rri_scored["RRI"], by = rri_scored[c("plot", "depth", "time")], FUN = finite_mean ) rri_agg <- rri_agg[order(rri_agg$plot, rri_agg$depth, rri_agg$time), , drop = FALSE] rownames(rri_agg) <- NULL stopifnot(!anyDuplicated(rri_agg[c("plot", "depth", "time")])) ## Step 3 — extract recovery signatures. ## Pass rri_agg directly (group columns are already inside it; no id= needed). metrics <- rri_recovery_metrics( res = rri_agg, time_col = "time", group_cols = c("plot","depth"), perturb_start = 8, perturb_end = 18, rri_col = "RRI", forcing_col = NULL # no measured forcing supplied ) if (!is.data.frame(metrics) || nrow(metrics) == 0L) { stop("rri_recovery_metrics() returned no nonempty recovery data frame.") } names(metrics) metrics ## ----history_sensitivity------------------------------------------------------ history <- do.call(rbind, lapply(1:4, function(nc) { z <- simulate_redox_holobiont(n_plot=1, n_depth=1, n_plant=2, n_time=30, p_micro=5, seed=99, n_cycles=nc, disturbance_strength=0.70) keep <- z$id$plant_id=="Plant1" data.frame(n_cycles=nc, EAC_end=tail(z$soil_data$EAC[keep],1), memory_end=tail(z$latent_state$memory[keep],1)) })) history ## ----restore-theme, include=FALSE--------------------------------------------- ## theme_set() changes state that persists for the rest of the session. ## Vignettes build in their own process so nothing outside is affected, but ## restoring is the same courtesy CRAN asks for with par() and options(). if (exists("old_theme")) ggplot2::theme_set(old_theme) ## ----sessionInfo-------------------------------------------------------------- sessionInfo()