Nonlinear logistic regression of leaf area per plant as a function of accumulated growing degree-days (GDD), with ANOVA on model parameters and derivative-based critical-point analysis.
Overview
A three-parameter logistic model is fitted separately to each combination of season × cultivar × block. The estimated parameters are then subjected to a two-factor ANOVA in randomised complete blocks (FAT2DBC) to test the effects of sowing date and cultivar.
The logistic function is:
\[
y = \frac{\beta_1}{1 + e^{\;\beta_3(\beta_2 - x)}}
\]
Parameter
Interpretation
β₁
Upper asymptote (maximum leaf area, cm² plant⁻¹)
β₂
Inflection point (GDD at maximum growth rate)
β₃
Steepness coefficient (growth rate)
Required packages
Show code
library(rio) # Data importlibrary(tidyverse) # Data wrangling and visualisationlibrary(metan) # mean_by(), doo(), corr_plot()library(broom) # tidy() – extract NLS coefficients as a data framelibrary(AgroR) # FAT2DBC() – two-factor ANOVA in RCBDlibrary(patchwork) # Combine ggplot objectslibrary(hydroGOF) # gof() – goodness-of-fit statistics (R², RMSE, …)library(gt) # Professional tables
Load data
Show code
# Import the pre-computed plot-mean dataset.# Each row = one season × cultivar × block × date mean (already averaged across plants).df_model <-import("../df_model_cresc_medias.xlsx")
Fit logistic models – leaf area
Show code
# Define the logistic formula for NLS fittingformula_af <- af_planta_mean ~ b1 / (1+exp((b3 * (b2 - gda))))# Initial parameter guesses (required by nls())# b1 = 100 → expected maximum leaf area (~100 cm² plant⁻¹)# b2 = 400 → inflection point around 400 GDD# b3 = 0.01 → moderate steepnessstart_af <-list(b1 =100, b2 =400, b3 =0.01)# Fit one model per season × cultivar × block combination.# The date "04/11" is excluded because it falls outside the vegetative growth# window (post-flowering measurement, distorts the sigmoid shape).# metan::doo() is a tidy wrapper around dplyr::group_map() for NLS.mod_af <- df_model |>filter(data !="04/11") |>group_by(epoca, cultivar, bloco) |>doo(~nls(formula_af, data = ., start = start_af))
Extract estimated parameters
Show code
# broom::tidy() converts each NLS object into a data frame with columns:# term, estimate, std.error, statistic, p.value# pivot_wider() reshapes so each parameter (b1, b2, b3) is its own column.parameters_af <- mod_af |>mutate(data =map(data, ~.x |>tidy())) |>unnest(data) |>select(epoca, cultivar, bloco, term, estimate) |>pivot_wider(names_from = term,values_from = estimate)
Goodness of fit
Show code
# Helper function: extract R² and AIC from a single NLS objectget_gof <-function(model) { aic <-AIC(model) bic <-BIC(model) fit <- model$m$fitted() # fitted values res <- model$m$resid() # residuals obs <- fit + res # observed = fitted + residual# Compute GOF metrics using hydroGOF::gof g <- hydroGOF::gof(sim = fit, obs = obs, digits =4)# Extract specific metrics r2 <- g["R2", ] rmse <- g["RMSE", ] mae <- g["MAE", ] d <- g["d", ]data.frame(aic = aic, bic = bic, r2 = r2, rmse = rmse, mae = mae, d = d)}# Apply the helper to every model in the nested data framegof_af <- mod_af |>mutate(map_dfr(.x = data, .f =~get_gof(.))) |>select(-data) |>mutate(variable ="leaf_area")# Display goodness of fit table with gtgof_af |>gt() |>tab_header(title ="Supplementary Table S1",subtitle ="Goodness-of-fit metrics for Leaf Area logistic models (per block)" ) |>fmt_number(columns =where(is.numeric), decimals =4)
Supplementary Table S1
Goodness-of-fit metrics for Leaf Area logistic models (per block)
epoca
bloco
cultivar
aic
bic
r2
rmse
mae
d
variable
E1
B1
D
92.1544
94.0940
0.9512
8.0647
5.8542
0.9875
leaf_area
E1
B2
D
96.2183
98.1579
0.8796
9.5527
7.6902
0.9681
leaf_area
E1
B3
D
91.6287
93.5684
0.9259
7.8900
6.3099
0.9810
leaf_area
E1
B1
M
98.0237
99.9633
0.9402
10.2990
8.1658
0.9849
leaf_area
E1
B2
M
99.4571
101.3968
0.9518
10.9329
8.6882
0.9883
leaf_area
E1
B3
M
100.9898
102.9294
0.8989
11.6538
9.1617
0.9732
leaf_area
E2
B1
D
58.6348
59.8451
0.9750
3.0429
2.7395
0.9937
leaf_area
E2
B2
D
70.9637
72.1740
0.9185
5.6364
4.5106
0.9790
leaf_area
E2
B3
D
65.4323
66.6426
0.9478
4.2745
3.2005
0.9864
leaf_area
E2
B1
M
79.8645
81.0749
0.8642
8.7959
7.1313
0.9625
leaf_area
E2
B2
M
78.0279
79.2382
0.9025
8.0242
6.2223
0.9732
leaf_area
E2
B3
M
74.8398
76.0502
0.9147
6.8418
5.1728
0.9780
leaf_area
Show code
saveRDS(gof_af, "../results/gof_af.rds")
Fitted logistic curves
Show code
# Logistic formula used inside geom_smooth (x/y notation for ggplot internals)formula_xy <- y ~ b1 / (1+exp((b3 * (b2 - x))))ggplot( df_model |>filter(!data %in%c("04/11")),aes(gda, af_planta_mean, color = cultivar)) +# Fitted NLS curves via geom_smooth (one per cultivar)geom_smooth(method ="nls",method.args =list(formula = formula_xy,start =c(b1 =60, b2 =400, b3 =0.01) ),se =FALSE,aes(color = cultivar) ) +# One panel per sowing seasonfacet_wrap(~epoca, ncol =1) +# Treatment means overlaid as pointsstat_summary(fun = mean,geom ="point",aes(color = cultivar),size =2.5,position =position_dodge(width =0.8) ) +scale_y_continuous(breaks =seq(0, 150, by =25)) +labs(x ="Accumulated Degree Days",y =expression(Leaf ~ area ~ (cm^2~ plant^{-1})),color ="Cultivar" ) +scale_color_manual(values =c("gold", "brown")) +# Add the excluded 04/11 point as a transparent referencestat_summary(data = df_model |>filter(data =="04/11"),aes(x = gda, y = af_planta_mean),fun = mean,alpha =0.4,shape =16,show.legend =FALSE ) +theme_bw(base_size =18) +theme(panel.grid.major =element_blank(),legend.background =element_rect(fill ="transparent"),legend.position ="bottom" )
Logistic growth curves fitted to leaf area per plant (cm² plant⁻¹) as a function of accumulated GDD, faceted by sowing season. Points = treatment means. The semi-transparent point at the far right (04/11) was excluded from fitting.
First (left) and second (right) derivatives of the fitted logistic curves for leaf area. Points mark the inflection, acceleration, and deceleration thresholds for each cultivar × season combination.