Your first individual

guide

This guide begins with the smallest unit in plant: one individual. You will create a plant, inspect its current state, calculate how quickly that state is changing, and grow it through time. You will then compare plants with different traits under different light conditions.

We use the FF16 model throughout. Its strategy stores the biological parameters for a type of plant; an individual is one plant governed by that strategy. Patch simulations later group individuals of similar age into nodes and allow them to compete, but those extra layers are not needed yet.

library(plant)

Create an individual

FF16_Individual() creates an individual with the default FF16 strategy. You can also pass it an FF16 strategy with different parameter values. Other model families have matching constructors, such as TF24_Individual().

indv <- FF16_Individual()

Printing the object shows the methods and values available on it:

indv
<Individual<FF16,FF16_Env>>
  Inherits from: <Individual>
  Public:
    .ptr: externalptr
    aux: function (name) 
    aux_names: active binding
    aux_size: active binding
    clone: function (deep = FALSE) 
    compute_competition: function (h) 
    compute_rates: function (environment) 
    establishment_probability: function (environment) 
    initialize: function (ptr) 
    internals: active binding
    mortality_probability: active binding
    net_mass_production_dt: function (environment) 
    ode_names: active binding
    ode_rates: active binding
    ode_size: active binding
    ode_state: active binding
    rate: function (name) 
    reset_mortality: function () 
    resource_compensation_point: function () 
    set_initial_states: function (environment) 
    set_state: function (name, v) 
    state: function (name) 
    strategy: active binding
    strategy_name: active binding

The most useful starting point is state(), which reads the current value of a state variable. set_state() changes a value directly:

indv$state("height")
[1] 0.3441948
indv$set_state("height", 10)
indv$state("height")
[1] 10
indv$state("fecundity")
[1] 0
indv$state("mortality")
[1] 0

Height is the individual’s current size. Fecundity and mortality are cumulative states: they record reproductive output and mortality hazard along the trajectory. The numerical solver advances these variables together:

indv$ode_state
[1] 10  0  0  0  0

Reading a state tells us where the plant is now. To calculate what happens next, the model also needs an environment. FF16 describes light using canopy openness, from 0 for no available light to 1 for full light. The helper below creates a fixed environment with the same openness at every height. The individual does not alter this demonstration environment.

fixed_environment <- function(e = 1.0, height_max = 150.0) {
  env <- Environment("FF16")
  env$set_fixed_environment(e, height_max)
  env
}

env <- fixed_environment(1.0)

compute_rates() evaluates the plant’s carbon budget in that environment and updates the instantaneous rates of change. The first printout below contains the initial placeholders; the second contains rates calculated in full light:

indv$ode_rates
[1] NA NA NA NA NA
indv$compute_rates(env)
indv$ode_rates
[1] 9.231625e-01 1.002618e-02 6.905594e-05 3.132290e-04 1.687620e+00

FF16 advances five state variables in total. Their names and current rates are available directly:

indv$ode_names
[1] "height"         "mortality"      "fecundity"      "area_heartwood"
[5] "mass_heartwood"

To retrieve one rate by name, use rate():

indv$rate("height")
[1] 0.9231625

Individuals are R6 reference objects. Assigning an individual to a new name does not make an independent copy: both names still point to the same underlying object.

indv2 <- indv
indv2$set_state("height", 1)
indv2$state("height")
[1] 1
indv$state("height") # also 1!
[1] 1

This behaviour matters when saving or comparing individuals. If you need two independent plants, construct two objects rather than assigning one to another name.

Grow an individual

Setting height directly changes only one state. Normally we want to integrate all states together so that height, fecundity, mortality, and tissue turnover describe the same history. grow_individual_to_time() provides the simplest route. Here we request 101 points over 50 years:

tt <- seq(0, 50, length.out = 101)
res <- grow_individual_to_time(FF16_Individual(), tt, env)

The returned state matrix has one row per requested time and one column per state variable. Plotting height gives the growth trajectory:

plot(height ~ tt, res$state, type = "l", las = 1,
     xlab = "Time (years)", ylab = "Height (m)")
Figure 1: Height through time using plant’s individual-growth helper.

Under the hood: integrating the equations directly

Most users can skip this subsection. It demonstrates that the individual’s states and rates can also be passed to a general-purpose ODE solver such as deSolve:

# deSolve wrapper of Individual ODEs
derivs <- function(t, y, individual, env) {
  individual$ode_state <- y
  individual$compute_rates(env)
  list(individual$ode_rates)
}

