Overview

Variant of PSU2022_ionome_content.Rmd where figure brackets come from linear-model contrasts (emmeans::emmeans + pairs) instead of marginal Welch t-tests. The brackets visualise the same kind of t-statistic that underlies each lm coefficient — same data, same model — so the asterisks in the figures match what the lm effect tables report, with full power from the joint fit (Treatment absorbed as covariate, full residual df).

Three convention notes:

  • For Treatment +P vs −P brackets in the main figure: the contrast is pairs(emmeans(m, ~ Treatment | Genotype)). CTRL and Inv4m facets get facet-specific p-values (handles the S_stover concentration × Genotype interaction correctly, for example).
  • For Inv4m vs CTRL brackets in the supp figure: pooled-across-treatment marginal contrast pairs(emmeans(m, ~ Genotype)).
  • FDR is applied within each metric (concentration / content / HI).

Load libraries

library(dplyr)
library(tidyr)
library(ggplot2)
library(tibble)
library(ggpubr)
library(ggbeeswarm)
library(ggfx)
library(rstatix)
library(ggtext)
library(ggh4x)
library(emmeans)

Load spatially corrected data

ionome <- read.csv(
  file.path(
    paths$intermediate,
    "PSU2022_spatially_corrected_both_treatments_with_ionome.csv"
  )
) %>%
  rename(Genotype = genotype) %>%
  filter(Genotype %in% c("CTRL", "Inv4m")) %>%
  droplevels()

ionome$Treatment <- factor(ionome$Treatment, levels = c("+P", "-P"))
ionome$Genotype  <- factor(ionome$Genotype,  levels = c("CTRL", "Inv4m"))

reliable <- c("Ca", "Fe", "K", "Mg", "Mn", "P", "S", "Zn")

content_vars <- c(
  paste0(reliable, "_grain_mg"),
  paste0(reliable, "_stover_mg"),
  paste0(reliable, "_HI")
)

conc_vars <- c(
  paste0(reliable, "_grain"),
  paste0(reliable, "_stover")
)

ratio_vars <- paste0(reliable, "_ratio")

content <- ionome

cat("Sample sizes:\n")
## Sample sizes:
ionome %>% count(Genotype, Treatment) %>% print()
##   Genotype Treatment  n
## 1     CTRL        +P 16
## 2     CTRL        -P 16
## 3    Inv4m        +P 16
## 4    Inv4m        -P 16

Long-format inputs (used by both contrasts and plots)

to_plot_content <- content %>%
  select(Genotype, Treatment,
         all_of(paste0(reliable, "_grain_mg")),
         all_of(paste0(reliable, "_stover_mg"))) %>%
  pivot_longer(cols = -c(Genotype, Treatment),
               names_to = "mineral_pool", values_to = "mg") %>%
  mutate(
    mineral = sub("_(grain|stover)_mg$", "", mineral_pool),
    pool    = sub("^.+_(grain|stover)_mg$", "\\1", mineral_pool)
  ) %>%
  select(-mineral_pool) %>%
  filter(!is.na(mg)) %>%
  mutate(
    pool      = factor(pool, levels = c("stover", "grain")),
    Genotype  = gsub("Inv4m", "<i>Inv4m</i>", Genotype),
    Genotype  = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>")),
    Treatment = factor(Treatment, levels = c("+P", "-P"))
  )

to_plot_conc <- content %>%
  select(Genotype, Treatment,
         all_of(paste0(reliable, "_grain")),
         all_of(paste0(reliable, "_stover"))) %>%
  pivot_longer(cols = -c(Genotype, Treatment),
               names_to = "mineral_pool", values_to = "ppm") %>%
  mutate(
    mineral = sub("_(grain|stover)$", "", mineral_pool),
    pool    = sub("^.+_(grain|stover)$", "\\1", mineral_pool)
  ) %>%
  select(-mineral_pool) %>%
  filter(!is.na(ppm)) %>%
  mutate(
    pool      = factor(pool, levels = c("stover", "grain")),
    Genotype  = gsub("Inv4m", "<i>Inv4m</i>", Genotype),
    Genotype  = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>")),
    Treatment = factor(Treatment, levels = c("+P", "-P"))
  )

to_plot_hi <- content %>%
  select(Genotype, Treatment, all_of(paste0(reliable, "_HI"))) %>%
  pivot_longer(cols = -c(Genotype, Treatment),
               names_to = "mineral", values_to = "hi") %>%
  mutate(
    mineral   = sub("_HI$", "", mineral),
    Genotype  = gsub("Inv4m", "<i>Inv4m</i>", Genotype),
    Genotype  = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>")),
    Treatment = factor(Treatment, levels = c("+P", "-P"))
  ) %>%
  filter(!is.na(hi), is.finite(hi))

to_plot_ratio <- content %>%
  select(Genotype, Treatment, all_of(paste0(reliable, "_ratio"))) %>%
  pivot_longer(cols = -c(Genotype, Treatment),
               names_to = "mineral", values_to = "ratio") %>%
  mutate(
    mineral   = sub("_ratio$", "", mineral),
    Genotype  = gsub("Inv4m", "<i>Inv4m</i>", Genotype),
    Genotype  = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>")),
    Treatment = factor(Treatment, levels = c("+P", "-P"))
  ) %>%
  filter(!is.na(ratio), is.finite(ratio))

Fit lms per response (kept for emmeans)

# Fit lm per response and keep model objects. Same model spec everywhere.
# Results are cached to RDS; delete the RDS to force a refit.
fit_per_response <- function(responses, data) {
  models <- lapply(responses, function(resp) {
    if (!resp %in% colnames(data)) return(NULL)
    if (sum(!is.na(data[[resp]])) < 20) return(NULL)
    tryCatch(
      lm(as.formula(paste(resp, "~ Genotype * Treatment")), data = data),
      error = function(e) NULL
    )
  })
  names(models) <- responses
  models[!sapply(models, is.null)]
}

models_rds <- file.path(paths$intermediate, "ionome_content_models.rds")
if (file.exists(models_rds)) {
  cat("Loading cached lm models from", models_rds, "\n")
  .ml <- readRDS(models_rds)
  models_conc    <- .ml$conc
  models_content <- .ml$content
  models_hi      <- .ml$hi
  models_ratio   <- .ml$ratio
  rm(.ml)
} else {
  models_conc    <- fit_per_response(conc_vars, content)
  models_content <- fit_per_response(paste0(reliable, c(rep("_grain_mg", 8),
                                                       rep("_stover_mg", 8))),
                                     content)
  models_hi      <- fit_per_response(paste0(reliable, "_HI"), content)
  models_ratio   <- fit_per_response(ratio_vars, content)
  saveRDS(list(conc = models_conc, content = models_content,
               hi = models_hi, ratio = models_ratio), models_rds)
  cat("Saved lm models to", models_rds, "\n")
}
## Loading cached lm models from /Users/fvrodriguez/Library/CloudStorage/GoogleDrive-frodrig4@ncsu.edu/My Drive/repos/inv4m/results/phosphorus_paper/intermediate/ionome_content_models.rds
cat("Fitted lms — conc:", length(models_conc),
    "content:", length(models_content),
    "HI:", length(models_hi),
    "ratio:", length(models_ratio), "\n")
