Build and run your first simulation with Julia's SciML

In this tutorial, we will build and run our first simulation with SciML!

Note

This tutorial assumes that you have already installed Julia on your system. If you have not done so already, please follow the installation tutorial first.

To build our simulation, we will use the ModelingToolkit system for modeling and simulation. ModelingToolkit is a bit higher level than directly defining code for a differential equation system: it's a symbolic system that will automatically simplify our models, optimize our code, and generate compelling visualizations. Sounds neat? Let's dig in.

Required Dependencies

The following parts of the SciML Ecosystem will be used in this tutorial:

ModuleDescription
ModelingToolkit.jlThe symbolic modeling environment
DifferentialEquations.jlThe differential equation solvers
Plots.jlThe plotting and visualization package

Our Problem: Simulate the Lotka-Volterra Predator-Prey Dynamics

The dynamics of our system are given by the Lotka-Volterra dynamical system: Let $x(t)$ be the number of rabbits in the environment and $y(t)$ be the number of wolves. The equation that defines the evolution of the species is given as follows:

\[\begin{align} \frac{dx}{dt} &= \alpha x - \beta x y\\ \frac{dy}{dt} &= -\gamma y + \delta x y \end{align}\]

where $\alpha, \beta, \gamma, \delta$ are parameters. Starting from equal numbers of rabbits and wolves, $x(0) = 1$ and $y(0) = 1$, we want to simulate this system from time $t_0 = 0$ to $t_f = 10$. Luckily, a local guide provided us with some parameters that seem to match the system! These are $\alpha = 1.5$, $\beta = 1.0$, $\gamma = 3.0$, $\delta = 1.0$. How many rabbits and wolves will there be 10 months from now? And if z = x + y, i.e. the total number of animals at a given time, can we visualize this total number of animals at each time?

Solution as Copy-Pastable Code

using ModelingToolkit, DifferentialEquations, Plots

# Define our state variables: state(t) = initial condition
@variables t x(t)=1 y(t)=1 z(t)=2

# Define our parameters
@parameters α=1.5 β=1.0 γ=3.0 δ=1.0

# Define our differential: takes the derivative with respect to `t`
D = Differential(t)

# Define the differential equations
eqs = [D(x) ~ α * x - β * x * y
       D(y) ~ -γ * y + δ * x * y
       z ~ x + y]

# Bring these pieces together into an ODESystem with independent variable t
@mtkbuild sys = ODESystem(eqs, t)

# Convert from a symbolic to a numerical problem to simulate
tspan = (0.0, 10.0)
prob = ODEProblem(sys, [], tspan)

# Solve the ODE
sol = solve(prob)

# Plot the solution
p1 = plot(sol, title = "Rabbits vs Wolves")
p2 = plot(sol, idxs = z, title = "Total Animals")

plot(p1, p2, layout = (2, 1))
Example block output

Step-by-Step Solution

Step 1: Install and Import the Required Packages

To do this tutorial, we will need a few components:

To start, let's add these packages as demonstrated in the installation tutorial:

using Pkg
Pkg.add(["ModelingToolkit", "DifferentialEquations", "Plots"])

Now we're ready. Let's load in these packages:

using ModelingToolkit, DifferentialEquations, Plots

Step 2: Define our ODE Equations

Now let's define our ODEs. We use the ModelingToolkit.@variabes statement to declare our variables. We have the independent variable time t, and then define our 3 state variables:

# Define our state variables: state(t) = initial condition
@variables t x(t)=1 y(t)=1 z(t)=2

\[ \begin{equation} \left[ \begin{array}{c} t \\ x\left( t \right) \\ y\left( t \right) \\ z\left( t \right) \\ \end{array} \right] \end{equation} \]

Notice here that we use the form state = default, where on the right-hand side the default value of a state is interpreted to be its initial condition. This is then done similarly for parameters, where the default value is now the parameter value:

# Define our parameters
@parameters α=1.5 β=1.0 γ=3.0 δ=1.0

\[ \begin{equation} \left[ \begin{array}{c} \alpha \\ \beta \\ \gamma \\ \delta \\ \end{array} \right] \end{equation} \]

Note

Julia's text editors like VS Code are compatible with Unicode defined in a LaTeX form. Thus if you write \alpha into your REPL and then press Tab, it will auto-complete that into the α symbol. That can make your code look a lot more like the mathematical expressions!

Next, we define our set of differential equations. To define the Differential operator D, we need to first tell it what to differentiate with respect to, here the independent variable t, Then, once we have the operator, we apply that into the equations.

Note