# initialise the individual and its starting state
indv <- FF16_Individual()
y0 <- setNames(indv$ode_state, indv$ode_names)

# solve and plot
yy <- deSolve::lsoda(y0, tt, derivs, indv, env=env)
plot(height ~ time, yy, type="l")
Figure 2: Height through time, integrating the individual’s ODEs directly with deSolve.

The grey points from deSolve lie on the trajectory returned by plant’s helper, confirming that both approaches integrate the same equations:

plot(height ~ tt, res$state, type="l", las=1,
     xlab="Time (years)", ylab="Height (m)")

points(height ~ time, yy, col="grey", cex=.5)
Figure 3: Internal ODE solver (line) against the deSolve result (grey points).

Grow to a target size

Sometimes the question is not “How large is the plant after 20 years?” but “When does it reach 15 metres?” grow_individual_to_size() integrates until a sequence of target sizes is reached. grow_individual_to_height() is a convenient height-specific wrapper.

# create a sequence from the initial size to height at maturity
indv <- FF16_Individual()
heights <- seq(indv$state("height"), indv$strategy$pars$hmat, length.out=20)
res <- grow_individual_to_size(indv, heights, "height", env)

The result contains the time at which each height was reached,

res$time
 [1]  0.000000  1.545681  2.523393  3.365033  4.155087  4.926508  5.696593
 [8]  6.476387  7.274127  8.096757  8.950785  9.842785 10.779771 11.769706
[15] 12.821689 13.946774 15.158668 16.476692 17.957500 20.137320

the complete state at each of those times,

head(res$state)
        height  mortality    fecundity area_heartwood mass_heartwood
[1,] 0.3441948 0.00000000 0.000000e+00   0.000000e+00   0.000000e+00
[2,] 1.1995465 0.01545682 7.754892e-20   1.280271e-07   6.544158e-05
[3,] 2.0548958 0.02523396 5.654090e-18   9.826615e-07   8.617440e-04
[4,] 2.9102485 0.03365038 2.253268e-16   3.837305e-06   4.790943e-03
[5,] 3.7656006 0.04155098 6.750303e-15   1.081677e-05   1.755419e-02
[6,] 4.6209512 0.04926531 1.710477e-13   2.510437e-05   5.019027e-02

and an individual object for each target. Here the tenth object has the tenth requested height:

res$individual[[10]]
<Individual<FF16,FF16_Env>>
  Inherits from: <Individual>
  Public:
    .ptr: externalptr
    aux: function (name) 
    aux_names: active binding
    aux_size: active binding
    clone: function (deep = FALSE) 
    compute_competition: function (h) 
    compute_rates: function (environment) 
    establishment_probability: function (environment) 
    initialize: function (ptr) 
    internals: active binding
    mortality_probability: active binding
    net_mass_production_dt: function (environment) 
    ode_names: active binding
    ode_rates: active binding
    ode_size: active binding
    ode_state: active binding
    rate: function (name) 
    reset_mortality: function () 
    resource_compensation_point: function () 
    set_initial_states: function (environment) 
    set_state: function (name, v) 
    state: function (name) 
    strategy: active binding
    strategy_name: active binding
res$individual[[10]]$state("height")
[1] 8.042356
heights[[10]]
[1] 8.042356

Compare traits and environments

We can now ask a biological question: how do leaf traits change a plant’s growth trajectory? We compare two FF16 strategies that differ in leaf mass per area (LMA). Low LMA describes thinner, cheaper leaves; high LMA describes thicker, more expensive leaves.

Changing LMA through a trait matrix also updates linked parameters such as leaf turnover and respiration. This is called hyperparameterisation. The Strategies and traits guide explains the machinery in more detail; here we focus on the resulting plants.

Begin with the default FF16 parameters:

params <- scm_base_parameters("FF16")

Generate one low-LMA and one high-LMA strategy. param_hyperpar() selects the appropriate linking rules for the model stored in params:

s <- generate_strategy(params, trait_matrix(c(0.0825, 0.2625), "lma"),
                       birth_rate = 1)
s1 <- s[[1]]
s2 <- s[[2]]

The resulting strategies show how LMA is linked to leaf turnover (k_l) and dark respiration per unit leaf mass (r_l):

lapply(s, function(x) x$pars[c("lma", "k_l", "r_l")])
[[1]]
[[1]]$lma
[1] 0.0825

[[1]]$k_l
[1] 2.03812

[[1]]$r_l
[1] 476


[[2]]
[[2]]$lma
[1] 0.2625

[[2]]$k_l
[1] 0.2816144

[[2]]$r_l
[1] 149.6