## Fitted lms — conc: 16 content: 16 HI: 8 ratio: 8

Effect table (lm coefficients, FDR across all)

extract_coefs <- function(models, family) {
  lapply(names(models), function(resp) {
    m <- models[[resp]]
    out <- coef(summary(m)) %>%
      as.data.frame() %>%
      rownames_to_column("predictor")
    colnames(out)[3] <- "Std.Error"
    colnames(out)[5] <- "p.value"
    out$response <- resp
    out$family   <- family
    out %>% select(family, response, everything())
  }) %>% bind_rows()
}

effects <- bind_rows(
  extract_coefs(models_conc,    "conc"),
  extract_coefs(models_content, "content"),
  extract_coefs(models_hi,      "hi"),
  extract_coefs(models_ratio,   "ratio")
) %>%
  mutate(p.adjust = p.adjust(p.value, method = "fdr")) %>%
  arrange(p.adjust)

cat("Total models fitted:", length(unique(effects$response)), "\n")
## Total models fitted: 48
cat("Total effects tested:", nrow(effects), "\n")
## Total effects tested: 192
cat("\n=== Significant effects (FDR < 0.05, no Intercept) ===\n")
## 
## === Significant effects (FDR < 0.05, no Intercept) ===
effects %>%
  filter(p.adjust < 0.05, !grepl("Intercept", predictor)) %>%
  select(family, response, predictor, Estimate, p.value, p.adjust) %>%
  as_tibble() %>%
  print(n = Inf)
## # A tibble: 31 × 6
##    family  response     predictor                    Estimate  p.value p.adjust
##    <chr>   <chr>        <chr>                           <dbl>    <dbl>    <dbl>
##  1 conc    P_stover     Treatment-P               -1592.      2.24e-26 1.65e-25
##  2 ratio   P_ratio      Treatment-P                   2.09    3.45e-17 1.62e-16
##  3 content P_stover_mg  Treatment-P                -186.      4.40e-17 2.01e-16
##  4 hi      P_HI         Treatment-P                   0.280   4.10e-12 1.68e-11
##  5 conc    P_grain      Treatment-P                -672.      4.02e- 9 1.61e- 8
##  6 conc    Zn_stover    Treatment-P                   6.85    8.78e- 8 3.31e- 7
##  7 ratio   Zn_ratio     Treatment-P                  -0.306   1.33e- 7 4.91e- 7
##  8 conc    Ca_grain     Treatment-P                  19.0     1.32e- 6 4.68e- 6
##  9 conc    S_stover     Treatment-P                 113.      1.40e- 6 4.89e- 6
## 10 content Ca_stover_mg Treatment-P                 -97.4     1.49e- 5 4.94e- 5
## 11 hi      Ca_HI        Treatment-P                   0.00373 1.48e- 5 4.94e- 5
## 12 ratio   Ca_ratio     Treatment-P                   0.00416 1.83e- 5 5.95e- 5
## 13 content Ca_stover_mg GenotypeInv4m               -93.6     2.83e- 5 9.05e- 5
## 14 content Ca_grain_mg  Treatment-P                   1.26    6.12e- 5 1.90e- 4
## 15 content Mg_stover_mg Treatment-P                 -34.8     1.05e- 4 3.21e- 4
## 16 content K_stover_mg  Treatment-P                -299.      7.55e- 4 2.27e- 3
## 17 conc    S_stover     GenotypeInv4m:Treatment-P   -93.8     2.17e- 3 6.40e- 3
## 18 conc    Mg_grain     Treatment-P                 -97.5     2.46e- 3 7.15e- 3
## 19 hi      Fe_HI        GenotypeInv4m                 0.0403  3.33e- 3 9.56e- 3
## 20 conc    Mg_grain     GenotypeInv4m               -95.6     3.85e- 3 1.09e- 2
## 21 conc    Ca_stover    GenotypeInv4m              -411.      5.03e- 3 1.40e- 2
## 22 ratio   Mn_ratio     Treatment-P                  -0.0149  5.44e- 3 1.49e- 2
## 23 content P_grain_mg   Treatment-P                 -41.1     6.91e- 3 1.86e- 2
## 24 content Zn_stover_mg GenotypeInv4m                -0.440   7.06e- 3 1.86e- 2
## 25 ratio   Fe_ratio     GenotypeInv4m                 0.0531  7.02e- 3 1.86e- 2
## 26 content Fe_grain_mg  GenotypeInv4m                 0.206   7.85e- 3 2.04e- 2
## 27 ratio   Mg_ratio     Treatment-P                  -0.0673  8.95e- 3 2.29e- 2
## 28 content Mn_stover_mg Treatment-P                  -0.662   1.01e- 2 2.55e- 2
## 29 conc    S_grain      Treatment-P                  79.1     1.11e- 2 2.76e- 2
## 30 hi      Zn_HI        GenotypeInv4m                 0.0641  1.31e- 2 3.22e- 2
## 31 content K_stover_mg  GenotypeInv4m              -213.      1.39e- 2 3.39e- 2

emmeans contrast helpers

# Treatment-within-Genotype contrast (CTRL facet bracket = -P vs +P at CTRL;
# Inv4m facet bracket = -P vs +P at Inv4m). Returns one row per (response,
# Genotype).
contrasts_treatment_per_geno <- function(models) {
  lapply(names(models), function(resp) {
    m <- models[[resp]]
    emm <- emmeans(m, ~ Treatment | Genotype)
    pairs(emm, reverse = TRUE) %>%   # (-P) - (+P)
      as_tibble() %>%
      mutate(response = resp)
  }) %>% bind_rows()
}

# Pooled Genotype contrast (Inv4m vs CTRL averaged over Treatment). Returns
# one row per response.
contrasts_genotype_pooled <- function(models) {
  lapply(names(models), function(resp) {
    m <- models[[resp]]
    emm <- emmeans(m, ~ Genotype)
    pairs(emm, reverse = TRUE) %>%   # Inv4m - CTRL
      as_tibble() %>%
      mutate(response = resp)
  }) %>% bind_rows()
}

# Marginal Treatment contrast (pooled across Genotype). Returns one row per
# response. Use only when Genotype x Treatment interaction is ns.
contrasts_treatment_marginal <- function(models) {
  lapply(names(models), function(resp) {
    m <- models[[resp]]
    emm <- emmeans(m, ~ Treatment)
    pairs(emm, reverse = TRUE) %>%   # (-P) - (+P)
      as_tibble() %>%
      mutate(response = resp)
  }) %>% bind_rows()
}

# Format an emmeans contrast tibble to look like rstatix t_test output for
# stat_pvalue_manual. The function strips the contrast string and assigns
# group1/group2 explicitly. Adds FDR within the tibble (one metric).
format_treatment_contrast <- function(ct_tbl, response_to_mineral_pool, value_var) {
  ct_tbl %>%
    response_to_mineral_pool() %>%
    rename(p = p.value, statistic = t.ratio) %>%
    mutate(
      .y. = value_var,
      group1 = "+P",
      group2 = "-P",
      Genotype = gsub("Inv4m", "<i>Inv4m</i>", as.character(Genotype)),
      Genotype = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>"))
    ) %>%
    rstatix::adjust_pvalue(method = "fdr") %>%
    rstatix::add_significance("p.adj")
}