Note that in ModelingToolkit and Symbolics, ~ is used for equation equality. This is separate from = which is the “assignment operator” in the Julia programming language. For example, x = x + 1 is a valid assignment in a programming language, and it is invalid for that to represent “equality”, which is why a separate operator is used!

# Define our differential: takes the derivative with respect to `t`
D = Differential(t)

# Define the differential equations
eqs = [D(x) ~ α * x - β * x * y
       D(y) ~ -γ * y + δ * x * y
       z ~ x + y]

\[ \begin{align} \frac{\mathrm{d} x\left( t \right)}{\mathrm{d}t} &= x\left( t \right) \alpha - x\left( t \right) y\left( t \right) \beta \\ \frac{\mathrm{d} y\left( t \right)}{\mathrm{d}t} &= - y\left( t \right) \gamma + x\left( t \right) y\left( t \right) \delta \\ z\left( t \right) &= x\left( t \right) + y\left( t \right) \end{align} \]

Notice that in the display, it will automatically generate LaTeX. If one is interested in generating this LaTeX locally, one can simply do:

using Latexify # add the package first
latexify(eqs)

Step 3: Define the ODEProblem

Now we bring these pieces together. In ModelingToolkit, we can bring these pieces together to represent an ODESystem with the following:

# Bring these pieces together into an ODESystem with independent variable t
@mtkbuild sys = ODESystem(eqs, t)

\[ \begin{align} \frac{\mathrm{d} x\left( t \right)}{\mathrm{d}t} &= x\left( t \right) \alpha - x\left( t \right) y\left( t \right) \beta \\ \frac{\mathrm{d} y\left( t \right)}{\mathrm{d}t} &= - y\left( t \right) \gamma + x\left( t \right) y\left( t \right) \delta \end{align} \]

Notice that in our equations we have an algebraic equation z ~ x + y. This is not a differential equation but an algebraic equation, and thus we call this set of equations a Differential-Algebraic Equation (DAE). The symbolic system of ModelingToolkit can eliminate such equations to return simpler forms to numerically approximate.

Notice that what is returned is an ODESystem, but now with the simplified set of equations. z has been turned into an “observable”, i.e. a state that is not computed but can be constructed on-demand. This is one of the ways that SciML reaches its speed: you can have 100,000 equations, but solve only 1,000 to then automatically reconstruct the full set. Here, it's just 3 equations to 2, but as models get more complex, the symbolic system will find ever more clever interactions!

Now that we have simplified our system, let's turn it into a numerical problem to approximate. This is done with the ODEProblem constructor, that transforms it from a symbolic ModelingToolkit representation to a numerical DifferentialEquations representation. We need to tell it the numerical details now:

  1. Whether to override any of the default values for the initial conditions and parameters.
  2. What is the initial time point.
  3. How long to integrate it for.

In this case, we will use the default values for all our variables, so we will pass a blank override []. If for example we did want to change the initial condition of x to 2.0 and α to 4.0, we would do [x => 2.0, α => 4.0]. Then secondly, we pass a tuple for the time span, (0.0,10.0) meaning start at 0.0 and end at 10.0. This looks like:

# Convert from a symbolic to a numerical problem to simulate
tspan = (0.0, 10.0)
prob = ODEProblem(sys, [], tspan)
ODEProblem with uType Vector{Float64} and tType Float64. In-place: true
timespan: (0.0, 10.0)
u0: 2-element Vector{Float64}:
 1.0
 1.0

Step 4: Solve the ODE System

Now we solve the ODE system. Julia's SciML solvers have a defaulting system that can automatically determine an appropriate solver for a given system, so we can just tell it to solve:

# Solve the ODE
sol = solve(prob)
retcode: Success
Interpolation: 3rd order Hermite
t: 34-element Vector{Float64}:
  0.0
  0.0776084743154256
  0.2326451370670694
  0.42911851563726466
  0.679082199936808
  0.9444046279774128
  1.2674601918628516
  1.61929140093895
  1.9869755481702074
  2.2640903679981617
  ⋮
  7.5848624442719235
  7.978067891667038
  8.483164641366145
  8.719247691882519
  8.949206449510513
  9.200184762926114
  9.438028551201125
  9.711807820573478
 10.0
