Parallell Ensemble Simulation of the Periodically Forced Pendulum

In this project we are going to simulate the trajectories of a periodically forced pendulum

\[ x'' + \left( \alpha + \beta \cos{2\pi t}\right)\sin{x}=0 \]

Set Up. Computing one solution

using DifferentialEquations

function parameterized_pendulum!(du,u,p,t)
    x,y = u
    α,β = p
    du[1] = dx = y
    du[2] = dy = -(α+β*cos(2*π*t))*sin(x)
end

num_final=20
u0 = [0.0,1.0] # Initial Conditions
tspan = (0.0,num_final)
p = [0.5,0.1]
f=ODEFunction(parameterized_pendulum!,
    syms = [:x, :y])
prob = ODEProblem(f,u0,tspan,p,saveat=[0,1,num_final])
sol = solve(prob)

Ensemble Simulation

Now that we have tested that the system computes one solution, we can assemble the simulation experiment

When we want to simulate a bunch of solutions, called and ensemble simulation, we need to define a function which will assign new problems or initial conditions (one could also change the parameters) to the same ODEs.

A simple scenario is to modify the initial condition by a small random perturbation, to see the effect of dependence with respect to the initial conditions. To this end we take a random initial condition with a normal distribution around our the point \((\pi,0)\), which is a hyperbolic saddle pont, and build a simulation:

function prob_func(prob,i,repeat)
    #@. prob.u0 = [1.0+rand()*1e-2,1.0+randn()*1e-2,1.0+randn()*1e-2]
    @. prob.u0 = [π+   0.1*randn(),0.1*randn()]
    prob
end

We can test that the function works applying it to the problem

julia> prob
ODEProblem with uType Vector{Float64} and tType Float64. In-place: true
timespan: (0.0, 100.0)
u0: 2-element Vector{Float64}:
 0.0
 1.0

julia> prob_func(prob,1,true)
ODEProblem with uType Vector{Float64} and tType Float64. In-place: true
timespan: (0.0, 100.0)
u0: 2-element Vector{Float64}:
 3.1469246668695297
 0.007749192580242798

and we see that the initial condition has changed from \([1,0]\) above to the random perturbation of \((\pi,0)\) (numbers will differ at every run).