format_genotype_contrast <- function(ct_tbl, response_to_mineral_pool, value_var) {
  ct_tbl %>%
    response_to_mineral_pool() %>%
    rename(p = p.value, statistic = t.ratio) %>%
    mutate(
      .y. = value_var,
      group1 = "CTRL",
      group2 = "Inv4m"
    ) %>%
    rstatix::adjust_pvalue(method = "fdr") %>%
    rstatix::add_significance("p.adj")
}

Treatment contrasts → t-test-style tibbles for the main figure

# Helpers to split response name into mineral + pool
split_content <- function(df) {
  df %>% mutate(
    mineral = sub("_(grain|stover)_mg$", "", response),
    pool    = sub("^.+_(grain|stover)_mg$", "\\1", response),
    pool    = factor(pool, levels = c("stover", "grain"))
  )
}
split_conc <- function(df) {
  df %>% mutate(
    mineral = sub("_(grain|stover)$", "", response),
    pool    = sub("^.+_(grain|stover)$", "\\1", response),
    pool    = factor(pool, levels = c("stover", "grain"))
  )
}
split_hi <- function(df) {
  df %>% mutate(
    mineral = sub("_HI$", "", response)
  )
}
split_ratio <- function(df) {
  df %>% mutate(
    mineral = sub("_ratio$", "", response)
  )
}

# Cache raw emmeans outputs (before formatting) to avoid refitting on every
# render. Delete the RDS to force a refresh when models change.
emmeans_rds <- file.path(paths$intermediate, "ionome_content_emmeans.rds")
if (file.exists(emmeans_rds)) {
  cat("Loading cached emmeans contrasts from", emmeans_rds, "\n")
  .em <- readRDS(emmeans_rds)
  ct_conc    <- .em$ct_conc
  ct_content <- .em$ct_content
  ct_hi      <- .em$ct_hi
  ct_ratio   <- .em$ct_ratio
  rm(.em)
} else {
  ct_conc    <- contrasts_treatment_per_geno(models_conc)
  ct_content <- contrasts_treatment_per_geno(models_content)
  ct_hi      <- contrasts_treatment_per_geno(models_hi)
  ct_ratio   <- contrasts_treatment_per_geno(models_ratio)
  saveRDS(list(ct_conc = ct_conc, ct_content = ct_content,
               ct_hi = ct_hi, ct_ratio = ct_ratio), emmeans_rds)
  cat("Saved emmeans contrasts to", emmeans_rds, "\n")
}
## Loading cached emmeans contrasts from /Users/fvrodriguez/Library/CloudStorage/GoogleDrive-frodrig4@ncsu.edu/My Drive/repos/inv4m/results/phosphorus_paper/intermediate/ionome_content_emmeans.rds
# Unified FDR pool across every bracket drawn on the 4-panel figure:
#   - Panel A conc (per-Genotype)
#   - Panel B ratio (per-Genotype)
#   - Panel C content (per-Genotype)
#   - Panel D HI (per-Genotype)
raw_conc <- ct_conc %>%
  split_conc() %>%
  rename(p = p.value, statistic = t.ratio) %>%
  mutate(
    .y. = "ppm", group1 = "+P", group2 = "-P", metric = "conc",
    Genotype = gsub("Inv4m", "<i>Inv4m</i>", as.character(Genotype)),
    Genotype = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>"))
  )

raw_ratio <- ct_ratio %>%
  split_ratio() %>%
  rename(p = p.value, statistic = t.ratio) %>%
  mutate(
    .y. = "ratio", group1 = "+P", group2 = "-P", metric = "ratio",
    Genotype = gsub("Inv4m", "<i>Inv4m</i>", as.character(Genotype)),
    Genotype = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>"))
  )

raw_content <- ct_content %>%
  split_content() %>%
  rename(p = p.value, statistic = t.ratio) %>%
  mutate(
    .y. = "mg", group1 = "+P", group2 = "-P", metric = "content",
    Genotype = gsub("Inv4m", "<i>Inv4m</i>", as.character(Genotype)),
    Genotype = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>"))
  )

raw_hi <- ct_hi %>%
  split_hi() %>%
  rename(p = p.value, statistic = t.ratio) %>%
  mutate(
    .y. = "hi", group1 = "+P", group2 = "-P", metric = "hi",
    Genotype = gsub("Inv4m", "<i>Inv4m</i>", as.character(Genotype)),
    Genotype = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>"))
  )

unified_bracket_pool <- bind_rows(raw_conc, raw_ratio, raw_content,
                                  raw_hi) %>%
  mutate(p.adj = p.adjust(p, method = "fdr")) %>%
  rstatix::add_significance("p.adj")

cat("\n=== Unified FDR pool (n =", nrow(unified_bracket_pool),
    " | conc =", sum(unified_bracket_pool$metric == "conc"),
    "| ratio =", sum(unified_bracket_pool$metric == "ratio"),
    "| content =", sum(unified_bracket_pool$metric == "content"),
    "| hi =", sum(unified_bracket_pool$metric == "hi"),
    ") ===\n")
## 
## === Unified FDR pool (n = 96  | conc = 32 | ratio = 16 | content = 32 | hi = 16 ) ===
treatment.conc.ttest <- unified_bracket_pool %>%
  filter(metric == "conc") %>%
  mutate(Genotype = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>")),
         pool = factor(pool, levels = c("stover", "grain"))) %>%
  group_by(pool, mineral, Genotype) %>%
  group_modify(~ {
    sub <- to_plot_conc %>%
      filter(pool == .y$pool, mineral == .y$mineral, Genotype == .y$Genotype)
    .x %>%
      rstatix::add_y_position(
        data = sub, formula = ppm ~ Treatment,
        step.increase = 0.12
      )
  }) %>%
  ungroup() %>%
  filter(!is.na(y.position), is.finite(y.position))

