# Parameterising FINN from Ellenberg indicator values

FINN simulates forest succession as the interplay of regeneration, growth, mortality and competition as ecological processes. Here we give it four species with contrasting life-history strategies derived from Ellenberg values and let it simulate succession from bare ground to show that FINN provides the necessary structure to translate Ellenberg indicator values into a plausible succession trajectory. This vignette provides a rough sketch of how the first dynamic forest models were developed (cf. Botkin et al., 1972). No data, no fitting, just basic ecological knowledge.

    library(FINN)
    library(data.table)
    library(ggplot2)
    FINN.seed(1234)

    Ntimesteps <- 300
    Nsites     <- 1
    Nsp        <- 4          # pioneer -> early-mid -> mid-late -> climax
    patch_size <- 0.1

## From Ellenberg to FINN parameters

We use four European tree species along a life-history gradient, from the fast, light-demanding pioneer Betula pendula (silver birch) to the slow, shade-tolerant climax Fagus sylvatica (beech), with Tilia cordata (small-leaved lime) and Carpinus betulus (hornbeam) in between. Every parameter below specifies one end of a trade-off along this gradient.

A key species property in forest succession is shade tolerance (the shade parameter, `parGrowth[,1]` and `parReg`). In FINN this is the fraction of light a species needs to grow and regenerate. A high value therefore means light-demanding (a pioneer that needs open ground) and a low value means shade-tolerant (a climax species that establishes under a closed canopy). We derive it from each species’ Ellenberg light-indicator value L rescaled into a range that is compatible with FINN. Ellenberg L describes a species’ realised light niche, which we use here as a convenient proxy for shade tolerance (Ellenberg et al. 1991).

    # The Ellenberg light-indicator value L ranges from 1 = deep shade ... 9 = full light; we
    # rescale L into the shade parameter.
    species     <- c("Betula pendula", "Tilia cordata", "Carpinus betulus", "Fagus sylvatica")
    ellenberg_L <- c(7, 5, 4, 3)   # birch (pioneer) ... beech (climax)

    # shade = light fraction a species needs to grow / regenerate.
    #   high -> light-demanding (birch); low -> shade-tolerant (beech)
    shadeSP <- 0.08 + (0.60 - 0.08) *
      (ellenberg_L - min(ellenberg_L)) / (max(ellenberg_L) - min(ellenberg_L))
    # -> 0.60 birch, 0.34 lime, 0.21 hornbeam, 0.08 beech

    # Other parameters are derived from a basic understanding of demographic trade-offs.
    # pioneer <-> climax trade-off (fast, short-lived, prolific vs slow, long-lived,
    # sparse).

    # --- regeneration ----------------------------------------------------------
    # reg light threshold = shade tolerance; the intercept is relative seed rain,
    # higher for the pioneer (many seeds) than the climax (fewer, masting).
    parReg    <- shadeSP
    parRegEnv <- matrix(c(
      c(3.2, 2.6, 2.0, 1.2),   # intercept: relative seed rain, pioneer -> climax
      rep(0, Nsp)              # env slope = 0 (the environment is held constant here)
    ), Nsp, 2)

    # --- growth ----------------------------------------------------------------
    # col 1 = shade threshold (as above). col 2 = size-dependent growth decay: growth
    # carries a factor exp(-col2 * dbh), so relative growth fades as a tree gets big.
    # Higher for the pioneer (fast when young, then fades) than the climax (sustains).
    parGrowth <- matrix(c(
      shadeSP,
      c(0.040, 0.038, 0.034, 0.030)
    ), Nsp, 2)
    # growth-rate intercept: the pioneer grows ~4x faster than the climax, so it
    # seizes the early canopy; the climax builds slowly.
    parGrowthEnv <- matrix(c(
      c(1.00, 0.65, 0.30, -0.20),
      rep(0, Nsp)
    ), Nsp, 2)

    # --- mortality -------------------------------------------------------------
    # Mortality is mort = plogis(intercept + col1*light + col2*dbh/100 + col3*growth),
    # so it changes through the run as light, size and growth change:
    #   col1 (light): negative -> mortality RISES as a cohort is overtopped (low
    #                 light). Steep for the pioneer (shade-intolerant), ~flat for
    #                 the climax (shade-tolerant).
    #   col2 (dbh): positive -> senescence: mortality rises as a tree approaches its
    #               maximum size. Steep for the short-lived pioneer (senesces small
    #               and young), gentle for the long-lived climax.
    #   col3 (growth): negative -> suppressed, slow-growing trees die more (vigour).
    parMort <- matrix(c(
      c(-1.0, -0.8, -0.5, -0.3),   # col1 light: pioneer strongly shade-intolerant
      c(0.60, 0.35, 0.30, 0.15),   # col2 dbh: senescence, steep pioneer -> gentle climax
      c(-0.3, -0.3, -0.3, -0.3)    # col3 growth: vigour lowers mortality
    ), Nsp, 3)
    # baseline mortality ~ inverse longevity: high for the short-lived pioneer,
    # low for the long-lived climax.
    parMortEnv <- matrix(c(
      c(-0.69, -1.40, -2.30, -3.30),  # intercept: per-species baseline
      rep(0, Nsp)                     # env slope = 0
    ), Nsp, 2)

    # --- competition -----------------------------------------------------------
    # col 1 = height allometry (maximum stature): beech is tallest and overtops late,
    # hornbeam is a sub-canopy tree and stays short. col 2 = competition strength (how
    # strongly a cohort shades its neighbours); illustrative, mild pioneer -> climax gradient.
    parComp <- matrix(c(
      c(0.55, 0.60, 0.48, 0.68),   # birch, lime, hornbeam (sub-canopy), beech
      c(0.30, 0.25, 0.20, 0.15)
    ), Nsp, 2)