One could also specify a predefined set of initial conditions to cover regularly an interval or a condition to stop based on previous computations. See the documentation at [https://docs.sciml.ai/DiffEqDocs/stable/features/ensemble/] for more details.

Now it is time to define which values will be recorded from every simulation. This can be the full solution, in which case we do not need to specify anything or a special function. For instance, in the following function we will save the first and the last values:

output_func(sol,i)= ([sol[begin][1],sol[begin][2],sol[end][1],sol[end][2],p[1],p[2],i],false)

We can also test that this function works:

julia> sol
retcode: Success
Interpolation: 1st order linear
t: 3-element Vector{Float64}:
   0.0
   1.0
 100.0
u: 3-element Vector{Vector{Float64}}:
 [0.0, 1.0]
 [0.9270727649671856, 0.7781739937191692]
 [-0.22324970982006284, -0.989034653336524]

julia> output_func(sol,1) # 1 may be any number
([0.0, 1.0, -0.22324970982006284, -0.989034653336524, 0.5, 0.1, 1.0], false)

The ensemble simulation is built as follows

ensemble_prob = EnsembleProblem(prob,
    prob_func=prob_func,
    output_func=output_func)

We now generate the simulation. If we are in a mulithreading environment we can use the threaded version:

num_simulations=1_000
@time  sim = solve(ensemble_prob, EnsembleThreads(), trajectories=num_simulations);

Now sim is a collection of recorded simulations acording to the function for the output we just defined

julia> sim[1],sim[end]
([3.15319958148223, 0.01246027614094439, 65.63715793227964, 0.31083198327601425, 0.5, 0.1, 1.0], [3.147012216371264, 0.015192732680286439, 6.0127069035180005, 1.3930256997040051, 0.5, 0.1, 1000.0])

Exporting solutions to a DataFrame

Althoug there is a plot recipe for plotting the solutions, we decided to save the simulation first as a DataFrame/CSV file and then loaded for plotting with your program of choice (later I will post how to do it in Julia/PlotlyJS). I think it is good practice to separate computation from postprocessing and many computing facilities only have terminal access.

We create an empty dataframe with the right column names and then fill it with the trajectories recorded

using DataFrames

df=DataFrame(x0=[],y0=[],x1=[],y1=[],a=[],b=[],Sim=[])
for i=1:num_simulations
   push!(df,sim[i])
end

This produces a dataframe with the computed values:

julia> df
1000×7 DataFrame
  Row │ x0       y0           x1        y1           a    b    Sim
Any      Any          Any       Any          Any  Any  Any
──────┼───────────────────────────────────────────────────────────────
    13.1532   0.0124603    65.6372   0.310832     0.5  0.1  1.0
    23.1614   0.00623642   15.5928   -0.0107822   0.5  0.1  2.0
    33.15516  0.0051406    -7.84982  -0.997625    0.5  0.1  3.0
    43.14294  0.019612     9.04021   -0.218531    0.5  0.1  4.0
    53.13438  0.0154323    8.21038   0.804815     0.5  0.1  5.0
    63.12374  0.00444056   -51.3852  -1.20316     0.5  0.1  6.0
                                                  
  9953.13759  -0.0119371   -9.2579   -0.0221638   0.5  0.1  995.0
  9963.14093  0.00340972   18.1892   1.32428      0.5  0.1  996.0
  9973.14527  -0.00157223  -3.18328  -0.0567693   0.5  0.1  997.0
  9983.15112  0.00447047   13.806    -1.13599     0.5  0.1  998.0
  9993.15362  0.00460436   3.23222   -0.00657033  0.5  0.1  999.0
 10003.14701  0.0151927    6.01271   1.39303      0.5  0.1  1000.0
                                                      988 rows omitted

We can export it with the CSV package:

using CSV
CSV.write("./outdata.csv",df)

and plot it with our program of choice.

Plotting the simulation with Plotly

Exporting solutions in Tidy Format

Instead of saving only the last iterate in x1 and y1, we could produce long dataframe which includes the number of the iteration, the initial condition \((x_0,y_0)\), the parameters and the computed \((x,y)\) for every iteration with is computed using the num_iterates value. In this case we need to modify the output_sol so that we return an array where each element is an array again with the saved solution:

function output_func(sol,i)
    dim_output=length(sol.t)
    outsol=([sol.t, #save t
    map(mod2pi,sol[1,:]),  #save x
    sol[2,:],   #save y
    fill(sol.prob.u0[1],dim_output), #save ic x
    fill(sol.prob.u0[2],dim_output), #save ic y
    fill(sol.prob.p[1],dim_output), #save param a
    fill(sol.prob.p[2],dim_output), #save param b
    fill(i,dim_output)], #Save simulation number
    false) #end
    outsol
end


prob = ODEProblem(f,u0,tspan,p,
    saveat=range(0,num_final,num_final+1))
sol = solve(prob)

We check that the solution is computed at the right place and now we set the experiment

ensemble_prob = EnsembleProblem(prob,
    prob_func=prob_func,
    output_func=output_func)
num_simulations=10_000
sim2 = solve(ensemble_prob,
    EnsembleThreads(),
    trajectories=num_simulations);


using DataFrames

df2= df2=DataFrame(sim2[begin],:auto)
for i=2:num_simulations
   df3=DataFrame(sim2[i],:auto)
   df2=vcat(df2,df3)
end
rename!(df2,[:t,:x1,:y1,:x0,:y0,:a,:b,:Sim])

using CSV
CSV.write("./outdata2.csv",df2)

The output can be plotted with any program of your choice for instance, to get an interactive version you can use Plotly for R

library(plotly)
Loading required package: ggplot2

Attaching package: 'plotly'
The following object is masked from 'package:ggplot2':

    last_plot
The following object is masked from 'package:stats':

    filter
The following object is masked from 'package:graphics':

    layout
df<-  read.csv("./outdata2.csv")

fig <- df %>%
  plot_ly(type = 'scatter', mode = 'markers')

fig <- fig %>%
  add_trace(
    x = ~x1,
    y = ~y1,
    frame= ~t,
    color= 100*sqrt((df$x0-pi)**2+(df$y0)**2),
    marker = list(
      size = 1,
      opacity = 0.5,
      line = list(
        width = 0
      )
    ),
    showlegend = F
  )

fig %>%
  hide_colorbar()