Author: agriReg Authors
Date: 2026-03-27

# Advanced: Mixed models and multi-environment trials

#### agriReg Authors

#### 2026-03-27

## Multi-environment mixed model

In multi-environment trials (MET) yield is affected by genotype (G), environment (E), and their interaction (GEI). A mixed model is the standard approach:

    set.seed(2024)
    # Simulate a 3-genotype × 4-environment trial with 3 reps
    met <- expand.grid(
      genotype    = paste0("G", 1:3),
      environment = paste0("E", 1:4),
      rep         = 1:3,
      stringsAsFactors = FALSE
    )
    # True effects
    g_eff <- c(G1 = 0, G2 = 0.3, G3 = -0.2)
    e_eff <- c(E1 = 0, E2 = 0.5, E3 = -0.3, E4 = 0.1)
    set.seed(42)
    met$yield <- 4 +
      g_eff[met$genotype] +
      e_eff[met$environment] +
      rnorm(nrow(met), 0, 0.15)   # residual

    head(met)
    #>   genotype environment rep    yield
    #> 1       G1          E1   1 4.205644
    #> 2       G2          E1   1 4.215295
    #> 3       G3          E1   1 3.854469
    #> 4       G1          E2   1 4.594929
    #> 5       G2          E2   1 4.860640
    #> 6       G3          E2   1 4.284081

### Fixed genotype, random environment

    # Environment as random, genotype as fixed
    m_met <- fit_linear(met,
                        formula = "yield ~ genotype",
                        random  = "(1|environment) + (1|environment:rep)")
    summary(m_met)
    #> ==============================
    #>  agriReg: Linear Model Summary
    #> ==============================
    #> Engine : lmer
    #>
    #> Formula: yield ~ genotype
    #> Random : (1|environment) + (1|environment:rep)
    #>
    #> Fixed effects:
    #> (Intercept)  genotypeG2  genotypeG3
    #>   4.0557062   0.3552232  -0.1669417
    #>
    #> Random effects (variance components):
    #>              grp        var1     vcov  sdcor
    #>  environment:rep (Intercept) 0.001448 0.0381
    #>      environment (Intercept) 0.074141 0.2723
    #>         Residual        <NA> 0.034104 0.1847
    #>
    #> Number of obs: 36 | Groups: environment:rep, environment, Residual
    #>
    #> --- Goodness-of-fit ---
    #> R2       : 0.7957
    #> Adj-R2   : 0.7833
    #> RMSE     : 0.1664
    #> MAE      : 0.1332
    #> AIC      : 11.67
    #> BIC      : 21.17

### Post-hoc comparisons with emmeans

    library(emmeans)
    emm <- emmeans(m_met$fit, ~ genotype)
    contrast(emm, method = "pairwise", adjust = "tukey")
    #>  contrast estimate     SE df t.ratio p.value
    #>  G1 - G2    -0.355 0.0754 22  -4.712  0.0003
    #>  G1 - G3     0.167 0.0754 22   2.214  0.0909
    #>  G2 - G3     0.522 0.0754 22   6.926 <0.0001
    #>
    #> Degrees-of-freedom method: kenward-roger
    #> P value adjustment: tukey method for comparing a family of 3 estimates

## Fitting multiple growth curves by group

Use `lapply` to fit the logistic model separately to each treatment:

    maize <- load_example_data("maize_growth")

    fits_by_trt <- lapply(
      split(maize, maize$treatment),
      function(sub) {
        fit_nonlinear(sub, x_col = "days", y_col = "biomass_g",
                      model = "logistic")
      }
    )

    # Compare parameter estimates between treatments
    lapply(fits_by_trt, function(m) round(coef(m), 2))
    #> $DS
    #>   Asym   xmid   scal
    #> 346.61  39.60   9.04
    #>
    #> $WW
    #>   Asym   xmid   scal
    #> 497.86  47.07   8.55

### Overlay curves on a single plot

    library(ggplot2)

    # Build prediction ribbons for each treatment
    preds <- do.call(rbind, lapply(names(fits_by_trt), function(trt) {
      m     <- fits_by_trt[[trt]]
      xseq  <- seq(1, 100, length.out = 200)
      nd    <- data.frame(days = xseq)
      data.frame(
        days      = xseq,
        biomass_g = predict(m$fit, newdata = nd),
        treatment = trt
      )
    }))

    raw <- maize[, c("days", "biomass_g", "treatment")]

    ggplot(preds, aes(x = days, y = biomass_g, colour = treatment)) +
      geom_line(linewidth = 1.1) +
      geom_point(data = raw, alpha = 0.3, size = 1.2) +
      scale_color_manual(values = c(WW = "#1D9E75", DS = "#D85A30")) +
      labs(title = "Logistic growth by water treatment",
           x = "Days after emergence", y = "Biomass (g/plant)") +
      theme_minimal()

## Comparing dose-response curves across species

    herb <- load_example_data("herbicide_trial")

    # Fit one model per species
    drc_fits <- lapply(
      split(herb, herb$species),
      function(sub) {
        fit_dose_response(sub,
                          dose_col = "dose_g_ha",
                          resp_col = "weed_control_pct",
                          fct      = "LL.4")
      }
    )

    # ED50 per species
    lapply(names(drc_fits), function(sp) {
      cat(sp, ":\n"); ed_estimates(drc_fits[[sp]], respLev = 50)
    })
    #> Amaranth :
    #>
    #> Estimated effective doses
    #>
    #>        Estimate Std. Error   Lower   Upper
    #> e:1:50  82.2591     2.3345 77.5331 86.9851
    #>        Estimate Std. Error    Lower    Upper
    #> e:1:50 82.25913   2.334523 77.53314 86.98513
    #> Ryegrass :
    #>
    #> Estimated effective doses
    #>
    #>        Estimate Std. Error    Lower    Upper
    #> e:1:50 207.4966     6.0475 195.2541 219.7392
    #>        Estimate Std. Error    Lower    Upper
    #> e:1:50 207.4966   6.047501 195.2541 219.7392
    #> [[1]]
    #>        Estimate Std. Error    Lower    Upper
    #> e:1:50 82.25913   2.334523 77.53314 86.98513
    #>
    #> [[2]]
    #>        Estimate Std. Error    Lower    Upper
    #> e:1:50 207.4966   6.047501 195.2541 219.7392

## Exporting a comparison table to Word / Excel

    # Requires the openxlsx package
    library(openxlsx)

    m1 <- fit_linear(load_example_data("wheat_trial"), "yield ~ nitrogen")
    m2 <- fit_nonlinear(load_example_data("wheat_trial"), "nitrogen","yield","quadratic")
    cmp <- compare_models(linear = m1, quadratic = m2)

    write.xlsx(cmp, "model_comparison.xlsx", rowNames = FALSE)