We can gain an overview of the parameters in a table:

    pars_dt <- data.table(
      species       = species,
      ellenberg_L   = ellenberg_L,
      shade         = round(shadeSP, 2),
      growth_rate   = round(exp(parGrowthEnv[, 1]), 2),   # relative growth speed
      mort_shaded   = round(plogis(parMortEnv[, 1]), 3),  # mortality when overtopped (light~0)
      shade_intol   = -parMort[, 1],                      # how much mortality rises in shade
      seed_rain     = round(exp(parRegEnv[, 1]), 1),      # relative fecundity
      height_allom  = parComp[, 1]
    )
    pars_dt
    #>             species ellenberg_L shade growth_rate mort_shaded shade_intol
    #>              <char>       <num> <num>       <num>       <num>       <num>
    #> 1:   Betula pendula           7  0.60        2.72       0.334         1.0
    #> 2:    Tilia cordata           5  0.34        1.92       0.198         0.8
    #> 3: Carpinus betulus           4  0.21        1.35       0.091         0.5
    #> 4:  Fagus sylvatica           3  0.08        0.82       0.036         0.3
    #>    seed_rain height_allom
    #>        <num>        <num>
    #> 1:      24.5         0.55
    #> 2:      13.5         0.60
    #> 3:       7.4         0.48
    #> 4:       3.3         0.68

Reading the rows: the pioneer needs the most light (`shade` 0.60), grows fastest (`growth_rate` ≈ 2.7), is the most shade-intolerant (`shade_intol` highest, so its mortality climbs steeply once overtopped, to `mort_shaded` ≈ 0.33) and regenerates most (`seed_rain`); the climax is shade-tolerant, slow, almost immortal even in deep shade (`mort_shaded` ≈ 0.035), sparse but steady, and the tallest, so it ultimately overtops.

The `shade` parameter is not guessed, it is a straight rescaling of the Ellenberg light value L. A light-demanding species (high L) needs a high fraction of full light, a shade-tolerant species (low L) tolerates deep shade. Other parameters are based on basic ecological understanding (see comments in the code).

