Modelling a Periodic Event During ODE and Jump Simulations
This tutorial will describe how to simulate systems with periodic events in ODEs and jump simulations (SDEs use identical syntax). We will consider a model with a circadian rhythm, where a parameter represents the level of light. While outdoor light varies smoothly, in experimental settings a lamp is often simply turned on/off every 12 hours. Here we will model this toggling of the light using a periodic event that is triggered every 12 hours.
Modelling a circadian periodic event in an ODE simulation
We will consider a simple circadian model, consisting of a single protein ($X$), which is phosphorylated ($X \to Xᴾ$) in the presence of light ($l$). Here, the light parameter can either be $0$ (night) or $1$ (day). We can model this using a simple periodic event which switches the value of $l$ every 12 hours (here, %
is the remainder operator).
using Catalyst
circadian_model = @reaction_network begin
@discrete_events begin
12 => [l ~ (l + 1)%2]
end
(kₚ*l,kᵢ), X <--> Xᴾ
end
\[ \begin{align*} \mathrm{X} &\xrightleftharpoons[\mathtt{k_i}]{\mathtt{k_p} l} \mathrm{\mathtt{X^P}} \end{align*} \]
We can now simulate this model, observing how a 24-hour cycle is reached
using OrdinaryDiffEqDefault, Plots
u0 = [:X => 150.0, :Xᴾ => 50.0]
ps = [:kₚ => 0.1, :kᵢ => 0.1, :l => 1.0]
tspan = (0.0, 100.0)
oprob = ODEProblem(circadian_model, u0, tspan, ps)
sol = solve(oprob)
plot(sol)
Modelling a circadian periodic event in a jump simulation
We can define periodic events in an identical manner for jump simulations. Let's reuse our previously defined network, but now simulate it as a stochastic chemical kinetics jump process model
using JumpProcesses
u0 = [:X => 150, :Xᴾ => 50] # define u0 as integers now
jinput = JumpInputs(circadian_model, u0, tspan, ps)
jprob = JumpProblem(jinput)
jsol = solve(jprob)
plot(jsol)

Sometimes we might want to setup a more complicated condition than simply that our event occurs at a fixed time or with a fixed frequency. For example, suppose we want to skip the first event at time t = 12
and then have the event be periodic after that point every 12 units of time. We can do so using a more general discrete callback as follows
circadian_model = @reaction_network begin
@discrete_events begin
((t % 12 == 0) & (t > 12)) => [l ~ (l + 1)%2]
end
(kₚ*l,kᵢ), X <--> Xᴾ
end
\[ \begin{align*} \mathrm{X} &\xrightleftharpoons[\mathtt{k_i}]{\mathtt{k_p} l} \mathrm{\mathtt{X^P}} \end{align*} \]
Here our condition ((t % 12 == 0) & (t > 12))
determines when the event occurs, evaluating to true
when t
is a multiple of 12
that is also larger than 12
. We now finish specifying our model
using JumpProcesses
u0 = [:X => 150, :Xᴾ => 50]
ps = [:kₚ => 0.1, :kᵢ => 0.1, :l => 1.0]
tspan = (0.0, 100.0)
jinput = JumpInputs(circadian_model, u0, tspan, ps)
jprob = JumpProblem(jinput)
Next, if we simulate our model, we note that the events do not seem to be triggered
sol = solve(jprob)
plot(sol)

The reason is that general discrete callbacks' conditions are only checked at the end of each simulation time step, and the probability that these will coincide with the callback's trigger times ($t = 12, 24, 36, ...$) is infinitely small. Hence, we must directly instruct our simulation to stop at these time points. We can do this using the tstops
argument:
tstops = range(12.0, tspan[2]; step = 12.0)
sol = solve(jprob; tstops)
plot(sol)

Plotting the light level
Sometimes when simulating models with periodic parameters, one would like to plot the parameter's value across the simulation. For this, there are two potential strategies. One includes creating a saving callback. The other one, which we will demonstrate here, includes turning the parameter $l$ to a variable (so that its value is recorded throughout the simulation):
circadian_model = @reaction_network begin
@variables l(t)
@discrete_events begin
12 => [l ~ (l + 1)%2]
end
(kₚ*l,kᵢ), X <--> Xᴾ
end
Next, we simulate our model like before (but providing $l$'s value as an initial condition):
u0 = [:X => 150.0, :Xᴾ => 50.0, :l => 1.0]
ps = [:kₚ => 0.1, :kᵢ => 0.1]
oprob = ODEProblem(circadian_model, u0, (0.0, 100.0), ps)
sol = solve(oprob)
If we directly plot $l$'s value, it will be too small (compared to $X$ and $Xᴾ$ to be discernible). We instead @unpack
our variables, and then plot a re-scaled version:
@unpack X, Xᴾ, l = circadian_model
plot(sol; idxs = [X, Xᴾ, 150*l], labels = ["X" "Xᴾ" "Light amplitude"])