Testing Compost Maturity

Marc Los Huertos

EA030 · Compiled 2026-08-17

Introduction Solvita Methods Import Data t-test ANOVA Assumptions Post-hoc Summary

🎯 Learning Outcomes

  1. Describe the biological basis of the Solvita compost maturity test and interpret CO₂ and NH₃ scores.
  2. Import class Solvita data from a Google Sheet CSV export and check for entry errors.
  3. Select and execute the correct t-test (independent or paired) for a two-treatment compost comparison.
  4. Run a one-way ANOVA to compare Solvita maturity indices across three or more compost treatments.
  5. Check ANOVA assumptions — normality of residuals, homogeneity of variance — and choose a robust alternative when assumptions are violated.
  6. Interpret Tukey HSD results and report which treatment pairs differ significantly.

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.


1. Introduction: Why Compost Maturity Matters

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.


2. Solvita Field Methods

2.1 The Solvita paddle system

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.

1
Very immature
2
Immature
3
Active
4
Maturing
5
Near mature
6
Mature
7
Well mature
8
Fully stable

2.2 Step-by-step protocol

Step 1
Collect sample
Composite from multiple pile locations. Target ~50% moisture — damp but not dripping when squeezed.
Step 2
Fill jar
100–150 g compost in a Solvita jar. Leave headspace. Insert paddle without touching compost surface.
Step 3
Incubate
Seal the jar. Incubate 4–24 hours at room temperature, undisturbed.
Step 4
Score & record
Match paddle colour to the reference chart. Record CO₂ score, NH₃ score, and calculate the SMI. Enter in the class Google Sheet.

2.3 Calculating the Solvita Maturity Index

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.


3. Importing and Checking the Class Data

3.1 Packages

library(tidyverse)
library(knitr)
library(kableExtra)

3.2 Read the data

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, …

3.3 Quality checks

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)
Score range check (valid range: 1–8)
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)
Replicates per treatment and week
treatment week_4 week_8
Yard waste 8 8
Kitchen + yard 8 8
Kitchen + yard + manure 8 8

3.4 Exploratory visualisation

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()
  )


4. t-test: Comparing Two Treatments

4.1 When to use a t-test

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?

4.2 Independent samples t-test: Week 8 scores

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.

4.3 Paired t-test: Week 4 vs. Week 8 (same piles)

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.


5. ANOVA: Comparing Three or More Treatments

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)
One-way ANOVA: SMI ~ treatment (Week 8)
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₀.


6. Checking ANOVA Assumptions

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

7. Post-hoc Tests: Which Pairs Differ?

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")
Tukey HSD pairwise comparisons — SMI at Week 8
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

7.1 Visualise pairwise differences

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"
  )


8. Summary

💡 Key takeaways

💬 Discussion questions

  1. Your yard-waste pile scores SMI = 5.2 at Week 8 while your kitchen + yard pile scores SMI = 6.4. The t-test is significant (p = 0.03). Would you recommend applying the yard-waste compost to a vegetable garden? What practical factors beyond the p-value inform your decision?
  2. Why does the paired t-test generally have more statistical power than the independent t-test when the same piles are tested at two time points? Draw a sketch of what “within-pile variation” looks like and why removing it matters.
  3. The Kruskal-Wallis test returned the same conclusion as the ANOVA in the example above. When would you expect the two tests to disagree, and which would you trust more?
  4. The ANOVA F-test was significant but the Tukey HSD showed that only one pair differed significantly. How is this possible? Does it mean the ANOVA result was a false positive?
  5. Compost temperature, C/N ratio, and moisture content are covariates that influence maturity rate. How would you modify the ANOVA model to control for one of these — and what R code would you write to do it?

References

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.