Ellenberg light value L mapped to the FINN shade parameter. The line is the rescaling; the points are our four species.

## Simulate succession

In this example we simulate under constant environment (`climate = 0`), so succession here comes purely from the demographic trade-offs. We run from bare ground with no disturbance. We will explore the implications of adding an environmental gradient in the next section.

    env_dt <- data.table(expand.grid(siteID = 1:Nsites, year = 1:Ntimesteps))
    env_dt$climate <- 0

    m <- finn(
      N_species            = Nsp,
      competition_process  = createProcess(~0,      FINN::competition),
      growth_process       = createProcess(~1+climate, FINN::growth,       initSpecies = parGrowth, initEnv = parGrowthEnv),
      regeneration_process = createProcess(~1+climate, FINN::regeneration, initSpecies = parReg,    initEnv = parRegEnv),
      mortality_process    = createProcess(~1+climate, FINN::mortality,    initSpecies = parMort,   initEnv = parMortEnv)
    )

    sim <- predict(m, init_cohort = NULL, env = env_dt, disturbance = NULL,
                   patches = 100, device = "cpu")

    # average over patches; convert per-patch ba/trees to per-hectare
    p_dat <- sim$long$site[, .(value = mean(value)), by = .(year, species, variable)]
    p_dat[variable %in% c("ba", "trees"), value := value / patch_size]

    sp_labs <- species   # Betula, Tilia, Carpinus, Fagus (pioneer -> climax)

## The successional pattern

    ba <- p_dat[variable == "ba"]
    ggplot(ba[year <= 150], aes(year, value, colour = factor(species))) +
      geom_line(linewidth = 0.9) +
      scale_colour_brewer(palette = "Dark2", name = "Species", labels = sp_labs) +
      labs(x = "Year", y = expression(Basal~area~(m^2/ha))) +
      theme_minimal()

Basal area by species over time. The pioneer peaks within the first decade and collapses; mid-seral species crest in turn; the shade-tolerant climax rises slowly and dominates the mature stand.

The same run, across every state variable FINN tracks (diameter, basal area, tree numbers, growth, mortality and regeneration):

    panel <- copy(p_dat)
    # regeneration is shown as the mean per hectare (r_mean_ha); the per-patch
    # count (reg) is dropped.
    panel[, variable2 := factor(
      variable,
      levels = c("dbh", "ba", "trees", "AL", "growth", "mort", "r_mean_ha"),
      labels = c("avg. DBH [cm]", "Basal Area [m2/ha]", "Trees [N/ha]",
                 "Available Light [%]", "Growth [cm/yr]", "Mortality [%]",
                 "Regeneration [N/ha]"))]
    ggplot(panel[!is.na(variable2) & year <= 150], aes(year, value, colour = factor(species))) +
      geom_line(linewidth = 0.6) +
      facet_wrap(~variable2, scales = "free_y", ncol = 2, strip.position = "left") +
      coord_cartesian(ylim = c(0, NA)) +
      scale_colour_brewer(palette = "Dark2", name = "Species", labels = sp_labs) +
      labs(x = "Year", y = NULL) +
      theme_minimal() +
      theme(strip.placement = "outside",
            strip.text.y.left = element_text(angle = 90),
            axis.title.y = element_blank())

Succession across all FINN state variables for the four species.

### Ecological plausibility of the successional pattern

The figures showed classic expected succession patterns that are simulated by many dynamic forest models, and every feature maps back to a parameter choice we made earlier:

