# Import the packages we need
using ModelingToolkit ## for symbolic system definition
using ModelingToolkit: t_nounits as t, D_nounits as D ## generic symbolic time variable and derivative operator
using OrdinaryDiffEq ## for ODE solvers
using CairoMakie ## for plotting
# Time-dependent variables.
# The assigned values are default values that will be used as initial conditions if initial conditions are not explicitly provided.
@variables x(t)=1 y(t)=1
# Parameters with assigned values
@parameters a=1.5 b=1.0 c=3.0 d=1.0
# Model equations
eqs = [D(x) ~ a * x - b * x * y,
D(y) ~ -c * y + d * x * y]
# Model system, defined by the equations, the independent time variable, the time-dependent variables and the parameters
@named sys = ODESystem(eqs, t, [x, y], [a, b, c, d])
# Simplified system equations. Necessary step before solving!
simpsys = structural_simplify(sys)
# Simulation timespan
tspan = (0.0, 10.0)
# Initial conditions. A vector of pairs of the form `[state => initial_condition, ...]`
u0 = [x => 5, y => 2]
# Problem to be solved
prob = ODEProblem(simpsys, u0, tspan)
# Solution of the problem using the Tsit5 solver
sol = solve(prob, Tsit5())
retcode: Success
Interpolation: specialized 4th order "free" interpolation
t: 31-element Vector{Float64}:
0.0
0.08117449098753833
0.2033472146318696
0.34481923758030086
0.5133453442586928
0.6997205942636654
0.9518449632884769
1.2078720930549587
1.5117501225731436
1.8960367593015124
2.3419689205858836
2.8001611761311196
3.154617596349518
3.446603431988819
3.816591344004686
4.104070083015805
4.50104637647137
4.897856816920354
5.496187010603642
6.017436474469348
6.362775057001467
6.659006719074707
6.972834263097586
7.347100606008145
7.7074811126632445
8.176455290373381
8.672007583256836
9.165339638990314
9.559050370550041
9.893470554340231
10.0
u: 31-element Vector{Vector{Float64}}:
[5.0, 2.0]
[4.737486951537538, 2.329442325406833]
[4.159128176496565, 2.7852722096289324]
[3.378232286801216, 3.105606216306078]
[2.5672395472069955, 3.0811885263548477]
[1.9773834953757778, 2.676854050573453]
[1.609406579313759, 1.9582843875373555]
[1.552749513812718, 1.3546873132985058]
[1.7519768926099824, 0.8937306633628304]
[2.3546312250537205, 0.61337657523862]
[3.5563657586661104, 0.5897128591272462]
[4.981843895194956, 1.0731410601672249]
[4.818950256230912, 2.2392637119053838]
[3.343661157987135, 3.1118008426116455]
[1.9313561988828034, 2.6139960476246533]
[1.5774359332463672, 1.8066110816035383]
[1.6566423633822205, 1.02648877617623]
[2.174120423530587, 0.6583968490231039]
[3.750824823666615, 0.6146437130025499]
[5.148338613885276, 1.3873773408422705]
[4.315048391711306, 2.6760882747903865]
[2.757183955074088, 3.1223343036598226]
[1.814291633172061, 2.437547442159294]
[1.5487493568378434, 1.4588265428851046]
[1.761324481429152, 0.8887489752549641]
[2.557619101510861, 0.5854753413658367]
[4.022955232810912, 0.6610024111093685]
[5.1545522666136785, 1.5571104452908116]
[3.7679793462689086, 2.9724967230117327]
[2.227954470813059, 2.900435675873252]
[1.946115942732129, 2.6293333027601915]
The solution object contains the values of every variable of the system (x
and y
) for every simulated timestep. One can easily access the values of a specific variable using its symbolic name
sol[x] ## or similarly sol[y]
31-element Vector{Float64}:
5.0
4.737486951537538
4.159128176496565
3.378232286801216
2.5672395472069955
1.9773834953757778
1.609406579313759
1.552749513812718
1.7519768926099824
2.3546312250537205
3.5563657586661104
4.981843895194956
4.818950256230912
3.343661157987135
1.9313561988828034
1.5774359332463672
1.6566423633822205
2.174120423530587
3.750824823666615
5.148338613885276
4.315048391711306
2.757183955074088
1.814291633172061
1.5487493568378434
1.761324481429152
2.557619101510861
4.022955232810912
5.1545522666136785
3.7679793462689086
2.227954470813059
1.946115942732129
Finally we can plot the timeseries of both variables of the solution in a line plot using
fig = plot(sol)
# display the figure
fig
We will shortly see more plot types and options.
Let's now move on to a two-dimensional model of a neuron. This is a popular model, known as the Izhikevich neuron [1]. Even though the model appears simple, it can exhibit many different spiking patterns (e.g. regular and fast spiking, bursting, chattering) by changing its parameters. The Izhikevich neuron is a similar system of ODEs like the Lotka-Volterra example above. One notable difference however is the spiking mechanism. Spiking in the Izhikevich neuron needs to be implemented "manually". That is we need to detect when the voltage variable crosses a spiking threshold and every time this event happens we need to reset the neuron's voltage to a more polarized value and potentially alter other variables too.
@variables v(t)=-65 u(t)=-13
# The following parameter values correspond to chattering spiking.
@parameters a=0.02 b=0.2 c=-50 d=2 I=10
eqs = [D(v) ~ 0.04 * v ^ 2 + 5 * v + 140 - u + I,
D(u) ~ a * (b * v - u)]
2-element Vector{Symbolics.Equation}:
Differential(t)(v(t)) ~ 140 + I - u(t) + 5v(t) + 0.04(v(t)^2)
Differential(t)(u(t)) ~ a*(-u(t) + b*v(t))
Now we need to define the callback to simulate spikes. Spiking callbacks belong to the family of discrete callbacks and are defined by two parts :
a condition which triggers the callback every timestep it evaluates as true
, e.g. v > 30
means that each time the neuron voltage v
rises above 30
mV a spike is triggered.
a list of equations that assign values to variables and/or parameters, e.g. v ~ -50
means that when a spike is triggered the voltage is resetted to -50
mV.
The spiking event for this model looks like this
event = (v > 30.0) => [u ~ u + d, v ~ c]
v(t) > 30.0 => Symbolics.Equation[u(t) ~ d + u(t), v(t) ~ c]
Now we can move on to define the system including the spiking event, create a problem and solve it.
# Model system and simplification.
@named izh_system = ODESystem(eqs, t, [v, u], [a, b, c, d, I]; discrete_events = event)
izh_simple = structural_simplify(izh_system)
# Initial conditions.
u0 = [v => -65.0, u => -13.0]
# Simulation timespan
tspan = (0.0, 400.0)
izh_prob = ODEProblem(izh_simple, u0, tspan)
izh_sol = solve(izh_prob)
fig = plot(izh_sol)
fig
or if we want to plot just the voltage timeseries with its spiking pattern
fig = plot(izh_sol; idxs=[v])
fig
After defining and simulating a system we might want to run another simulation by changing either or both of the parameter values and the initial conditions. The Izhikevich neuron that we simulated above has multiple spiking regimes that correspond to different parameter value combinations. If we have our ODEProblem
already defined as the izh_prob
variable above, we can remake it by keeping everything the same except for the fields that we choose to alter. Here we will change only the model parameters to change from chattering to fast spiking.
izh_prob = remake(izh_prob; p = [a => 0.1, b => 0.2, c => -65, d => 2])
# or we can change the initial conditions along with the parameters as
izh_prob = remake(izh_prob; p = [a => 0.1, b => 0.2, c => -65, d => 2], u0 = [v => -70, u => -10])
izh_sol = solve(izh_prob)
fig = plot(izh_sol; idxs=[v])
fig
Notice how the spiking pattern has changed compared to the previous simulation.
Until now we have been simulating the Izhikevich neuron by injecting it with a constant external DC current I=10
. We'll now see how we can expand the I
input to a dynamic current, which is more realistic (currents do not remain constant in the brain for too long). Since I
will change dynamically in time, it will be a time-dependent variable of the system and not a constant parameter.
@variables v(t)=-65 u(t)=-13 I(t)
# The following parameter values correspond to regular spiking.
@parameters a=0.02 b=0.2 c=-65 d=8
eqs = [D(v) ~ 0.04 * v ^ 2 + 5 * v + 140 - u + I,
D(u) ~ a * (b * v - u),
I ~ 10*sin(0.5*t)]
event = (v > 30.0) => [u ~ u + d, v ~ c]
# Notice how `I` was moved from the parameter list to the variable list in the following call.
@named izh_system = ODESystem(eqs, t, [v, u, I], [a, b, c, d]; discrete_events = event)
izh_simple = structural_simplify(izh_system)
Model izh_system:
Equations (2):
2 standard: see equations(izh_system)
Unknowns (2): see unknowns(izh_system)
v(t) [defaults to -65]
u(t) [defaults to -13]
Parameters (4): see parameters(izh_system)
a [defaults to 0.02]
d [defaults to 8]
b [defaults to 0.2]
c [defaults to -65]
Observed (1): see observed(izh_system)
Let's display the equations of the original and the simplified system to see the effect of structural_simplify
.
equations(izh_system)
3-element Vector{Symbolics.Equation}:
Differential(t)(v(t)) ~ 140 - u(t) + 5v(t) + I(t) + 0.04(v(t)^2)
Differential(t)(u(t)) ~ a*(-u(t) + b*v(t))
I(t) ~ 10sin(0.5t)
The original system contains the algebraic equation for the external current I
.
equations(izh_simple)
2-element Vector{Symbolics.Equation}:
Differential(t)(v(t)) ~ 140.0 - u(t) + 5.0v(t) + I(t) + 0.04(v(t)^2)
Differential(t)(u(t)) ~ a*(-u(t) + b*v(t))
However the I
equation has been simplified away in izh_simple
, since it was substituted into the differential equation D(v)
. This is an important functionality of structural_simplify
as it has turned a system of Differential Algebraic Equations (DAE) to one of Ordinary Differential Equations (ODE). ODEs can be integrated (solved) much more efficiently compared to DAEs.
tspan = (0.0, 400.0)
# We do not provide initial conditions, so the default values for `v` and `u` from above will be used.
izh_prob = ODEProblem(izh_simple, [], tspan)
izh_sol = solve(izh_prob)
fig = plot(izh_sol; idxs=[v, I])
fig
Notice how the external current is slowly being accumulated in the neuron's potential v
until the eventual spike and reset.
References
[1] : E. M. Izhikevich, "Simple model of spiking neurons," in IEEE Transactions on Neural Networks, vol. 14, no. 6, pp. 1569-1572, Nov. 2003, doi: 10.1109/TNN.2003.820440