Prof. Marc Los Huertos • Pomona College • Environmental Analysis
What this activity covers. Compost quality is not all-or-nothing — it exists on a continuum from fresh, biologically active material that can harm plants to fully stabilised humus that improves soil structure and fertility. The Solvita paddle test provides a fast, standardised maturity index by measuring CO₂ respiration and NH₃ emission from a sealed compost sample. In this activity you will import your class’s Solvita data, visualise the score distributions, and use t-tests and ANOVA to test whether different compost treatments reach maturity at different rates.
Compost maturity has consequences across multiple domains:
Agriculture. Immature compost applied to soil continues to decompose, consuming nitrogen that plants need (nitrogen immobilisation) and potentially releasing phytotoxic compounds. Mature compost improves soil structure, water retention, and cation exchange capacity without these risks.
Climate. Active composting produces CO₂ and can produce N₂O — both greenhouse gases. Well-managed, mature compost that is land-applied sequesters carbon in stable humic fractions, offsetting some of those emissions.
Waste. Diverting organic waste from landfills to compost eliminates the methane that landfill anaerobic decomposition would otherwise produce. Methane has approximately 28× the warming potential of CO₂ over a 100-year horizon.
Public health. The high temperatures of active composting (55–65°C) pasteurise pathogens and weed seeds. A mature, stable compost signals that this pasteurisation phase is complete and the material is safe to handle and apply.
📌 What does “mature” mean biologically? Maturity is not a single threshold but a gradient. The Solvita test operationalises it through two proxies: CO₂ respiration (how actively microbes are still decomposing) and NH₃ emission (how much nitrogen is being lost as ammonia, signalling protein mineralisation). Low values of both indicate that the microbial community has consumed most of the labile carbon and stabilised, the definition of maturity.
The Solvita Compost Maturity Test uses two colorimetric gel paddles inserted into a sealed jar of compost. After 4–24 hours of incubation, the paddle colours are matched to reference charts to yield:
The two scores are combined into a single Solvita Maturity Index (SMI). Compost with SMI ≥ 6 is generally considered mature and appropriate for most agricultural applications.
The SMI is derived from the CO₂ and NH₃ scores using Solvita’s published lookup table. The CO₂ score is the primary driver; the NH₃ score can reduce the index if elevated ammonia is detected.
| CO₂ score | NH₃ = 1–2 | NH₃ = 3–4 | NH₃ = 5–6 | NH₃ = 7–8 |
|---|---|---|---|---|
| 1–2 | 1 | 1 | 1 | 2 |
| 3–4 | 2 | 3 | 3 | 4 |
| 5–6 | 3 | 4 | 5 | 6 |
| 7–8 | 4 | 5 | 6 | 7–8 |
💡 Enter your data in the class Google Sheet as you go. The sheet is at this link. Record: sample date, treatment label, replicate number, CO₂ score, NH₃ score, calculated SMI, and any notes on moisture or unusual colour. Consistent recording now saves debugging time later.
library(tidyverse)
library(knitr)
library(kableExtra)
Export the class Google Sheet as CSV (File → Download → CSV)
and place it in your project’s data/ folder. Update the
path below.
# ── Replace this path with your exported CSV ────────────────────────────────
# solvita <- read_csv("data/solvita_class_data.csv")
# ── Simulated class dataset used here ───────────────────────────────────────
# Three treatments: Yard waste only, Kitchen + yard, Kitchen + yard + manure
# Two time points: Week 4 and Week 8 of composting
set.seed(42)
n_per <- 8 # replicates per treatment × time
solvita <- tibble(
treatment = rep(c("Yard waste", "Kitchen + yard", "Kitchen + yard + manure"),
each = n_per * 2),
week = rep(rep(c(4L, 8L), each = n_per), times = 3),
co2_score = c(
round(rnorm(n_per, 2.8, 0.6)), round(rnorm(n_per, 5.2, 0.7)), # yard
round(rnorm(n_per, 3.2, 0.7)), round(rnorm(n_per, 5.8, 0.6)), # k+y
round(rnorm(n_per, 3.8, 0.8)), round(rnorm(n_per, 6.4, 0.7)) # k+y+m
),
nh3_score = c(
round(rnorm(n_per, 5.5, 0.6)), round(rnorm(n_per, 7.0, 0.5)),
round(rnorm(n_per, 4.5, 0.7)), round(rnorm(n_per, 6.5, 0.6)),
round(rnorm(n_per, 3.5, 0.8)), round(rnorm(n_per, 5.8, 0.7))
)
) %>%
# Clamp scores to valid 1–8 range
mutate(
co2_score = pmax(1L, pmin(8L, co2_score)),
nh3_score = pmax(1L, pmin(8L, nh3_score)),
# Solvita Maturity Index: simplified as min(co2_score, nh3_score + 1)
# Use the lookup table above for a precise calculation
smi = pmin(co2_score, nh3_score + 1L),
treatment = factor(treatment,
levels = c("Yard waste", "Kitchen + yard", "Kitchen + yard + manure"))
)
glimpse(solvita)
## Rows: 48
## Columns: 5
## $ treatment <fct> Yard waste, Yard waste, Yard waste, Yard waste, Yard waste, …
## $ week <int> 4, 4, 4, 4, 4, 4, 4, 4, 8, 8, 8, 8, 8, 8, 8, 8, 4, 4, 4, 4, …
## $ co2_score <dbl> 4, 2, 3, 3, 3, 3, 4, 3, 7, 5, 6, 7, 4, 5, 5, 6, 3, 1, 1, 4, …
## $ nh3_score <dbl> 5, 6, 6, 5, 6, 6, 6, 6, 7, 7, 6, 7, 7, 7, 7, 8, 4, 5, 5, 5, …
## $ smi <dbl> 4, 2, 3, 3, 3, 3, 4, 3, 7, 5, 6, 7, 4, 5, 5, 6, 3, 1, 1, 4, …
cat("Rows:", nrow(solvita), "| Missing values:", sum(is.na(solvita)), "\n\n")
## Rows: 48 | Missing values: 0
# Score range check — all values should be integers 1–8
solvita %>%
summarise(
co2_min = min(co2_score), co2_max = max(co2_score),
nh3_min = min(nh3_score), nh3_max = max(nh3_score),
smi_min = min(smi), smi_max = max(smi)
) %>%
kbl(caption = "Score range check (valid range: 1–8)") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
| co2_min | co2_max | nh3_min | nh3_max | smi_min | smi_max |
|---|---|---|---|---|---|
| 1 | 7 | 3 | 8 | 1 | 7 |
# Sample count per treatment × week
solvita %>%
count(treatment, week) %>%
pivot_wider(names_from = week, values_from = n,
names_prefix = "week_") %>%
kbl(caption = "Replicates per treatment and week") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
| treatment | week_4 | week_8 |
|---|---|---|
| Yard waste | 8 | 8 |
| Kitchen + yard | 8 | 8 |
| Kitchen + yard + manure | 8 | 8 |
treat_colors <- c(
"Yard waste" = "#8aad96",
"Kitchen + yard" = "#c8832a",
"Kitchen + yard + manure" = "#1e3a2f"
)
solvita %>%
mutate(week = factor(week, labels = c("Week 4", "Week 8"))) %>%
ggplot(aes(x = treatment, y = smi, fill = treatment)) +
geom_boxplot(alpha = 0.75, outlier.shape = 21, outlier.size = 2.5,
outlier.fill = "white") +
geom_jitter(width = 0.12, size = 2.2, alpha = 0.6,
aes(color = treatment)) +
geom_hline(yintercept = 6, linetype = "dashed",
color = "#2e7d32", linewidth = 0.8) +
annotate("text", x = 0.55, y = 6.15, label = "Maturity threshold (SMI = 6)",
color = "#2e7d32", size = 3, hjust = 0, fontface = "italic") +
facet_wrap(~ week) +
scale_fill_manual(values = treat_colors, guide = "none") +
scale_color_manual(values = treat_colors, guide = "none") +
scale_x_discrete(guide = guide_axis(angle = 25)) +
scale_y_continuous(breaks = 1:8, limits = c(1, 8.5)) +
labs(
title = "Solvita Maturity Index by treatment and week",
subtitle = "Dashed line = SMI 6 maturity threshold",
x = NULL, y = "Solvita Maturity Index (SMI)"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(family = "serif", face = "bold",
color = "#1e3a2f", size = 13),
plot.subtitle = element_text(color = "#7a7a72", size = 10),
strip.text = element_text(face = "bold"),
panel.grid.major.x = element_blank()
)
A t-test compares the means of exactly two groups. Choose the type based on your study design:
| Design | Test | R function |
|---|---|---|
| Two independent groups (e.g., yard waste vs. kitchen + yard) | Independent samples t-test | t.test(x, y, var.equal = TRUE) |
| Same samples measured twice (e.g., Week 4 vs. Week 8 of the same pile) | Paired t-test | t.test(x, y, paired = TRUE) |
⚙️ The t-statistic
t = (x̄₁ − x̄₂) / SE
where SE is the standard error of the difference between means. Larger
|t| means the observed difference is larger relative to the variability
in the data. The p-value asks: if the null hypothesis (x̄₁ = x̄₂) were
true, how often would we observe a t-statistic this extreme by
chance?
Comparing Yard waste vs. Kitchen + yard at Week 8 — two independent treatment groups.
week8 <- solvita %>% filter(week == 8)
yard_smi <- week8 %>% filter(treatment == "Yard waste") %>% pull(smi)
kitchen_smi <- week8 %>% filter(treatment == "Kitchen + yard") %>% pull(smi)
t_result <- t.test(kitchen_smi, yard_smi,
alternative = "two.sided",
var.equal = TRUE)
print(t_result)
##
## Two Sample t-test
##
## data: kitchen_smi and yard_smi
## t = 0.5706, df = 14, p-value = 0.5773
## alternative hypothesis: true difference in means is not equal to 0
## 95 percent confidence interval:
## -0.689711 1.189711
## sample estimates:
## mean of x mean of y
## 5.875 5.625
cat(sprintf(
"Mean SMI — Kitchen + yard: %.2f | Yard waste: %.2f\n",
mean(kitchen_smi), mean(yard_smi)
))
## Mean SMI — Kitchen + yard: 5.88 | Yard waste: 5.62
cat(sprintf(
"Difference: %.2f Solvita units | 95%% CI: [%.2f, %.2f]\n",
diff(t_result$estimate),
t_result$conf.int[1], t_result$conf.int[2]
))
## Difference: -0.25 Solvita units | 95% CI: [-0.69, 1.19]
cat(sprintf("t(%d) = %.3f, p = %.4f\n",
t_result$parameter,
t_result$statistic,
t_result$p.value))
## t(14) = 0.571, p = 0.5773
📌 Interpreting the result. If p < 0.05, we reject H₀ (equal means) and conclude that kitchen and yard waste compost reached statistically different maturity at Week 8. The confidence interval on the difference tells you the range of plausible true differences — if it excludes zero, the difference is significant at α = 0.05.
When the same compost pile is tested at two time points, the two measurements are not independent — they share the same pile’s characteristics. A paired test accounts for this by analysing the within-pile change.
kitchen_data <- solvita %>%
filter(treatment == "Kitchen + yard") %>%
arrange(week) %>%
group_by(week) %>%
mutate(rep_id = row_number()) %>%
ungroup()
w4 <- kitchen_data %>% filter(week == 4) %>% pull(smi)
w8 <- kitchen_data %>% filter(week == 8) %>% pull(smi)
paired_result <- t.test(w8, w4, paired = TRUE, alternative = "greater")
print(paired_result)
##
## Paired t-test
##
## data: w8 and w4
## t = 6.6193, df = 7, p-value = 0.0001495
## alternative hypothesis: true mean difference is greater than 0
## 95 percent confidence interval:
## 2.31978 Inf
## sample estimates:
## mean difference
## 3.25
cat(sprintf(
"\nMean change Week 4 → Week 8: +%.2f SMI units\n",
mean(w8 - w4)
))
##
## Mean change Week 4 → Week 8: +3.25 SMI units
💡 Why use alternative = "greater"
here? We have a directional hypothesis: compost should mature
over time, so Week 8 SMI should be higher than Week 4. Using a
one-tailed test (alternative = "greater") is appropriate
when theory strongly predicts the direction of the effect. Always
declare the direction before seeing the data, not after.
When there are three or more treatment groups, running multiple t-tests inflates the familywise error rate. If you run three pairwise t-tests each at α = 0.05, the probability of at least one false positive is approximately 14%, not 5%. ANOVA tests all groups simultaneously while controlling this rate.
H₀: All treatment means are equal (μ₁ = μ₂ =
μ₃).
H₁: At least one treatment mean differs.
library(knitr)
# Week 8 data only — three treatments
week8_all <- solvita %>% filter(week == 8)
aov_result <- aov(smi ~ treatment, data = week8_all)
summary(aov_result)
## Df Sum Sq Mean Sq F value Pr(>F)
## treatment 2 1.583 0.7917 1.167 0.331
## Residuals 21 14.250 0.6786
broom::tidy(aov_result) %>%
mutate(across(where(is.numeric), ~ round(.x, 3))) %>%
kbl(caption = "One-way ANOVA: SMI ~ treatment (Week 8)") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
| term | df | sumsq | meansq | statistic | p.value |
|---|---|---|---|---|---|
| treatment | 2 | 1.583 | 0.792 | 1.167 | 0.331 |
| Residuals | 21 | 14.250 | 0.679 | NA | NA |
⚙️ The F-statistic
F = MStreatment /
MSresidual
MStreatment measures how much
group means vary relative to the grand mean. MSresidual
measures how much observations vary within groups. A large F means
treatment differences are large relative to within-group noise — strong
evidence against H₀.
ANOVA requires: (1) normally distributed residuals, (2) approximately equal variance across groups, and (3) independent observations.
par(mfrow = c(1, 2), mar = c(4, 4, 3, 1))
qqnorm(residuals(aov_result), main = "Normal Q-Q — SMI residuals",
col = "#4a7c5e", pch = 19, cex = 1.1)
qqline(residuals(aov_result), col = "#c8832a", lwd = 2)
plot(fitted(aov_result), residuals(aov_result),
xlab = "Fitted values (group means)", ylab = "Residuals",
main = "Residuals vs. fitted",
col = "#4a7c5e", pch = 19, cex = 1.1)
abline(h = 0, col = "#c8832a", lwd = 2, lty = 2)
par(mfrow = c(1, 1))
# Shapiro-Wilk test for normality of residuals
shapiro.test(residuals(aov_result))
##
## Shapiro-Wilk normality test
##
## data: residuals(aov_result)
## W = 0.97444, p-value = 0.7759
# Levene's test for homogeneity of variance
car::leveneTest(smi ~ treatment, data = week8_all)
## Levene's Test for Homogeneity of Variance (center = median)
## Df F value Pr(>F)
## group 2 1.9783 0.1633
## 21
⚠️ Solvita scores are ordinal integers, not continuous. SMI ranges from 1–8 in whole-number steps, which technically violates the continuous-variable assumption of ANOVA. In practice, with n ≥ 5 per group ANOVA is robust to this. If you are concerned, use the non-parametric Kruskal-Wallis test as a sensitivity check.
kruskal.test(smi ~ treatment, data = week8_all)
##
## Kruskal-Wallis rank sum test
##
## data: smi by treatment
## Kruskal-Wallis chi-squared = 2.0983, df = 2, p-value = 0.3502
A significant ANOVA F-test only tells you that at least one group differs. Tukey’s Honest Significant Difference (HSD) test identifies which pairs differ while controlling the familywise error rate.
tukey_result <- TukeyHSD(aov_result)
print(tukey_result)
## Tukey multiple comparisons of means
## 95% family-wise confidence level
##
## Fit: aov(formula = smi ~ treatment, data = week8_all)
##
## $treatment
## diff lwr upr p adj
## Kitchen + yard-Yard waste 0.250 -0.7881655 1.288166 0.8178996
## Kitchen + yard + manure-Yard waste 0.625 -0.4131655 1.663166 0.3032456
## Kitchen + yard + manure-Kitchen + yard 0.375 -0.6631655 1.413166 0.6398253
tukey_tbl <- broom::tidy(tukey_result) %>%
select(contrast, estimate, conf.low, conf.high, adj.p.value) %>%
mutate(across(where(is.numeric), ~ round(.x, 3))) %>%
arrange(adj.p.value)
tukey_tbl %>%
kbl(
caption = "Tukey HSD pairwise comparisons — SMI at Week 8",
col.names = c("Comparison", "Difference",
"Lower 95% CI", "Upper 95% CI", "Adj. p-value")
) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE) %>%
row_spec(which(tukey_tbl$adj.p.value < 0.05),
bold = TRUE, background = "#f1f8f1")
| Comparison | Difference | Lower 95% CI | Upper 95% CI | Adj. p-value |
|---|---|---|---|---|
| Kitchen + yard + manure-Yard waste | 0.625 | -0.413 | 1.663 | 0.303 |
| Kitchen + yard + manure-Kitchen + yard | 0.375 | -0.663 | 1.413 | 0.640 |
| Kitchen + yard-Yard waste | 0.250 | -0.788 | 1.288 | 0.818 |
tukey_df <- as.data.frame(tukey_result$treatment) %>%
rownames_to_column("comparison") %>%
rename(estimate = diff, conf.low = lwr, conf.high = upr, adj.p.value = `p adj`) %>%
mutate(significant = adj.p.value < 0.05)
ggplot(tukey_df, aes(x = reorder(comparison, estimate),
y = estimate, color = significant)) +
geom_hline(yintercept = 0, linetype = "dashed",
color = "#7a7a72", linewidth = 0.7) +
geom_errorbar(aes(ymin = conf.low, ymax = conf.high),
width = 0.18, linewidth = 0.9) +
geom_point(size = 3.5) +
coord_flip() +
scale_color_manual(
values = c("TRUE" = "#1e3a2f", "FALSE" = "#8aad96"),
labels = c("TRUE" = "p < 0.05", "FALSE" = "p ≥ 0.05"),
name = NULL
) +
labs(
title = "Tukey HSD: pairwise SMI differences at Week 8",
subtitle = "Intervals crossing zero are not significant",
x = NULL, y = "Difference in mean SMI"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(family = "serif", face = "bold",
color = "#1e3a2f", size = 13),
plot.subtitle = element_text(color = "#7a7a72", size = 10),
legend.position = "top"
)
Brinton, W. F., et al. 1995. Development and application of the Solvita compost-maturity test. Compost Science and Utilization 3:60–65.
Maynard, A. A. 2000. Compost: the process and research. Connecticut Agricultural Experiment Station Bulletin 966.
US Composting Council. 2002. Test Methods for the Examination of Composting and Compost (TMECC). Holbrook, NY.