Create one individual from each strategy:

indv1 <- FF16_Individual(s1)
indv2 <- FF16_Individual(s2)

We grow each individual through a sequence of heights. Their starting heights differ because the same seed mass produces more leaf area, and therefore a taller seedling, when LMA is lower.

heights1 <- seq(indv1$state("height"), s1$pars$hmat, length.out=100L)
heights2 <- seq(indv2$state("height"), s2$pars$hmat, length.out=100L)

dat1 <- grow_individual_to_height(indv1, heights1, env,
                             time_max=100, warn=FALSE, filter=TRUE)
dat2 <- grow_individual_to_height(indv2, heights2, env,
                             time_max=100, warn=FALSE, filter=TRUE)

plot(dat1$trajectory[, "time"], dat1$trajectory[, "height"],
     type="l", lty=1,
     las=1, xlab="Time (years)", ylab="Height (m)")
lines(dat2$trajectory[, "time"], dat2$trajectory[, "height"], lty=2)
legend("bottomright", c("Low LMA", "High LMA"), lty=1:2, bty="n")
Figure 4: Height trajectories of a low-LMA and a high-LMA species in full light.

Next compare the same plants at 50% canopy openness. The line type identifies the LMA strategy, while colour identifies the light environment:

env_low <- fixed_environment(0.5)
dat1_low <- grow_individual_to_height(indv1, heights1, env_low,
                                 time_max=100, warn=FALSE, filter=TRUE)
dat2_low <- grow_individual_to_height(indv2, heights2, env_low,
                                 time_max=100, warn=FALSE, filter=TRUE)

cols <- c("black", "#e34a33")
plot(dat1$trajectory[, "time"], dat1$trajectory[, "height"],
     type="l", lty=1,
     las=1, xlab="Time (years)", ylab="Height (m)")
lines(dat2$trajectory[, "time"], dat2$trajectory[, "height"], lty=2)
lines(dat1_low$trajectory[, "time"], dat1_low$trajectory[, "height"],
      lty=1, col=cols[[2]])
lines(dat2_low$trajectory[, "time"], dat2_low$trajectory[, "height"],
      lty=2, col=cols[[2]])
legend("bottomright",
       c("High light", "Low light"), lty=1, col=cols,
       bty="n")
Figure 5: Height trajectories for both species in high (black) and low (red) light.

Inspect growth rates

Height growth rate is the slope of a height-through-time trajectory. The solver integrates this rate to obtain height, so inspecting it helps explain why the four trajectories diverge.

Select an individual from part-way through the low-LMA, high-light run:

indv <- dat1$individual[[50]]

Its ODE state records where the individual is,

setNames(indv$ode_state, indv$ode_names)
        height      mortality      fecundity area_heartwood mass_heartwood 
  8.412120e+00   7.797210e-02   9.998969e-08   3.403727e-04   1.262008e+00 

while the ODE rates record how those states are changing:

setNames(indv$ode_rates, indv$ode_names)
        height      mortality      fecundity area_heartwood mass_heartwood 
  9.530474e-01   1.003019e-02   3.243850e-07   1.780143e-04   8.068135e-01 

An individual saved from a trajectory may retain rates from the solver’s last internal evaluation. Recompute them in the environment before interpreting them:

indv$compute_rates(dat1$env)
setNames(indv$ode_rates, indv$ode_names)
        height      mortality      fecundity area_heartwood mass_heartwood 
  9.530474e-01   1.003019e-02   3.243850e-07   1.780143e-04   8.068135e-01 

They happen to be unchanged here because the demonstration environment is constant. That need not be true with a varying environment.

The named accessor and the first entry of ode_rates return the same height growth rate:

indv$rate("height")
[1] 0.9530474
indv$ode_rates[[1]]
[1] 0.9530474

Collecting that rate across all four trajectories shows how growth changes with time, trait value, and light:

f <- function(x) x$rate("height")
dhdt1 <- sapply(dat1$individual, f)
dhdt2 <- sapply(dat2$individual, f)
dhdt1_low <- sapply(dat1_low$individual, f)
dhdt2_low <- sapply(dat2_low$individual, f)

plot(dat1$time, dhdt1, type="l", lty=1,
     las=1, xlab="Time (years)", ylab="Height growth rate (m / yr)")
lines(dat2$time, dhdt2, lty=2)
lines(dat1_low$time, dhdt1_low, lty=1, col=cols[[2]])
lines(dat2_low$time, dhdt2_low, lty=2, col=cols[[2]])
legend("topright",
       c("High light", "Low light"), lty=1, col=cols,
       bty="n")