- The pioneer (birch) erupts and collapses. On open ground light is abundant, so its high light requirement is met; its fast growth and high regeneration let it dominate the canopy within the first decade. But as the canopy closes, light drops below its high `shade` threshold and it can no longer regenerate because it is shade-intolerant. Its mortality increases rapidly once overtopped (visible in the mortality panel: from ~0.08 yr⁻¹ in full light to ~0.3 yr⁻¹ in shade), clearing the standing trees. By mid-succession it is nearly gone.
- Each mid-successional species has its turn. Lime and hornbeam have progressively lower light requirements and lower mortality, so each peaks a little later and stays a little longer. Hornbeam holds a stable subdominant presence through the middle of the run.
- The climax (beech) dominates the stand. It grows slowly and regenerates sparsely, so early on it is barely visible. But it regenerates under shade (lowest `shade`), is shade-tolerant so it barely dies even when overtopped (mortality stays ~0.02–0.03 yr⁻¹ throughout), and is the tallest (highest `height_allom`). So once the early- and mid-successional canopy disappears it overtops and dominates the mature forest.

This is an emergent pattern resulting from the parameters that encode demographic trade-offs for four species and FINN providing the structure to create the successional turnover.

## Adding a climate gradient

So far we ignored the environment. We now add a climate gradient to let FINN differentiate between species environmental niche. The gradient `climate` ranges from a cold and wet site at `-1`, through a benign site, to a warm and dry site at `+1`, so temperature and drought increase together along one axis. We keep the birch pioneer and add six species that each prefer a different position on the axis, the boreal conifers Picea abies and Pinus sylvestris, the temperate broadleaves Tilia cordata, Carpinus betulus and Fagus sylvatica, and the warm-dry Quercus robur.

Each species carries an Ellenberg temperature value (`ellenberg_T` in the code), which we rescale exactly as we rescaled L for shade into a climate optimum (`t_opt`), its preferred position on the cold-wet to warm-dry axis. Growth peaks at `t_opt` and mortality is lowest there and rises as a site moves away from it, so a species grows and survives well only where the climate suits it. The constants `k` and `km` set how quickly growth and survival fall off away from the optimum. Other dynamic forest models often implement such environmental niches with lower and upper thresholds for environmental variables like Degree Day Sum, Minimum Winter Temperature, or water availability (e.g., ForClim).

In addition two terms act on every species alike at the edges of the gradient. `growth_stress` lowers growth toward the cold-wet and warm-dry extremes, so the benign middle grows best, and `mort_stress` raises mortality there, the cold-wet end cold-stressed and the warm-dry end drought-stressed. Because standing basal area is set mostly by mortality, `mort_stress` is what makes it peak in the benign middle and fall off toward both extremes, the realistic pattern of a mesic optimum with cold-limited and drought-limited edges.