u: 34-element Vector{Vector{Float64}}:
 [1.0, 1.0]
 [1.0454942346944578, 0.8576684823217128]
 [1.1758715885890039, 0.6394595702308831]
 [1.419680958026516, 0.45699626144050703]
 [1.8767193976262215, 0.3247334288460738]
 [2.5882501035146133, 0.26336255403957304]
 [3.860709084797009, 0.27944581878759106]
 [5.750813064347339, 0.5220073551361045]
 [6.814978696356636, 1.917783405671627]
 [4.392997771045279, 4.194671543390719]
 ⋮
 [2.6142510825026886, 0.2641695435004172]
 [4.241070648057757, 0.30512326533052475]
 [6.79112182569163, 1.13452538354883]
 [6.265374940295053, 2.7416885955953294]
 [3.7807688120520893, 4.431164521488331]
 [1.8164214705302744, 4.064057991958618]
 [1.146502825635759, 2.791173034823897]
 [0.955798652853089, 1.623563316340748]
 [1.0337581330572414, 0.9063703732075853]

Step 5: Visualize the Solution

Now let's visualize the solution! Notice that our solution only has two states. If we recall, the simplified system only has two states: z was symbolically eliminated. We can access any of the values, even the eliminated values, using the symbolic variable as the index. For example:

sol[z]
34-element Vector{Float64}:
 2.0
 1.9031627170161705
 1.815331158819887
 1.876677219467023
 2.2014528264722952
 2.8516126575541865
 4.1401549035846
 6.272820419483444
 8.732762102028264
 8.587669314435999
 ⋮
 2.8784206260031056
 4.546193913388282
 7.92564720924046
 9.007063535890381
 8.21193333354042
 5.880479462488893
 3.937675860459656
 2.5793619691938368
 1.9401285062648266

returns the time series of the observable z at time points corresponding to sol.t. We can use this with the automated plotting functionality. First let's create a plot of x and y over time using plot(sol) which will plot all of the states. Then next, we will explicitly tell it to make a plot with the index being z, i.e. idxs=z.

Note

Note that one can pass an array of indices as well, so idxs=[x,y,z] would make a plot with all three lines together!

# Plot the solution
p1 = plot(sol, title = "Rabbits vs Wolves")
Example block output
p2 = plot(sol, idxs = z, title = "Total Animals")
Example block output

Finally, let's make a plot where we merge these two plot elements. To do so, we can take our two plot objects, p1 and p2, and make a plot with both of them. Then we tell Plots to do a layout of (2,1), or 2 rows and 1 columns. Let's see what happens when we bring these together:

plot(p1, p2, layout = (2, 1))
Example block output

And tada, we have a full analysis of our ecosystem!

Bonus Step: Emoji Variables

If you made it this far, then congrats, you get to learn a fun fact! Since Julia code can use Unicode, emojis work for variable names. Here's the simulation using emojis of rabbits and wolves to define the system:

using ModelingToolkit, DifferentialEquations
@parameters α=1.5 β=1.0 γ=3.0 δ=1.0
@variables t 🐰(t)=1.0 🐺(t)=1.0
D = Differential(t)
eqs = [D(🐰) ~ α * 🐰 - β * 🐰 * 🐺,
    D(🐺) ~ -γ * 🐺 + δ * 🐰 * 🐺]

@mtkbuild sys = ODESystem(eqs, t)
prob = ODEProblem(sys, [], (0.0, 10.0))
sol = solve(prob)
retcode: Success
Interpolation: 3rd order Hermite
t: 34-element Vector{Float64}:
  0.0
  0.0776084743154256
  0.2326451370670694
  0.42911851563726466
  0.679082199936808
  0.9444046279774128
  1.2674601918628516
  1.61929140093895
  1.9869755481702074
  2.2640903679981617
  ⋮
  7.5848624442719235
  7.978067891667038
  8.483164641366145
  8.719247691882519
  8.949206449510513
  9.200184762926114
  9.438028551201125
  9.711807820573478
 10.0
u: 34-element Vector{Vector{Float64}}:
 [1.0, 1.0]
 [1.0454942346944578, 0.8576684823217128]
 [1.1758715885890039, 0.6394595702308831]
 [1.419680958026516, 0.45699626144050703]
 [1.8767193976262215, 0.3247334288460738]
 [2.5882501035146133, 0.26336255403957304]
 [3.860709084797009, 0.27944581878759106]
 [5.750813064347339, 0.5220073551361045]
 [6.814978696356636, 1.917783405671627]
 [4.392997771045279, 4.194671543390719]
 ⋮
 [2.6142510825026886, 0.2641695435004172]
 [4.241070648057757, 0.30512326533052475]
 [6.79112182569163, 1.13452538354883]
 [6.265374940295053, 2.7416885955953294]
 [3.7807688120520893, 4.431164521488331]
 [1.8164214705302744, 4.064057991958618]
 [1.146502825635759, 2.791173034823897]
 [0.955798652853089, 1.623563316340748]
 [1.0337581330572414, 0.9063703732075853]

Now go make your professor mad that they have to grade a fully emojified code. I'll vouch for you: the documentation told you to do this.