Figure 6: Height growth rate through time across traits and light environments.

Plotting the same rates against height separates the effect of plant size from the passage of time:

ylim <- c(0, max(dhdt1))
plot(dat1$state[, "height"], dhdt1, type="l", lty=1,
     las=1, xlab="Height (m)", ylab="Height growth rate (m / yr)", ylim=ylim)
lines(dat2$state[, "height"], dhdt2, lty=2)
lines(dat1_low$state[, "height"], dhdt1_low, lty=1, col=cols[[2]])
lines(dat2_low$state[, "height"], dhdt2_low, lty=2, col=cols[[2]])
legend("topright",
       c("High light", "Low light"), lty=1, col=cols,
       bty="n")
Figure 7: Height growth rate against height across traits and light environments.

Find the light compensation point

For a plant of a fixed size, lower canopy openness reduces height growth:

indv <- FF16_Individual()
indv$set_state("height", 10)
indv$compute_rates(fixed_environment(1.0))
indv$rate("height") # in full light
[1] 0.9231625
indv$compute_rates(fixed_environment(0.5))
indv$rate("height") # in 1/2 light
[1] 0.4522332

At sufficiently low light, the individual cannot maintain a positive carbon balance. For this 10 m plant, height growth has stopped by 25% canopy openness:

indv$compute_rates(fixed_environment(0.25))
indv$rate("height")
[1] 0

The canopy openness at which whole-plant carbon gain reaches zero is the whole-plant light compensation point.

openness <- seq(0, 1, length.out=51)
lcp <- indv$resource_compensation_point()

Above this point, height growth increases with canopy openness and gradually approaches a plateau:

f <- function(x, indv) {
  env <- fixed_environment(x)
  indv$compute_rates(env)
  indv$rate("height")
}
x <- c(lcp, openness[openness > lcp])
plot(x, sapply(x, f, indv), type="l", xlim=c(0, 1),
     las=1, xlab="Canopy openness", ylab="Height growth rate (m / yr)")
points(lcp, 0.0, pch=19)
Figure 8: Height growth rate against canopy openness; the filled point marks the light compensation point.

The compensation point is not a single constant for a strategy: it also changes with plant size. The next plot compares a seedling with a larger individual set to half its maximum height (hmat / 2). The helper g() evaluates both plants across the same openness sequence:

g <- function(openness, indv) cbind(openness, sapply(openness, f, indv))

indv_seed <- FF16_Individual(s1)
y_seed <- g(openness, indv_seed)

indv_adult <- FF16_Individual(s1)
indv_adult$set_state("height", indv_adult$strategy$pars$hmat / 2)
y_adult <- g(openness, indv_adult)

cols_height <- c("#31a354", "black")
ymax <- max(y_seed[, 2], y_adult[, 2])
plot(y_seed, type="l", col=cols_height[[1]],
     xlim=c(0, 1), ylim=c(0, ymax), las=1,
     xlab="Canopy openness", ylab="Height growth rate (m / yr)")
lines(y_adult, col=cols_height[[2]])
legend("bottomright", c("Seedling", "Larger individual"), lty=1, col=cols_height, bty="n")
Figure 9: Height growth rate against canopy openness for a seedling and a larger individual.

The compensation point and curve also vary with traits. Here we overlay the high-LMA species s2 (dotted) on the low-LMA species s1 (solid):

indv2_seed <- FF16_Individual(s2)
y2_seed <- g(openness, indv2_seed)

indv2_adult <- FF16_Individual(s2)
indv2_adult$set_state("height", indv2_adult$strategy$pars$hmat / 2)
y2_adult <- g(openness, indv2_adult)

ymax <- max(ymax, y2_seed[, 2], y2_adult[, 2])
plot(y_seed, type="l", col=cols_height[[1]],
     xlim=c(0, 1), ylim=c(0, ymax), las=1,
     xlab="Canopy openness", ylab="Height growth rate (m / yr)")
lines(y_adult, col=cols_height[[2]])
lines(y2_seed, col=cols_height[[1]], lty=2)
lines(y2_adult, col=cols_height[[2]], lty=2)
legend("bottomright", c("Seedling", "Larger individual"), lty=1, col=cols_height, bty="n")
Figure 10: Height growth rate against canopy openness for low-LMA (solid) and high-LMA (dotted) strategies at two sizes.

In this example, the high-LMA strategy has the lower growth rate at most light levels as a seedling but the higher rate across all light levels at the larger size. Traits, size, and environment therefore interact: none of these inputs alone determines the growth trajectory.