Admittedly, this is an extremely simplified representation of multidimensional environments, but it serves the purpose of showcasing the interplay of environmental stress and competition during succession.

    #                birch   pine   spruce   lime   hornbeam  beech   oak
    species_T   <- c("Betula pendula", "Pinus sylvestris", "Picea abies", "Tilia cordata",
                     "Carpinus betulus", "Fagus sylvatica", "Quercus robur")
    ellenberg_T <- c(5, 4, 3, 5, 6, 5, 6)  # cold 1 ... warm 9 (birch indifferent)
    Nsp_T       <- length(species_T)

    # Only the climate optimum is derived from an indicator value: rescale Ellenberg T
    # to a position on the axis [-1, 1]. Everything else below is illustrative.
    t_opt <- 2 * (ellenberg_T - min(ellenberg_T)) / (max(ellenberg_T) - min(ellenberg_T)) - 1

    # Per-species life-history speed and baseline mortality: birch grows fastest and
    # is the shortest-lived (pioneer); the rest are slower and long-lived. Oak is the
    # fastest climax so it can seize the warm-dry canopy before hornbeam fills in.
    base_g <- c(1.00, 0.70, 0.25, 0.28, 0.25, 0.36, 0.55)   # growth speed at the optimum
    m_opt  <- c(-0.7, -2.5, -2.5, -2.5, -2.5, -2.5, -2.5)   # baseline mortality at the optimum

    # Shared climate effects (illustrative magnitudes, not fitted). Two describe each
    # species' own climate niche: `k` is how fast its growth falls off away from its
    # optimum, `km` how fast its mortality rises away from it. Two more act at the
    # EDGES of the gradient (the cold-wet and warm-dry extremes) on every species
    # alike: `growth_stress` lowers growth there and `mort_stress`
    # raises mortality there. Because basal area is set mostly by mortality,
    # `mort_stress` is what makes the stand densest in the benign middle and
    # sparser toward both extremes.
    k  <- 1.0; growth_stress <- 0.40
    km <- 0.9; mort_stress   <- 0.90

    # Growth response = a species niche peaking at t_opt (curvature -k) minus the
    # shared growth stress, combined into the climate^2 column -(k + growth_stress).
    # Mortality response = a species mismatch penalty (curvature km, lowest at t_opt)
    # plus the shared mortality stress, combined into the climate^2 column
    # km + mort_stress. Both are written as intercept + b1*climate + b2*climate^2.
    parGrowthEnv_T <- cbind(base_g - k * t_opt^2,  2 * k * t_opt,  rep(-(k + growth_stress), Nsp_T))
    parMortEnv_T   <- cbind(m_opt + km * t_opt^2, -2 * km * t_opt,  rep(km + mort_stress, Nsp_T))

    # Birch is the exception. It is a fast, short-lived, light-demanding generalist
    # that pioneers every site, so it has no climate optimum of its own: it feels only
    # the shared productivity hump and end-harshness (climate^2), not a species niche.
    birch <- match("Betula pendula", species_T)
    parGrowthEnv_T[birch, ] <- c(base_g[birch], 0, -growth_stress)
    parMortEnv_T[birch, ]   <- c(m_opt[birch], 0, mort_stress)

    # shade tolerance and the other traits from autecology (birch light-demanding,
    # spruce and beech tolerant, oak and pine more light-demanding, hornbeam sub-canopy)
    shade_T     <- c(0.60, 0.45, 0.13, 0.24, 0.15, 0.10, 0.30)
    parGrowth_T <- cbind(shade_T, c(0.040, 0.038, 0.030, 0.034, 0.032, 0.030, 0.036))
    parReg_T    <- shade_T
    parRegEnv_T <- matrix(c(3.4, 3.0, 2.4, 2.4, 2.2, 2.2, 2.6), Nsp_T, 1)
    # mortality columns: col1 light (shade-intolerance), col2 dbh (senescence: steep
    # for the short-lived birch and hornbeam, gentle for the long-lived conifers, beech
    # and oak), col3 growth (vigour). See the main example for the mortality equation.
    parMort_T   <- cbind(c(-1.0, -0.9, -0.3, -0.5, -0.35, -0.3, -0.7),
                         c(0.60, 0.25, 0.20, 0.20, 0.35, 0.15, 0.15),
                         rep(-0.3, Nsp_T))
    parComp_T   <- cbind(c(0.55, 0.64, 0.66, 0.58, 0.45, 0.66, 0.66), c(0.30, 0.28, 0.16, 0.22, 0.20, 0.15, 0.20))

    m_T <- finn(
      N_species            = Nsp_T,
      competition_process  = createProcess(~0,           FINN::competition, initSpecies = parComp_T),
      growth_process       = createProcess(~1+climate+climate_sq, FINN::growth,       initSpecies = parGrowth_T, initEnv = parGrowthEnv_T),
      regeneration_process = createProcess(~1,           FINN::regeneration, initSpecies = parReg_T,    initEnv = parRegEnv_T),
      mortality_process    = createProcess(~1+climate+climate_sq, FINN::mortality,    initSpecies = parMort_T,   initEnv = parMortEnv_T)
    )

First, each species’ Ellenberg T is rescaled to an optimum position on the axis; that optimum then becomes a bell-shaped climate niche. Birch has no optimum and stays flat, the generalist that pioneers every site.

Left: Ellenberg temperature value T mapped to each species’ climate optimum. Right: the resulting climate niche along the cold-wet to warm-dry axis, a bell peaking at the optimum (birch stays flat). On top of these niches the model adds a site-productivity hump that makes the benign middle most productive.

