Spruce Budworm Model

1D Ordinary Differential Equations

The spruce budworm is an insect that periodically devastates spruce forests. The population dynamics can be modeled by the following ODE (Strogatz 2024, chap. 3.7):

\[ \frac{dx}{dt} = rx\left(1 - \frac{x}{k}\right) - \frac{x^2}{1 + x^2} \]

where:

The first term represents logistic growth, while the second term models predation by birds (which follows a saturating functional response).

Implementing the ODE Function

Create a Python function that implements the spruce budworm differential equation. The function should follow the signature required by scipy.integrate.solve_ivp.

  • Function name: spruce_budworm
  • Parameters: t (time), x (population), r (growth rate), k (carrying capacity)
  • Return: The rate of change \(\frac{dx}{dt}\).
  • Include appropriate docstring documentation.

Here is a template to get you started:

def spruce_budworm(t: float, x: float, r: float = 0.5, k: float = 10) -> float:
    """Docstring and type hints"""
    # Your implementation here
    dxdt = # fill in the equation
    return dxdt

Why do we need t as an input? Although the equation does not explicitly depend on time, solve_ivp requires the function to accept time as the first argument.

Phase Portrait Visualization

Create a function that plots the rate of change \(\frac{dx}{dt}\) as a function of the population \(x\). This phase portrait will help us visualize the equilibrium points and their stability.

  • Function name: plot_spruce_budworm_rate
  • Parameters: x_t (current population), r, k
  • Use matplotlib for plotting
  • Plot \(\frac{dx}{dt}\) vs \(x\) for \(x \in [0, k]\)
  • Identify and mark equilibrium points (where \(\frac{dx}{dt} = 0\))
  • Color-code equilibrium points:
    • Blue circles for stable equilibria (where \(\frac{dx}{dt}\) crosses zero from above).
    • Red circles for unstable equilibria (where \(\frac{dx}{dt}\) crosses zero from below).
  • Add a horizontal line at \(y = 0\) to indicate equilibria (null rate of change).
  • Label axes and add a title.
  • Mark the current population \(x_t\) with a vertical dashed line.

Figure 1 shows an example of the expected output.

Figure 1: Phase portrait (rate plot) of the spruce budworm model. Stable equilibria are marked in blue, unstable equilibria in red, and the current population \(x_t\) is shown as a green dashed line.

Here are some hints to help you implement this function:

  • You can use np.linspace to create an array of \(x\) values.
  • To find zero crossings: you can look for sign changes combining np.diff and np.sign.
  • Stability: a fixed point is stable if \(\frac{dx}{dt}\) decreases as you pass through it.

Review the following definitions for clarity:

  • Equilibrium point: A value \(x^*\) where \(\frac{dx}{dt} = 0\).
  • Stable equilibrium: Small perturbations decay back to \(x^*\).
  • Unstable equilibrium: Small perturbations grow away from \(x^*\).

Numerical Integration

Create a function that evolves the system forward in time using numerical integration. This function should solve the ODE and append the results to existing time and population arrays.

  • Function name: evolve_spruce_budworm
  • Inputs: t (time array), x (population array), r, k, t_eval (duration to evolve).
  • Use scipy.integrate.solve_ivp with the RK45 method.
  • Start from the last values in the input arrays.
  • Concatenate new results to the input arrays.
  • Ensure population never goes negative (use np.clip).
  • Return updated t and x arrays.

Here is a template to get you started:

def evolve_spruce_budworm(t: np.ndarray, x: np.ndarray, ...):
    """Don't forget the docstring and type hints"""
    # Define time span from last time point
    t_span = (t[-1], t[-1] + t_eval)

    # Create evaluation points, t_eval
    # This indicates where we want the solution evaluated
    # and should be distributed along the time span
    # Hint: use np.linspace

    # Solve the ODE
    solution = solve_ivp(
        fun=spruce_budworm,
        t_span=t_span,
        y0=[x[-1]],
        t_eval=t_eval,
        args=(r, k),
        method="RK45",
    )
    t_new = solution.t
    x_new = solution.y[0]

    # Concatenate results - Hint: use np.concatenate
    # Ensure non-negative population - Hint: use np.clip

    return t, x

Some important notes:

  • The args parameter in solve_ivp passes additional arguments to your ODE function. You can see how we use it in the example above.
  • Initial condition should be [x[-1]] (last population value).
  • Use method="RK45" for adaptive step-size Runge-Kutta integration.

Time Series Visualization

Create a function to plot the population dynamics over time. This visualization shows how the population evolves from the initial condition.

  • Function name: plot_spruce_budworm
  • Parameters: t (time array), x (population array).
  • Plot time on the x-axis and population on the y-axis.
  • Use green color for the trajectory.
  • Ensure y-axis starts at 0 (populations cannot be negative).
  • Include grid, labels, and title.
  • Return the figure and axes objects.

See Figure 2 for an example output.

Figure 2: Time series of the spruce budworm population generated with SciPy’s RK45 solver.

Building the Streamlit Application

Now that you have all the components, create an interactive Streamlit application that allows users to explore the spruce budworm model. This will enable real-time parameter adjustment and visualization.

While all the previous code could be done in Google Colab, Streamlit has to be built on your local machine. Follow the instructions in the README.md file in the repository to set up your environment.

