Week 10 - Lotka-Volterra competition model - Visualization of dynamics with complex eigenvalues

Part 1 - Visualize the Trajectory of 2 Species Population Dynamics

In class, we learned that the stability of a nonlinear ODE can be characterized by studying the dynamics of the “displacement for the equilibrium” (\(\pmb\varepsilon\)), which follows a much simpler linear ODE. If the linear ODE describing the dynamics of the displacement have a stable equilibrium at zero, then this indicates that the original equilibrium of the nonlinear ODE will also be stable. Consider the case where the dynamics of displacements \(\pmb\varepsilon\) can be described by the following linear ODE:

\[ \dfrac{d \vec{\pmb\varepsilon}}{d t} = \mathcal{J}\vec{\pmb\varepsilon} \]

where \(\vec{\pmb\varepsilon} = (\varepsilon_1, \varepsilon_2)^T\) and \(\mathcal{J} = \begin{pmatrix} -1 & 1\\ -2 & -1 \end{pmatrix}\). Or, we can write the linear system by two ODEs: \[\begin{align*} \dfrac{d \varepsilon_1}{d t} &= (-1)\times \varepsilon_1 + (1)\times\varepsilon_2\\ \dfrac{d \varepsilon_2}{d t} &= (-2)\times \varepsilon_1 + (-1)\times\varepsilon_2\\ \end{align*}\]

We can see that this ODE has an equilibrium at zero and the eigenvalues are as follows, which have negative real parts (indicating that it’ll be stable) with a non-zero imaginary part (indicating that it’ll rotate towards the equilibrium, as shown below).

eigen(matrix(c( -1, 1, -2, -1), 2, 2, T))$values # eigenvalues
## [1] -1+1.414214i -1-1.414214i
library(ggplot2)
library(tidyverse)
library(deSolve)
library(gganimate)
library(gifski)

### Model specification
ERROR <- function(times, state, parms) {
  with(as.list(c(state, parms)), {
    de1_dt = A * e1 + B * e2
    de2_dt = C * e1 + D * e2
    return(list(c(de1_dt, de2_dt)))
  })
}

### Imaginary eigenvalue
### Model parameters
times <- seq(0, 10, by = 0.0001)
state <- c(e1 = 0.1, e2 = 0.1)
parms <- c(A = -1, B = 1, C = -2, D = -1)

### Model application
error_1 <- ode(func = ERROR, times = times, y = state, parms = parms)
plot(e2 ~ e1, error_1, type = "l")
abline(h = 0, lty = 3, col = "red")
abline(v = 0, lty = 3, col = "red") 

### Plot animation
p1 <- error_1 %>%
  as.data.frame() %>%
  ggplot(aes(x = e1, y = e2)) +
  geom_point() +
  geom_vline(xintercept = 0, linetype="dashed", color = "red") +
  geom_hline(yintercept = 0, linetype="dashed", color = "red") +
  labs(subtitle = "Time: {round(frame_time, digit = 1)}") +
  transition_time(time) +
  shadow_wake(wake_length = 1)
#gif1 <- animate(p1, renderer = gifski_renderer())
#anim_save(filename = "W10_dynamics_error_imaginary.gif", gif1)

Here is another example that has negative real parts (indicating that it’ll be stable) with imaginary part equals to zero (indicating that it will not rotate but directly reaches the equilibrium, as shown below).

times <- seq(0, 10, by = 0.0001)
state <- c(e1 = 0.1, e2 = 0.1)
parms <- c(A = -1, B = 1, C = 0, D = -2)

### Model application
error_2 <- ode(func = ERROR, times = times, y = state, parms = parms)
plot(e2 ~ e1, error_2, type = "l")
abline(h = 0, lty = 3, col = "red")
abline(v = 0, lty = 3, col = "red") 

### Plot animation
p2 <- error_2 %>%
  as.data.frame() %>%
  ggplot(aes(x = e1, y = e2)) +
  geom_point() +
  geom_vline(xintercept = 0, linetype="dashed", color = "red") +
  geom_hline(yintercept = 0, linetype="dashed", color = "red") +
  labs(subtitle = "Time: {round(frame_time, digit = 1)}") +
  transition_time(time) +
  shadow_wake(wake_length = 1)
#gif2 <- animate(p2, renderer = gifski_renderer())
#anim_save(filename = "W10_dynamics_error_real.gif", gif2)

Part 2 - Invasion Simulation

In the following section, we are looking at how disturbances could affect population dynamics by doing reciprocal invasion simulations.

LV_invasion <- function(r1 = 1.0, r2 = 1.0,
                        a11 = 0.05, a21 = 0.01, a22 = 0.05, a12 = 0.01,
                        runtime = 100, invasion = c(50, 75)){

  ### Model specification
  LV <- function(times, state, parms){
    with(as.list(c(state, parms)), {
      dN1_dt = N1 * (r1 - a11 * N1 - a12 * N2)
      dN2_dt = N2 * (r2 - a22 * N2 - a21 * N1)
      return(list(c(dN1_dt, dN2_dt)))
    })
  }

  ### Event function
  ### N2 invade N1-monoculture
  eventfun_2invade <- function(times, state, parms){
    with(as.list(c(state, parms)), {
      N1 <- N1
      N2 <- N2 + 1
      return(c(N1, N2))
    })
  }

  ### N1 invade N2-monoculture
  eventfun_1invade <- function(times, state, parms){
    with(as.list(c(state, parms)), {
      N1 <- N1 + 1
      N2 <- N2
      return(c(N1, N2))
    })
  }

  ### Model parameters
  times <- seq(0, runtime, by = 0.1)
  state_1 <- c(N1 = 5.0, N2 = 0.0)
  state_2 <- c(N1 = 0.0, N2 = 5.0)
  parms <- c(r1 = r1, r2 = r2,
             a11 = a11, a21 = a21, a22 = a22, a12 = a12)

  ### Model application w/ event function
  ### N2 invade N1-monoculture
  pop_size_1 <- ode(func = LV, times = times,
                    y = state_1, parms = parms,
                    events = list(func = eventfun_2invade, time = invasion))

  ### N1 invade N2-monoculture
  pop_size_2 <- ode(func = LV, times = times,
                    y = state_2, parms = parms,
                    events = list(func = eventfun_1invade, time = invasion))

  ### Data manipulation
  Data <- as.data.frame(rbind(pop_size_1, pop_size_2))
  Data$Scenario <-rep(c("N2 invade N1-monoculture", "N1 invade N2-monoculture"),
                      each = length(times))

  ### Visualize the population dynamics
  Data %>%
    gather(key = "Species", value = "pop_size", -c(time, Scenario)) %>%
    ggplot(aes(x = time, y = pop_size, color = Species)) +
    geom_line(linewidth = 1.5) +
    facet_grid(~Scenario) +
    theme_classic() +
    theme(legend.position = "bottom") 
}

Plot the population dynamics under different parameter sets.

#### Run mutual invasion tests
### N1 win: N1 can invade & N2 cannot invade
LV_invasion(a11 = 0.01, a21 = 0.05, a22 = 0.05, a12 = 0.01)

### N2 win: N1 cannot invade & N2 can invade
LV_invasion(a11 = 0.05, a21 = 0.01, a22 = 0.01, a12 = 0.05)

### Coexist: N1 can invade & N2 can invade
LV_invasion(a11 = 0.05, a21 = 0.01, a22 = 0.05, a12 = 0.01)

### Priority Effect: N1 cannot invade & N2 cannot invade
LV_invasion(a11 = 0.01, a21 = 0.05, a22 = 0.01, a12 = 0.05)