We use the `predict` function to simulate from `m_T` for three artificial sites along the climate gradient and compare the simulated succession trajectories.

    site_temp <- c(`cold-wet` = -1, benign = 0.33, `warm-dry` = 1)
    runs <- rbindlist(lapply(names(site_temp), function(nm) {
      tt <- site_temp[[nm]]
      env_dt <- data.table(expand.grid(siteID = 1:Nsites, year = 1:Ntimesteps))
      env_dt$climate <- tt
      env_dt$climate_sq <- tt^2
      sim <- predict(m_T, init_cohort = NULL, env = env_dt, disturbance = NULL,
                     patches = 100, device = "cpu")
      ba <- sim$long$site[variable == "ba", .(value = mean(value) / patch_size),
                          by = .(year, species)]
      ba[, climate := factor(nm, levels = names(site_temp))]
      ba
    }))

    ggplot(runs, aes(year, value, colour = factor(species))) +
      geom_line(linewidth = 0.7) +
      facet_wrap(~climate, ncol = 3) +
      scale_colour_brewer(palette = "Dark2", name = "Species", labels = species_T) +
      labs(x = "Year", y = expression(Basal~area~(m^2/ha))) +
      guides(colour = guide_legend(nrow = 2)) +
      theme_minimal() +
      theme(legend.position = "bottom")

The same species pool along a cold-wet to warm-dry climate gradient. A spruce and pine stand forms in the cold and wet, a beech-dominated mixed forest in the benign middle, and an oak and hornbeam wood in the warm and dry, each opened by the birch pioneer.

One pioneer and six climate specialists, and FINN sorts them into three idealized ecologically plausible succession trajectories, a spruce and pine stand in the cold and wet, a beech-dominated mixed forest in the benign middle, and an oak and hornbeam wood in the warm and dry, each still opened by the birch pioneer.

## References

Botkin, D.B., Janak, J.F. and Wallis, J.R. (1972) Some ecological consequences of a computer model of forest growth. Journal of Ecology 60(3), 849–872. https://doi.org/10.2307/2258570

Ellenberg, H., Weber, H.E., Düll, R., Wirth, V., Werner, W. and Paulißen, D. (1991) Zeigerwerte von Pflanzen in Mitteleuropa. Scripta Geobotanica 18, 1–248.

    sessionInfo()
    #> R version 4.5.0 (2025-04-11)
    #> Platform: aarch64-apple-darwin20
    #> Running under: macOS 26.5.1
    #>
    #> Matrix products: default
    #> BLAS:   /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib
    #> LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
    #>
    #> locale:
    #> [1] C
    #>
    #> time zone: Europe/Berlin
    #> tzcode source: internal
    #>
    #> attached base packages:
    #> [1] stats     graphics  grDevices utils     datasets  methods   base
    #>
    #> other attached packages:
    #> [1] patchwork_1.3.0   ggplot2_3.5.2     data.table_1.17.8 FINN_0.1.0
    #>
    #> loaded via a namespace (and not attached):
    #>  [1] vctrs_0.6.5        cli_3.6.6          knitr_1.50         rlang_1.2.0
    #>  [5] xfun_0.57          processx_3.8.6     generics_0.1.4     torch_0.15.1
    #>  [9] coro_1.1.0         labeling_0.4.3     glue_1.8.0         bit_4.6.0
    #> [13] ps_1.9.1           scales_1.4.0       grid_4.5.0         abind_1.4-8
    #> [17] evaluate_1.0.5     tibble_3.3.0       lifecycle_1.0.5    compiler_4.5.0
    #> [21] dplyr_1.1.4        RColorBrewer_1.1-3 Rcpp_1.1.0         pkgconfig_2.0.3
    #> [25] farver_2.1.2       R6_2.6.1           tidyselect_1.2.1   pillar_1.11.0
    #> [29] callr_3.7.6        magrittr_2.0.3     withr_3.0.2        tools_4.5.0
    #> [33] bit64_4.6.0-1      gtable_0.3.6