Design a script, name it spruce_budworm_app.py. You will find a suggested layout below. This script will use the functions you implemented in sections Section 1 through Section 4. You can either import them from a separate module (another script you created) or paste the function definitions directly into the script. For best practices, consider creating a module (e.g., spruce_budworm_model.py) and importing the functions.

To test your app, run the following command in your terminal:

streamlit run spruce_budworm_app.py

Interactive Features

  • Display the differential equation with current parameter values.
  • Show the phase portrait (rate of change plot), using your function from section Section 2.
  • Show the time series evolution, using your function from section Section 4.
  • Add a button to “Evolve Forward” that continues the simulation, updating the plots.
  • Use st.session_state to maintain simulation state between button clicks. You will need to store the time and population arrays in the session state, otherwise they will reset on each interaction.

Layout Structure

import streamlit as st
import numpy as np
import matplotlib.pyplot as plt
from your_module import (
    spruce_budworm,
    plot_spruce_budworm_rate,
    evolve_spruce_budworm,
    plot_spruce_budworm,
)  # Or paste your functions here

st.title("Spruce Budworm Population Dynamics")

# Sidebar parameters (as in the app)
r = st.sidebar.slider("Intrinsic growth rate (r)", 0.0, 1.0, 0.5)
k = st.sidebar.slider("Carrying capacity (k)", 0.1, 10.0, 10.0)

# The app sets the initial population to k/10 by default
x0 = k / 10

# Initialize session state
if ("sbw_x" not in st.session_state):
    st.session_state["sbw_t"] = np.array([0])
    st.session_state["sbw_x"] = np.array([x0])

# Time slider and control buttons
t_eval = st.sidebar.slider("Time", 1, 100, 10)
button = st.sidebar.button("Evolve")

# Retrieve session data
t = st.session_state["sbw_t"]
x = st.session_state["sbw_x"]

# Evolve if requested
if button:
    t, x = evolve_spruce_budworm(t, x, r=r, k=k, t_eval=t_eval)
    st.session_state["sbw_t"] = t
    st.session_state["sbw_x"] = x

# Plot phase portrait and time series
fig1, ax1 = plot_spruce_budworm_rate(x[-1], r=r, k=k)
st.pyplot(fig1)
fig2, ax2 = plot_spruce_budworm(t, x)
st.pyplot(fig2)

Advanced Features (Optional)

  • Add a reset button to restart the simulation.
  • Show multiple trajectories with different initial conditions.
  • Add animation of the population dynamics.

Exploration Questions

Once your simulation is working, explore the following questions:

  1. Multiple Equilibria: For \(r = 0.5\) and \(k = 10\), how many equilibrium points exist? Which are stable?
  2. Bistability: Start with two different initial conditions (e.g., \(x_0 = 1\) and \(x_0 = 8\)). Do they converge to the same equilibrium?
  3. Hysteresis: Slowly increase the carrying capacity \(k\) from 5 to 15. Then slowly decrease it back to 5. Does the population return to the same state? If you are interested in this concept, see section Section 6.1.
  4. Outbreak Dynamics: What happens if you start with a small population (\(x_0 < 2\)) and the carrying capacity is large (\(k > 10\))?
  5. Critical Slowing Down: When the population is near an unstable equilibrium, how long does it take to move away? Compare this to the rate of change far from equilibrium.
  6. Parameter Space: Create a diagram showing the number of equilibria as a function of \(r\) and \(k\). Where do bifurcations occur?

Slow-Fast Dynamics

The carrying capacity \(k\) can be interpreted as a slowly varying parameter in real ecosystems (e.g., due to seasonal changes or forest management). You can simulate this by gradually changing \(k\) over time in your app. This can lead to hysteresis effects, where the population does not return to its original state after \(k\) is restored. Experiment with this by modifying your Streamlit app to allow \(k\) to vary over time.

Mathematical Background

In this section I provide some additional mathematical context for the spruce budworm model. Play with your simulation to see these concepts in action!

Equilibrium Analysis

Equilibrium points satisfy:

\[ rx^*\left(1 - \frac{x^*}{k}\right) - \frac{(x^*)^2}{1 + (x^*)^2} = 0 \]

This can be rewritten as:

\[ rx^*\left(1 - \frac{x^*}{k}\right) = \frac{(x^*)^2}{1 + (x^*)^2} \]

The left side represents birth rate (logistic growth), and the right side represents predation rate. Equilibria occur where these balance.

Stability Analysis

The stability of an equilibrium \(x^*\) is determined by the sign of the derivative:

\[ \frac{d}{dx}\left(\frac{dx}{dt}\right)\bigg|_{x=x^*} \]

If this derivative is:

  • Negative: the equilibrium is stable (attracting).
  • Positive: the equilibrium is unstable (repelling).
  • Zero: higher-order analysis is needed.

Ecological Interpretation

  • Low equilibrium: Few budworms, controlled by predation.
  • High equilibrium: Outbreak state, budworms overwhelm predators.
  • Middle equilibrium: Usually unstable, separates the two basins of attraction.
  • Hysteresis: The system can “jump” between states depending on history.

This behavior explains why spruce budworm populations can suddenly explode from low levels to outbreak proportions, and why simply reducing the outbreak may not return the forest to a healthy state.

Resources

References

Strogatz, Steven H. 2024. Nonlinear Dynamics and Chaos: With Applications to Physics, Biology, Chemistry, and Engineering. Chapman; Hall/CRC.