treatment.ratio.ttest <- unified_bracket_pool %>%
  filter(metric == "ratio") %>%
  mutate(Genotype = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>"))) %>%
  group_by(mineral, Genotype) %>%
  group_modify(~ {
    sub <- to_plot_ratio %>%
      filter(mineral == .y$mineral, Genotype == .y$Genotype)
    .x %>%
      rstatix::add_y_position(
        data = sub, formula = ratio ~ Treatment,
        step.increase = 0.12
      )
  }) %>%
  ungroup() %>%
  filter(!is.na(y.position), is.finite(y.position))

treatment.t.test <- unified_bracket_pool %>%
  filter(metric == "content") %>%
  mutate(Genotype = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>")),
         pool = factor(pool, levels = c("stover", "grain"))) %>%
  group_by(pool, mineral, Genotype) %>%
  group_modify(~ {
    sub <- to_plot_content %>%
      filter(pool == .y$pool, mineral == .y$mineral, Genotype == .y$Genotype)
    .x %>%
      rstatix::add_y_position(
        data = sub, formula = mg ~ Treatment,
        step.increase = 0.20
      )
  }) %>%
  ungroup() %>%
  filter(!is.na(y.position), is.finite(y.position)) %>%
  mutate(y.position = case_when(
    mineral == "Ca" & pool == "grain" ~ y.position + 35,
    mineral == "Mn"                   ~ y.position + 0.2,
    mineral == "K"                    ~ y.position + 100,
    TRUE                              ~ y.position
  ))

treatment.hi.ttest <- unified_bracket_pool %>%
  filter(metric == "hi") %>%
  mutate(Genotype = factor(Genotype, levels = c("CTRL", "<i>Inv4m</i>"))) %>%
  group_by(mineral, Genotype) %>%
  group_modify(~ {
    sub <- to_plot_hi %>%
      filter(mineral == .y$mineral, Genotype == .y$Genotype)
    .x %>%
      rstatix::add_y_position(
        data = sub, formula = hi ~ Treatment,
        step.increase = 0.12
      )
  }) %>%
  ungroup() %>%
  filter(!is.na(y.position), is.finite(y.position))

cat("\n=== Treatment concentration contrasts (unified FDR, p.adj < 0.05) ===\n")
## 
## === Treatment concentration contrasts (unified FDR, p.adj < 0.05) ===
treatment.conc.ttest %>% filter(p.adj < 0.05) %>%
  select(mineral, pool, Genotype, statistic, p, p.adj, p.adj.signif) %>%
  arrange(p.adj) %>% print(n = Inf)
## # A tibble: 11 × 7
##    mineral pool   Genotype     statistic        p    p.adj p.adj.signif
##    <chr>   <fct>  <fct>            <dbl>    <dbl>    <dbl> <chr>       
##  1 P       stover <i>Inv4m</i>    -20.5  3.24e-28 3.11e-26 ****        
##  2 P       stover CTRL            -18.8  2.24e-26 1.07e-24 ****        
##  3 P       grain  <i>Inv4m</i>     -7.39 1.89e- 9 2.02e- 8 ****        
##  4 P       grain  CTRL             -7.17 4.02e- 9 3.85e- 8 ****        
##  5 Zn      stover CTRL              6.12 8.78e- 8 7.66e- 7 ****        
##  6 S       stover CTRL              5.38 1.40e- 6 9.61e- 6 ****        
##  7 Ca      grain  CTRL              5.53 1.32e- 6 9.61e- 6 ****        
##  8 Ca      grain  <i>Inv4m</i>      4.92 1.07e- 5 6.42e- 5 ****        
##  9 Zn      stover <i>Inv4m</i>      4.63 2.08e- 5 9.97e- 5 ****        
## 10 Mg      grain  CTRL             -3.20 2.46e- 3 7.86e- 3 **          
## 11 S       grain  CTRL              2.64 1.11e- 2 2.80e- 2 *
cat("\n=== Treatment ratio contrasts (unified FDR, p.adj < 0.05) ===\n")
## 
## === Treatment ratio contrasts (unified FDR, p.adj < 0.05) ===
treatment.ratio.ttest %>% filter(p.adj < 0.05) %>%
  select(mineral, Genotype, statistic, p, p.adj, p.adj.signif) %>%
  arrange(p.adj) %>% print(n = Inf)
## # A tibble: 9 × 6
##   mineral Genotype     statistic        p    p.adj p.adj.signif
##   <chr>   <fct>            <dbl>    <dbl>    <dbl> <chr>       
## 1 P       CTRL             12.9  3.45e-17 1.06e-15 ****        
## 2 P       <i>Inv4m</i>     12.5  1.13e-16 2.17e-15 ****        
## 3 Zn      CTRL             -6.18 1.33e- 7 1.06e- 6 ****        
## 4 Ca      CTRL              4.76 1.83e- 5 9.23e- 5 ****        
## 5 Ca      <i>Inv4m</i>      4.24 9.98e- 5 4.17e- 4 ***         
## 6 Zn      <i>Inv4m</i>     -3.63 6.80e- 4 2.51e- 3 **          
## 7 Mn      CTRL             -2.91 5.44e- 3 1.68e- 2 *           
## 8 Fe      <i>Inv4m</i>     -2.81 7.16e- 3 2.02e- 2 *           
## 9 Mg      CTRL             -2.72 8.95e- 3 2.46e- 2 *
cat("\n=== Treatment content contrasts (unified FDR, p.adj < 0.05) ===\n")
## 
## === Treatment content contrasts (unified FDR, p.adj < 0.05) ===
treatment.t.test %>% filter(p.adj < 0.05) %>%
  select(mineral, pool, Genotype, statistic, p, p.adj, p.adj.signif) %>%
  arrange(p.adj) %>% print(n = Inf)
## # A tibble: 14 × 7
##    mineral pool   Genotype     statistic        p    p.adj p.adj.signif
##    <chr>   <fct>  <fct>            <dbl>    <dbl>    <dbl> <chr>       
##  1 P       stover CTRL            -11.8  4.40e-17 1.06e-15 ****        
##  2 P       stover <i>Inv4m</i>    -11.1  4.74e-16 7.59e-15 ****        
##  3 Ca      stover CTRL             -4.73 1.49e- 5 7.96e- 5 ****        
##  4 P       grain  <i>Inv4m</i>     -4.69 2.33e- 5 1.06e- 4 ***         
##  5 Ca      grain  CTRL              4.39 6.12e- 5 2.67e- 4 ***         
##  6 Mg      stover CTRL             -4.16 1.05e- 4 4.21e- 4 ***         
##  7 Ca      grain  <i>Inv4m</i>      4.18 1.24e- 4 4.77e- 4 ***         
##  8 K       stover CTRL             -3.56 7.55e- 4 2.68e- 3 **          
##  9 Mg      stover <i>Inv4m</i>     -3.40 1.21e- 3 4.16e- 3 **          
## 10 Ca      stover <i>Inv4m</i>     -3.37 1.34e- 3 4.43e- 3 **          
## 11 Mn      stover <i>Inv4m</i>     -2.82 6.59e- 3 1.98e- 2 *           
## 12 P       grain  CTRL             -2.82 6.91e- 3 2.01e- 2 *           
## 13 Mn      stover CTRL             -2.66 1.01e- 2 2.62e- 2 *           
## 14 Mn      grain  <i>Inv4m</i>     -2.69 9.90e- 3 2.62e- 2 *
cat("\n=== Treatment HI contrasts (unified FDR, p.adj < 0.05) ===\n")
## 
## === Treatment HI contrasts (unified FDR, p.adj < 0.05) ===
treatment.hi.ttest %>% filter(p.adj < 0.05) %>%
  select(mineral, Genotype, statistic, p, p.adj, p.adj.signif) %>%
  arrange(p.adj) %>% print(n = Inf)
## # A tibble: 4 × 6
##   mineral Genotype     statistic        p    p.adj p.adj.signif
##   <chr>   <fct>            <dbl>    <dbl>    <dbl> <chr>       
## 1 P       CTRL              9.16 4.10e-12 5.63e-11 ****        
## 2 P       <i>Inv4m</i>      7.89 3.24e-10 3.89e- 9 ****        
## 3 Ca      <i>Inv4m</i>      5.37 2.27e- 6 1.45e- 5 ****        
## 4 Ca      CTRL              4.82 1.48e- 5 7.96e- 5 ****

Genotype contrasts (pooled) → unified-FDR pool across conc / content / HI / ratio

# Cache raw pooled-Genotype emmeans outputs (including ratio).
geno_emmeans_rds <- file.path(paths$intermediate,
                              "ionome_content_geno_emmeans.rds")
needs_recompute <- TRUE
if (file.exists(geno_emmeans_rds)) {
  .gm <- readRDS(geno_emmeans_rds)
  if (all(c("gp_conc", "gp_content", "gp_hi", "gp_ratio") %in% names(.gm))) {
    gp_conc    <- .gm$gp_conc
    gp_content <- .gm$gp_content
    gp_hi      <- .gm$gp_hi
    gp_ratio   <- .gm$gp_ratio
    needs_recompute <- FALSE
    cat("Loaded cached pooled-Genotype contrasts from", geno_emmeans_rds, "\n")
  }
  rm(.gm)
}
## Loaded cached pooled-Genotype contrasts from /Users/fvrodriguez/Library/CloudStorage/GoogleDrive-frodrig4@ncsu.edu/My Drive/repos/inv4m/results/phosphorus_paper/intermediate/ionome_content_geno_emmeans.rds
if (needs_recompute) {
  gp_conc    <- contrasts_genotype_pooled(models_conc)
  gp_content <- contrasts_genotype_pooled(models_content)
  gp_hi      <- contrasts_genotype_pooled(models_hi)
  gp_ratio   <- contrasts_genotype_pooled(models_ratio)
  saveRDS(list(gp_conc = gp_conc, gp_content = gp_content,
               gp_hi = gp_hi, gp_ratio = gp_ratio), geno_emmeans_rds)
  cat("Saved pooled-Genotype contrasts to", geno_emmeans_rds, "\n")
}

# Unified pooled-Genotype FDR across all four metrics. Mirrors the
# unified-bracket convention used in the main 4-panel figure.
format_raw_geno <- function(ct_tbl, response_split, value_var, metric_lbl) {
  ct_tbl %>%
    response_split() %>%
    rename(p = p.value, statistic = t.ratio) %>%
    mutate(.y. = value_var, group1 = "CTRL", group2 = "Inv4m",
           metric = metric_lbl)
}

unified_geno_pool <- bind_rows(
  format_raw_geno(gp_conc,    split_conc,    "ppm",   "conc"),
  format_raw_geno(gp_content, split_content, "mg",    "content"),
  format_raw_geno(gp_hi,      split_hi,      "hi",    "hi"),
  format_raw_geno(gp_ratio,   split_ratio,   "ratio", "ratio")
) %>%
  rstatix::adjust_pvalue(method = "fdr", p.col = "p", output.col = "p.adj") %>%
  rstatix::add_significance("p.adj")

cat(sprintf("Unified pooled-Genotype FDR: %d contrasts, %d sig at p.adj < 0.05\n",
            nrow(unified_geno_pool), sum(unified_geno_pool$p.adj < 0.05)))
## Unified pooled-Genotype FDR: 48 contrasts, 14 sig at p.adj < 0.05
# Clean copies of the long-format data with non-markdown Genotype labels
# (needed for rstatix::add_y_position which matches on Genotype values).
to_plot_conc_clean <- to_plot_conc %>%
  mutate(Genotype = gsub("<i>|</i>", "", as.character(Genotype)),
         Genotype = factor(Genotype, levels = c("CTRL", "Inv4m")))
to_plot_content_clean <- to_plot_content %>%
  mutate(Genotype = gsub("<i>|</i>", "", as.character(Genotype)),
         Genotype = factor(Genotype, levels = c("CTRL", "Inv4m")))
to_plot_hi_clean <- to_plot_hi %>%
  mutate(Genotype = gsub("<i>|</i>", "", as.character(Genotype)),
         Genotype = factor(Genotype, levels = c("CTRL", "Inv4m")))
to_plot_ratio_clean <- to_plot_ratio %>%
  mutate(Genotype = gsub("<i>|</i>", "", as.character(Genotype)),
         Genotype = factor(Genotype, levels = c("CTRL", "Inv4m")))

add_y_pos_pool <- function(tbl, data, formula_y) {
  tbl %>%
    group_by(pool, mineral) %>%
    group_modify(~ {
      sub <- data %>% filter(pool == .y$pool, mineral == .y$mineral)
      .x %>% rstatix::add_y_position(
        data = sub, formula = formula_y, step.increase = 0.12
      )
    }) %>%
    ungroup() %>%
    filter(!is.na(y.position), is.finite(y.position))
}

add_y_pos_nopool <- function(tbl, data, formula_y) {
  tbl %>%
    group_by(mineral) %>%
    group_modify(~ {
      sub <- data %>% filter(mineral == .y$mineral)
      .x %>% rstatix::add_y_position(
        data = sub, formula = formula_y, step.increase = 0.12
      )
    }) %>%
    ungroup() %>%
    filter(!is.na(y.position), is.finite(y.position))
}

genotype.conc.ttest <- unified_geno_pool %>%
  filter(metric == "conc") %>%
  add_y_pos_pool(to_plot_conc_clean, ppm ~ Genotype)

genotype.content.ttest <- unified_geno_pool %>%
  filter(metric == "content") %>%
  add_y_pos_pool(to_plot_content_clean, mg ~ Genotype)

genotype.hi.ttest <- unified_geno_pool %>%
  filter(metric == "hi") %>%
  add_y_pos_nopool(to_plot_hi_clean, hi ~ Genotype)

genotype.ratio.ttest <- unified_geno_pool %>%
  filter(metric == "ratio") %>%
  add_y_pos_nopool(to_plot_ratio_clean, ratio ~ Genotype)

cat("\n=== Inv4m contrasts (unified pooled-Genotype FDR, p.adj < 0.05) ===\n")
## 
## === Inv4m contrasts (unified pooled-Genotype FDR, p.adj < 0.05) ===
unified_geno_pool %>%
  filter(p.adj < 0.05) %>%
  select(metric, mineral, any_of("pool"), statistic, p, p.adj, p.adj.signif) %>%
  arrange(p.adj) %>%
  print(n = Inf)
## # A tibble: 14 × 7
##    metric  mineral pool   statistic           p     p.adj p.adj.signif
##    <chr>   <chr>   <fct>      <dbl>       <dbl>     <dbl> <chr>       
##  1 content Ca      stover     -5.48 0.000000959 0.0000460 ****        
##  2 conc    Ca      stover     -4.97 0.00000620  0.000149  ***         
##  3 conc    Mg      grain      -3.85 0.000344    0.00537   **          
##  4 content Zn      stover     -3.72 0.000448    0.00537   **          
##  5 conc    K       grain      -3.43 0.00125     0.0108    *           
##  6 hi      Fe      <NA>        3.41 0.00134     0.0108    *           
##  7 hi      Zn      <NA>        3.28 0.00195     0.0134    *           
##  8 conc    Zn      stover     -3.19 0.00229     0.0137    *           
##  9 conc    Mn      grain      -3.12 0.00305     0.0162    *           
## 10 conc    P       grain      -2.96 0.00478     0.0230    *           
## 11 ratio   Fe      <NA>        2.89 0.00575     0.0251    *           
## 12 content S       stover     -2.71 0.00889     0.0356    *           
## 13 content K       stover     -2.65 0.0104      0.0383    *           
## 14 content Fe      grain       2.63 0.0115      0.0394    *

Per-mineral plot functions (unchanged from base notebook)

pal <- c("+P" = "gold", "-P" = "purple4")

plot_mineral_content <- function(mineral_name, base_size = 18) {
  plot_data <- to_plot_content %>% filter(mineral == mineral_name)
  ttest_subset <- treatment.t.test %>% filter(mineral == mineral_name)

  ggplot(plot_data, aes(x = Treatment, y = mg, color = Treatment)) +
    geom_boxplot(width = 0.5, linewidth = 1, outlier.shape = NA) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    geom_quasirandom(size = 1) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    stat_pvalue_manual(ttest_subset, label = "p.adj.signif",
                       size = 8, bracket.size = 1, hide.ns = TRUE) +
    scale_color_manual(values = pal) +
    scale_y_continuous(expand = expansion(mult = c(0.05, 0.25))) +
    ggh4x::facet_nested(~ pool + Genotype, scales = "free_y") +
    labs(title = mineral_name, x = NULL, y = "Content [mg / plant]") +
    theme_classic(base_size = base_size) +
    theme(
      legend.position = "none",
      axis.text.x = element_text(color = "black", face = "bold", size = rel(1.0)),
      axis.title.y = element_text(face = "bold", size = rel(0.9)),
      plot.title = element_text(face = "bold", size = rel(1.55), hjust = 0.5),
      strip.text = element_markdown(face = "bold", size = rel(0.9)),
      strip.background = element_blank(),
      panel.spacing = unit(0.1, "lines"),
      ggh4x.facet.nestline = element_line(color = "black", linewidth = 0.5)
    )
}

plot_mineral_conc <- function(mineral_name, log_scale = FALSE, base_size = 18) {
  plot_data <- to_plot_conc %>% filter(mineral == mineral_name)
  ttest_subset <- treatment.conc.ttest %>% filter(mineral == mineral_name)

  p <- ggplot(plot_data, aes(x = Treatment, y = ppm, color = Treatment)) +
    geom_boxplot(width = 0.5, linewidth = 1, outlier.shape = NA) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    geom_quasirandom(size = 1) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    scale_color_manual(values = pal) +
    labs(title = mineral_name, x = NULL, y = "Concentration [mg / kg]")

  if (log_scale) {
    ttest_log <- ttest_subset
    ttest_log$y.position <- log2(ttest_log$y.position)
    p <- p +
      scale_y_continuous(trans = "log2",
        breaks = c(5, 10, 25, 50, 100, 200, 400, 800, 1600, 3200, 6400),
        expand = expansion(mult = c(0.05, 0.15))) +
      stat_pvalue_manual(ttest_log, label = "p.adj.signif",
                         size = 8, bracket.size = 1, hide.ns = TRUE)
  } else {
    p <- p + scale_y_continuous(expand = expansion(mult = c(0.05, 0.15))) +
      stat_pvalue_manual(ttest_subset, label = "p.adj.signif",
                         size = 8, bracket.size = 1, hide.ns = TRUE)
  }

  p + ggh4x::facet_nested(~ pool + Genotype, scales = "free_y") +
    theme_classic(base_size = base_size) +
    theme(
      legend.position = "none",
      axis.text.x = element_text(color = "black", face = "bold", size = rel(1.0)),
      axis.title.y = element_text(face = "bold", size = rel(0.9)),
      plot.title = element_text(face = "bold", size = rel(1.55), hjust = 0.5),
      strip.text = element_markdown(face = "bold", size = rel(0.9)),
      strip.background = element_blank(),
      panel.spacing = unit(0.1, "lines"),
      ggh4x.facet.nestline = element_line(color = "black", linewidth = 0.5)
    )
}

plot_mineral_ratio <- function(mineral_name, base_size = 18) {
  plot_data <- to_plot_ratio %>% filter(mineral == mineral_name)
  ttest_subset <- treatment.ratio.ttest %>% filter(mineral == mineral_name)

  ggplot(plot_data, aes(x = Treatment, y = ratio, color = Treatment)) +
    geom_boxplot(width = 0.5, linewidth = 1, outlier.shape = NA) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    geom_quasirandom(size = 1) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    stat_pvalue_manual(ttest_subset, label = "p.adj.signif",
                       size = 8, bracket.size = 1, hide.ns = TRUE) +
    scale_color_manual(values = pal) +
    scale_y_continuous(expand = expansion(mult = c(0.05, 0.15))) +
    facet_wrap(~ Genotype, nrow = 1) +
    labs(title = mineral_name, x = NULL, y = "Grain / stover ratio") +
    theme_classic(base_size = base_size) +
    theme(
      legend.position = "none",
      axis.text.x = element_text(color = "black", face = "bold", size = rel(1.0)),
      axis.title.y = element_text(face = "bold", size = rel(0.9)),
      plot.title = element_text(face = "bold", size = rel(1.55), hjust = 0.5),
      strip.text = element_markdown(face = "bold", size = rel(0.9)),
      strip.background = element_blank(),
      panel.spacing = unit(0.1, "lines")
    )
}

plot_mineral_HI_marginal <- function(mineral_name, base_size = 18) {
  plot_data <- to_plot_hi %>% filter(mineral == mineral_name)
  ttest_subset <- treatment.hi.marginal.ttest %>% filter(mineral == mineral_name)

  p <- ggplot(plot_data, aes(x = Treatment, y = hi, color = Treatment)) +
    geom_boxplot(width = 0.5, linewidth = 1, outlier.shape = NA) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    geom_quasirandom(size = 1) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    scale_color_manual(values = pal) +
    scale_y_continuous(expand = expansion(mult = c(0.05, 0.15))) +
    labs(title = mineral_name, x = NULL, y = "Mineral HI [grain / total]") +
    theme_classic(base_size = base_size) +
    theme(
      legend.position = "none",
      axis.text.x = element_text(color = "black", face = "bold", size = rel(1.0)),
      axis.title.y = element_text(face = "bold", size = rel(0.9)),
      plot.title = element_text(face = "bold", size = rel(1.55), hjust = 0.5),
      strip.text = element_markdown(face = "bold", size = rel(0.9)),
      strip.background = element_blank()
    )

  if (nrow(ttest_subset) > 0) {
    p <- p + stat_pvalue_manual(ttest_subset, label = "p.adj.signif",
                                size = 8, bracket.size = 1, hide.ns = TRUE)
  }
  p
}

plot_mineral_HI <- function(mineral_name, base_size = 18) {
  plot_data <- to_plot_hi %>% filter(mineral == mineral_name)
  ttest_subset <- treatment.hi.ttest %>% filter(mineral == mineral_name)

  p <- ggplot(plot_data, aes(x = Treatment, y = hi, color = Treatment)) +
    geom_boxplot(width = 0.5, linewidth = 1, outlier.shape = NA) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    geom_quasirandom(size = 1) %>%
    with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    scale_color_manual(values = pal) +
    scale_y_continuous(expand = expansion(mult = c(0.05, 0.15))) +
    labs(title = mineral_name, x = NULL, y = "Mineral HI [grain / total]") +
    facet_wrap(~ Genotype) +
    theme_classic(base_size = base_size) +
    theme(
      legend.position = "none",
      axis.text.x = element_text(color = "black", face = "bold", size = rel(1.0)),
      axis.title.y = element_text(face = "bold", size = rel(0.9)),
      plot.title = element_text(face = "bold", size = rel(1.55), hjust = 0.5),
      strip.text = element_markdown(face = "bold", size = rel(0.9)),
      strip.background = element_blank()
    )

  if (nrow(ttest_subset) > 0) {
    p <- p + stat_pvalue_manual(ttest_subset, label = "p.adj.signif",
                                size = 8, bracket.size = 1, hide.ns = TRUE)
  }
  p
}

Main 4-panel figure

rowA_main <- ggarrange(
  plot_mineral_conc("P"),
  plot_mineral_conc("Zn") + ggpubr::rremove("ylab"),
  plot_mineral_conc("Ca", log_scale = TRUE) + ggpubr::rremove("ylab"),
  plot_mineral_conc("S")  + ggpubr::rremove("ylab"),
  ncol = 4, nrow = 1, align = "h"
)

rowB_main <- ggarrange(
  plot_mineral_ratio("P") + labs(title = NULL) +
    scale_y_continuous(breaks = 1:5,
                       expand = expansion(mult = c(0.05, 0.15))),
  plot_mineral_ratio("Zn") + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_ratio("Ca") + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_ratio("S")  + labs(title = NULL) + ggpubr::rremove("ylab"),
  ncol = 4, nrow = 1, align = "h"
)

rowC_main <- ggarrange(
  plot_mineral_content("P")  + labs(title = NULL),
  plot_mineral_content("Zn") + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_content("Ca") + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_content("S")  + labs(title = NULL) + ggpubr::rremove("ylab"),
  ncol = 4, nrow = 1, align = "h"
)

rowD_main <- ggarrange(
  plot_mineral_HI("P")  + labs(title = NULL),
  plot_mineral_HI("Zn") + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_HI("Ca") + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_HI("S")  + labs(title = NULL) + ggpubr::rremove("ylab"),
  ncol = 4, nrow = 1, align = "h"
)

p_main_4panel <- ggarrange(
  rowA_main, rowB_main, rowC_main, rowD_main,
  ncol = 1, nrow = 4,
  heights = c(1, 0.8, 0.9, 0.8),
  labels = c("A", "B", "C", "D"),
  font.label = list(size = 30),
  align = "hv"
)
print(p_main_4panel)

Supplementary 4-panel figure

rowA_supp <- ggarrange(
  plot_mineral_conc("Mg"),
  plot_mineral_conc("Mn", log_scale = TRUE) + ggpubr::rremove("ylab"),
  plot_mineral_conc("K")  + ggpubr::rremove("ylab"),
  plot_mineral_conc("Fe") + ggpubr::rremove("ylab"),
  ncol = 4, nrow = 1, align = "h"
)

rowB_supp <- ggarrange(
  plot_mineral_ratio("Mg") + labs(title = NULL),
  plot_mineral_ratio("Mn") + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_ratio("K")  + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_ratio("Fe") + labs(title = NULL) + ggpubr::rremove("ylab"),
  ncol = 4, nrow = 1, align = "h"
)

rowC_supp <- ggarrange(
  plot_mineral_content("Mg") + labs(title = NULL),
  plot_mineral_content("Mn") + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_content("K")  + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_content("Fe") + labs(title = NULL) + ggpubr::rremove("ylab"),
  ncol = 4, nrow = 1, align = "h"
)

rowD_supp <- ggarrange(
  plot_mineral_HI("Mg") + labs(title = NULL),
  plot_mineral_HI("Mn") + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_HI("K")  + labs(title = NULL) + ggpubr::rremove("ylab"),
  plot_mineral_HI("Fe") + labs(title = NULL) + ggpubr::rremove("ylab"),
  ncol = 4, nrow = 1, align = "h"
)

p_supp_4panel <- ggarrange(
  rowA_supp, rowB_supp, rowC_supp, rowD_supp,
  ncol = 1, nrow = 4,
  heights = c(1, 0.8, 0.9, 0.8),
  labels = c("A", "B", "C", "D"),
  font.label = list(size = 30),
  align = "hv"
)
print(p_supp_4panel)

Inv4m supp figure (4 metrics x 5 ions; Figure-2-style organization)

Rows are metrics (conc, content, HI, ratio) and columns are ions (Mg, Ca, Zn, Fe, K). For conc and content, a single pool is shown per cell — the pool where the Inv4m effect is significant under the unified FDR, or stover as a fallback. Ion symbols are shown as plot titles on the top row (A, conc); pool (grain / stover) appears as a facet strip on A and B. Brackets are drawn only when the unified pooled-Genotype FDR is < 0.05.

pal_geno <- c("CTRL" = "gold", "Inv4m" = "purple4")

plot_inv4m_cell <- function(mineral_name, metric, pool_name = NULL,
                            show_title = FALSE, show_ylab = TRUE,
                            base_size = 18, log_scale = FALSE) {
  if (metric == "conc") {
    d <- to_plot_conc %>% filter(mineral == mineral_name, pool == pool_name)
    s <- genotype.conc.ttest %>% filter(mineral == mineral_name, pool == pool_name)
    y_var <- "ppm"
    ylab_text <- "Concentration [mg / kg]"
  } else if (metric == "content") {
    d <- to_plot_content %>% filter(mineral == mineral_name, pool == pool_name)
    s <- genotype.content.ttest %>% filter(mineral == mineral_name, pool == pool_name)
    y_var <- "mg"
    ylab_text <- "Content [mg / plant]"
  } else if (metric == "hi") {
    d <- to_plot_hi %>% filter(mineral == mineral_name)
    s <- genotype.hi.ttest %>% filter(mineral == mineral_name)
    y_var <- "hi"
    ylab_text <- "Mineral HI [grain / total]"
  } else if (metric == "ratio") {
    d <- to_plot_ratio %>% filter(mineral == mineral_name)
    s <- genotype.ratio.ttest %>% filter(mineral == mineral_name)
    y_var <- "ratio"
    ylab_text <- "Grain / stover ratio"
  }

  d <- d %>%
    mutate(Genotype = gsub("<i>|</i>", "", as.character(Genotype)),
           Genotype = factor(Genotype, levels = c("CTRL", "Inv4m")))

  p <- ggplot(d, aes(x = Genotype, y = .data[[y_var]], colour = Genotype)) +
    geom_boxplot(width = 0.5, linewidth = 1, outlier.shape = NA) %>%
      with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    geom_quasirandom(size = 1.2) %>%
      with_shadow(colour = "black", x_offset = 0, y_offset = 0, sigma = 1) +
    scale_colour_manual(values = pal_geno) +
    scale_x_discrete(labels = c("CTRL" = "CTRL", "Inv4m" = "<i>Inv4m</i>")) +
    labs(
      title = if (show_title) mineral_name
              else if (metric %in% c("hi", "ratio")) " "
              else NULL,
      x = NULL,
      y = if (show_ylab) ylab_text else NULL
    )

  if (log_scale) {
    p <- p + scale_y_continuous(
      trans = "log10", expand = expansion(mult = c(0.05, 0.15))
    )
  } else {
    p <- p + scale_y_continuous(expand = expansion(mult = c(0.05, 0.15)))
  }

  if (metric %in% c("conc", "content")) {
    p <- p + facet_wrap(~ pool, nrow = 1, strip.position = "top")
  }

  if (nrow(s) > 0) {
    p <- p + stat_pvalue_manual(
      s, label = "p.adj.signif",
      size = 8, bracket.size = 1, hide.ns = TRUE
    )
  }

  p + theme_classic(base_size = base_size) +
    theme(
      legend.position = "none",
      axis.text.x = element_markdown(face = "bold", size = rel(1.0), color = "black"),
      axis.title.y = element_text(face = "bold", size = rel(0.9)),
      plot.title = element_text(face = "bold", size = rel(1.55), hjust = 0.5),
      strip.text = element_text(face = "bold", size = rel(0.9)),
      strip.background = element_blank()
    )
}
ions <- c("Mg", "Ca", "Zn", "Fe", "K")

# Selected pool per cell for conc / content. Sig pool if any, else stover.
pool_conc    <- c(Mg = "grain",  Ca = "stover", Zn = "stover",
                  Fe = "stover", K  = "stover")
pool_content <- c(Mg = "stover", Ca = "stover", Zn = "stover",
                  Fe = "grain",  K  = "stover")

# Row order: A = conc (with ion titles), B = ratio, C = content, D = HI
row_A <- lapply(ions, function(m) {
  plot_inv4m_cell(m, "conc", pool_name = pool_conc[[m]],
                  show_title = TRUE, show_ylab = (m == "Mg"))
})
row_B <- lapply(ions, function(m) {
  plot_inv4m_cell(m, "ratio",
                  show_title = FALSE, show_ylab = (m == "Mg"))
})
row_C <- lapply(ions, function(m) {
  plot_inv4m_cell(m, "content", pool_name = pool_content[[m]],
                  show_title = FALSE, show_ylab = (m == "Mg"))
})
row_D <- lapply(ions, function(m) {
  plot_inv4m_cell(m, "hi",
                  show_title = FALSE, show_ylab = (m == "Mg"))
})

p_inv4m_effects <- ggarrange(
  ggarrange(plotlist = row_A, ncol = 5, align = "h"),
  ggarrange(plotlist = row_B, ncol = 5, align = "h"),
  ggarrange(plotlist = row_C, ncol = 5, align = "h"),
  ggarrange(plotlist = row_D, ncol = 5, align = "h"),
  ncol = 1, nrow = 4,
  heights = c(1, 1, 1, 1),
  labels = c("A", "B", "C", "D"),
  font.label = list(size = 30)
)

print(p_inv4m_effects)

Save outputs

write.csv(
  effects,
  file.path(paths$intermediate, "ionome_content_linear_model_effects.csv"),
  row.names = FALSE
)

write_ttest <- function(x, name) {
  if (nrow(x) == 0) return(invisible(NULL))
  write.csv(
    x %>% select(where(~ !is.list(.))),
    file.path(paths$intermediate, name),
    row.names = FALSE
  )
}

write_ttest(treatment.t.test,       "ionome_content_treatment_ttests.csv")
write_ttest(treatment.conc.ttest,   "ionome_content_conc_treatment_ttests.csv")
write_ttest(treatment.hi.ttest,     "ionome_content_hi_treatment_ttests.csv")
write_ttest(treatment.ratio.ttest,  "ionome_content_ratio_treatment_ttests.csv")
write_ttest(genotype.conc.ttest,    "ionome_content_conc_genotype_ttests.csv")
write_ttest(genotype.content.ttest, "ionome_content_genotype_ttests.csv")
write_ttest(genotype.hi.ttest,      "ionome_content_hi_genotype_ttests.csv")
write_ttest(genotype.ratio.ttest,   "ionome_content_ratio_genotype_ttests.csv")
write_ttest(unified_geno_pool %>% select(where(~ !is.list(.))),
            "ionome_content_unified_genotype_ttests.csv")

ggsave(file.path(paths$figures, "ionome_4panel_main_figure.png"),
       plot = p_main_4panel, width = 15, height = 16, units = "in", dpi = 300)
ggsave(file.path(paths$figures, "ionome_4panel_supplementary_figure.png"),
       plot = p_supp_4panel, width = 15, height = 16, units = "in", dpi = 300)
ggsave(file.path(paths$figures, "ionome_inv4m_effects_figure.png"),
       plot = p_inv4m_effects, width = 14, height = 16, units = "in", dpi = 300)

Session info

sessionInfo()
## R version 4.5.2 (2025-10-31)
## Platform: aarch64-apple-darwin20
## Running under: macOS Tahoe 26.3.1
## 
## Matrix products: default
## BLAS:   /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
## 
## locale:
## [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
## 
## time zone: America/New_York
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] emmeans_2.0.1    ggh4x_0.3.1      ggtext_0.1.2     rstatix_0.7.3   
##  [5] ggfx_1.0.3       ggbeeswarm_0.7.3 ggpubr_0.6.2     tibble_3.3.0    
##  [9] ggplot2_4.0.1    tidyr_1.3.2      dplyr_1.1.4     
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6       beeswarm_0.4.0     xfun_0.55          bslib_0.9.0       
##  [5] lattice_0.22-7     vctrs_0.6.5        tools_4.5.2        generics_0.1.4    
##  [9] pkgconfig_2.0.3    RColorBrewer_1.1-3 S7_0.2.1           lifecycle_1.0.4   
## [13] stringr_1.6.0      compiler_4.5.2     farver_2.1.2       textshaping_1.0.4 
## [17] carData_3.0-5      litedown_0.9       vipor_0.4.7        htmltools_0.5.9   
## [21] sass_0.4.10        yaml_2.3.12        Formula_1.2-5      pillar_1.11.1     
## [25] car_3.1-3          jquerylib_0.1.4    cachem_1.1.0       magick_2.9.0      
## [29] abind_1.4-8        commonmark_2.0.0   tidyselect_1.2.1   digest_0.6.39     
## [33] stringi_1.8.7      mvtnorm_1.3-3      purrr_1.2.0        labeling_0.4.3    
## [37] cowplot_1.2.0      rprojroot_2.1.1    fastmap_1.2.0      grid_4.5.2        
## [41] here_1.0.2         cli_3.6.5          magrittr_2.0.4     utf8_1.2.6        
## [45] dichromat_2.0-0.1  broom_1.0.11       withr_3.0.2        scales_1.4.0      
## [49] backports_1.5.0    estimability_1.5.1 rmarkdown_2.30     otel_0.2.0        
## [53] ggsignif_0.6.4     ragg_1.5.0         coda_0.19-4.1      evaluate_1.0.5    
## [57] knitr_1.51         markdown_2.0       rlang_1.1.6        gridtext_0.1.5    
## [61] Rcpp_1.1.0         xtable_1.8-4       glue_1.8.0         xml2_1.5.1        
## [65] jsonlite_2.0.0     R6_2.6.1           systemfonts_1.3.1