Numerical integration in C++ with Boost odeint

May 2016, Bruxelles

Back to HOMEPAGE

In this notes there's nothing you can't find in the official documentation, moreover informations here may be misleading or even wrong. I've written this page just to save some time to whoever needs a quick kickstart to numerical integration with the Boost library (included myself). I'll appreciate any comment, suggestion and critics!

A very common task in numerical computing is integrating systems of differential equations. The boost library makes it very easy, but understanding how to do it the first time by searching into the official documentation and surfing the examples is time consuming, so I've written this brief howto.
This howto was written under GNU/Linux with Boost v1.60.

Some references: And here is the index:

INTRO:
Setting up the Boost C++ library
TUTORIAL:
0 - Integration functions
1 - Simplest ODE problem
2 - Integration functions: overview
3 - Integration with steppers
4 - Stiff problems and Jacobian

Setting up the Boost C++ library

Good news: the Boost library implements many functions into header files, so in many cases no compilation is necessary! This is our case. Download the boost library (version higher than 1.53 since odeint was introduced in that Boost version) from www.boost.org, and extract it where you like.
In my case, I've extracted the Boost library into the directory: ~/boost_1_60_0/
After extracting, you should see the directory boost/numeric, in which there's the odeint.hpp file. We will use this file including it into our C++ source.

0 - Integration functions

In case you are familiar to the Matlab/Octave syntax for the ode45 integrators, using odeint is quite similar.
First of all, odeint have 5 integration functions:
  1. integrate_const
  2. integrate_n_steps
  3. integrate_times
  4. integrate_adaptive
  5. integrate
The syntax for integrate_const is (the others vary just a little, see below):
integrate_const( stepper, system, x0, t0, t1, dt, observer)

Where:

1 - Simplest ODE problem

The following code defines a scalar ordinary differential equation and solves it using the simplest integration function.
 #include <iostream>
 #include <boost/numeric/odeint.hpp>       // odeint function definitions 
 
 using namespace std;
 using namespace boost::numeric::odeint;
 
 // Defining a shorthand for the type of the mathematical state
 typedef std::vector< double > state_type;
 
 // System to be solved: dx/dt = -2 x
 void my_system( const state_type &x , state_type &dxdt , const double t )
 {    dxdt[0] = -2*x[0];   }
 
 // Observer, prints time and state when called (during integration)
 void my_observer( const state_type &x, const double t )
 {    std::cout  << t << "   " << x[0] << std::endl;   }
 
 // ------  Main
 int main()
 {
     state_type x0(1); // Initial condition, vector of 1 element (scalar problem) 
     x0[0] = 10.0;
 
     // Integration parameters
     double t0 = 0.0;
     double t1 = 10.0;
     double dt = 0.1;
 
     // Run integrator
     integrate( my_system, x0, t0, t1, dt, my_observer );
 }
In order to compile you have to include the path for the extracted Boost folder with the -I option. For example, my Boost directory is ~/boost_1_60_0/ so I run: BOOSTLIB=~/boost_1_60_0 g++ -I$BOOSTLIB my_file.cpp The output of the program should be something like: 0 10 0.1 8.18731 0.222349 6.41018 0.344698 5.0188 0.467046 3.92942 0.589395 3.07651 0.711744 2.40873 0.838353 1.86989 0.964962 1.45159 1.09157 1.12687 1.2236 0.865356 [...] A couple of notes about the program.
  1. First of all, as you can see the integrate function does not require a stepper. In fact, integrate is the simplest integration function, used just to perform some integration on the fly. You usually want to use the others (see below).
  2. Secondly, you should note that the solution of the mathematical problem, x (aka the "state" of the system) have a type that we called state_type. One can choose various different types, here I've opted for a simple std vector of doubles, whereas we could have used ublas types for example.
  3. The my_system function takes as an input the current state and fills the second argument, dxdt that is the left hand side of the ODE problem.
  4. The observer just prints the intermediate result. We could have used it to write the results on a file.
  5. The observer is invoked roughly every "dt".
Comparison with exact solution:

Exact_vs_Numerical


2 - Integration functions: overview

The choice of the integration function to be used mainly depends on what kind of output you need, that is how often you need to call the observer.
Note that the choice of the stepper (think to an adaptive method, that changes the integration timestep according to the error) will modify how actually the integration is performed, but calls to the observer (and thus your output) follows the rules above.
Steppers are the actual "solvers", and are called recursively by the choosen integration function.

3 - Integration with steppers

Let's now switch to the integrate_const function and modify the previous program to introduce a stepper. With a stepper we can choose the type of integration to be performed, such as Runge-Kutta method, adaptive method or whatever.
For example, to choose a 4th order Runge-Kutta method you can call the integration function like this:
 integrate_const( runge_kutta4<state_type>(), my_system, x0, t0, t1, dt, my_observer );

Or you might prefere a cleaner syntax:
 typedef runge_kutta4<state_type> rk4;
 integrate_const( rk4(), my_system, x0, t0, t1, dt, my_observer );

Steppers can also be used to directly perform an integration step, in the following way. Here only the main() is shown, the rest being the very same as above:
 // ------  Main
 int main()
 {
   state_type x(1); // the state, vector of 1 element (scalar problem)
   x[0] = 10.0;
   
   // Integration parameters
   double t = 0.0;
   double dt = 0.1;
   size_t nSteps = 1000;
   
   // Create stepper
   runge_kutta4<state_type> rk4;
   
   // Perform integration step by step
   for(int ii = 0; ii < nSteps; ++ii)
   {
     // adjourn current time
     t += dt;
   
     // now perform only one integration step. Solution x is overwritten
     rk4.do_step(my_system, x, t, dt);
   
     // ... and output results
     cout << t << "   " << x[0] << endl;
   }
 }
Note that the x variable is initialized at the initial condition and is rewritten and output at each step.

As already stated, steppers are basically "solvers". There are some different kinds of steppers: The basic stepper have already been used and other possible integration methods are: runge_kutta4, euler, runge_kutta_cash_karp54, runge_kutta_dopri5, runge_kutta_fehlberg78, modified_midpoint, rosenbrock4.
Error steppers are steppers that provide an error estimation. Those steppers are required to build controlled steppers, that can adapt the integration timestep. Some error steppers are runge_kutta_cash_karp54, runge_kutta_dopri5 and runge_kutta_fehlberg78.
Controlled steppers are built on error steppers. With controlled steppers, the routine starts performing the integration with the given timestep size dt, but may decide with an error criteria to use a different timestep in case the suggested dt was not adequate. To build a controlled stepper for Runge Kutta, you can use controlled_runge_kutta.
Dense steppers perform some kind of continuous interpolation between timesteps. A dense stepper is the rosenbrock4, useful for stiff systems (see below).

Example
In the following example we'll use the controlled stepper runge_kutta_cash_karp54 to solve a slightly more interesting problem:

Oscillator_Equation

It's the equation of the harmonic oscillator. For sure you know that with a choice of ξ > 1 the equation shows a nonoscillating behaviour (supercritical damping). By choosing the appropriate initial conditions, the solution can present an overshoot. Here is the equation that we are going to solve plus BCs, along with a sketch of the exact solution. Note that since odeint deals with first order equations, the second order harmonic oscillator have to be recasted into a second order ODE system:

Oscillator_Equation

exact_solution_overdamped

In the following code I define a dimension-2 linear system and solve it using two different steppers. The first one is the already seen runge_kutta4 (basic stepper), the latter being a controller stepper based on the runge_kutta_cash_karp54 (that internally adjusts the integration steps based on error considerations).
The timestep is set very big on purpose, so that the runge_kutta4 basic stepper fails, while the adaptive stepper obtains a good result, internally automatically refining the timestep.

Take a look at the code and don't miss the notes right after!
#include <iostream>

#include <boost/numeric/odeint.hpp> // odeint function definitions

using namespace std;
using namespace boost::numeric::odeint;

// Defining a shorthand for the type of the mathematical state
typedef std::vector< double > state_type;

// System to be solved: dx/dt = -2 x
void my_system( const state_type &x , state_type &dxdt , const double t )
{
    dxdt[0] =  0*x[0]  +   1*x[1];
    dxdt[1] = -1*x[0]  - 2.2*x[1];
}

// Observer, prints time and state when called (during integration)
void my_observer( const state_type &x, const double t )
{
    std::cout  << t << "   " << x[0] << "   " << x[1] << std::endl;
}

// ------  Main
int main()
{
    state_type x0(2); // Initial condition, vector of 2 elements (position and velocity)
    x0[0] = 0.0;
    x0[1] = 1.0;

    // Integration parameters
    double t0 = 0.0;
    double t1 = 20.0;
    double dt = 1.0;

    // ----  Steppers definition  ----
    // Basic stepper:
    // follows given timestep size "dt"
    typedef runge_kutta4<state_type> rk4;

    // Error stepper, used to create the controlled stepper
    typedef runge_kutta_cash_karp54< state_type > rkck54;

    // Controlled stepper: 
    // it's built on an error stepper and allows us to have the output at each 
    // internally defined (refined) timestep, via integrate_adaptive call 
    typedef controlled_runge_kutta< rkck54 > ctrl_rkck54;

    // ----  Run integrations with the different steppers  ----

    // Run integrator with rk4 stepper
    std::cout << "==========  rk4 - basic stepper  ====================" << std::endl;
    integrate_adaptive( rk4(), my_system, x0, t0, t1, dt, my_observer );

    // Run integrator with controlled stepper
    std::cout << "==========  ctrl_rkck54 - Controlled Stepper  =======" << std::endl;
    x0[0] = 0.0; // Reset initial conditions
    x0[1] = 1.0;
    integrate_adaptive( ctrl_rkck54(), my_system, x0 , t0 , t1 , dt, my_observer );
}

In solid line the exact solution, circles and squares are numerical solutions.

Exact_vs_Numerical


A couple of notes: Here is the output of the program:
==========  rk4 - basic stepper  ====================
0   0   1
1   0.279667   0.0914
2   0.223193   -0.0698595
3   0.138185   -0.0688047
4   0.0784087   -0.0449346
5   0.0428421   -0.0260353
6   0.0229939   -0.0143611
7   0.0122327   -0.00774322
8   0.00647889   -0.0041288
9   0.00342373   -0.0021893
10   0.00180716   -0.00115761
11   0.000953317   -0.000611208
12   0.000502743   -0.000322475
13   0.000265086   -0.000170075
14   0.000139763   -8.96806e-05
15   7.36854e-05   -4.72839e-05
16   3.88473e-05   -2.49291e-05
17   2.04802e-05   -1.31428e-05
18   1.07971e-05   -6.92889e-06
19   5.69216e-06   -3.65289e-06
20   3.00087e-06   -1.92578e-06
==========  ctrl_rkck54 - Controlled Stepper  =======
0   0   1
0.2   0.160729   0.62909
0.4   0.25906   0.369921
0.6   0.314033   0.191075
0.809566   0.339954   0.065062
1.01913   0.344383   -0.0166857
1.2428   0.33413   -0.0702323
1.47763   0.31359   -0.101238
1.73025   0.285836   -0.11597
2.00191   0.253734   -0.118652
2.29508   0.219629   -0.112967
2.6131   0.185377   -0.101919
2.96069   0.152372   -0.087867
3.34299   0.121725   -0.0726501
3.76712   0.0941826   -0.0576185
4.24293   0.0702024   -0.0437071
4.78465   0.049993   -0.0315045
5.41432   0.0335592   -0.0213196
6.17034   0.0207314   -0.0132375
7.13966   0.0111524   -0.00714214
8.2138   0.0056026   -0.0035926
9.33462   0.00272999   -0.00175145
10.5825   0.0012258   -0.00078657
11.998   0.00049426   -0.000317177
13.6366   0.000172728   -0.000110845
15.5745   4.98493e-05   -3.199e-05
17.9281   1.10743e-05   -7.10652e-06
20   2.9352e-06   -1.8835e-06


Generation functions
Odeint provides two handy functions to generate controlled and dense_output steppers, called make_controlled and make_dense_output. Here is the documentation page for Boost version 1.60.
Here's an example:
// Define the error stepper
typedef runge_kutta_cash_karp54< state_type > error_stepper_rkck54;

// Error bounds
double err_abs = 1.0e-10;
double err_rel = 1.0e-6;

// Fire! 
integrate_adaptive( make_controlled( err_abs , err_rel , error_stepper_rkck54() ) , 
                    my_system , x0 , t0 , t1 , dt , my_observer );

4 - Stiff problems and Jacobian

Stiff problems are characterized by very different timescales. Think for example at a chemically reacting mixture: chemical rates may be very different among each others (by orders of magnitude!) That characteristic makes common integrators inappropriate and one needs apposite solvers.
Consider for example the following system:

This is basically a system where the derivative of the first component x1 is likely way bigger than the one for the second component x2. A quick check with matlab shows that a Runge-Kutta 45 adaptive method (ode45) takes 70000 steps to solve it into a certain interval, whereas a stiff solver (ode15) takes only 100 timesteps!
The more the system stiffness, the more the disparity: what if we increase the stiffness, then? Let's put the coefficients of the first equation to 30000 and 20000, the required timesteps for the nonstiff solver increase to 700000, while the stiff solver takes still 100 timesteps!!! Of course, nobody knows what kind of black magic they put into Matlab functions, but I think you are now convinced of the importance of stiff solvers.

Odeint implements some stiff solvers, such as the rosenbrock4, but they additionaly require to provide the system Jacobian, that is the matrix of the partial derivatives. Also, note that the stiff integrator of Odeint *requires* that the system state is of ublas::vector type and the jacobian *must* be a ublas::matrix!

Here's a program that solves the problem defined above. Note that:

 #include <iostream>
 
 #include <boost/numeric/odeint.hpp> // odeint function definitions
 
 using namespace std;
 using namespace boost::numeric::odeint;
 
 // Defining shorthands for boost types vector and matrix.
 // vector is used for the system state "x" and matrix for the jacobian "J"
 typedef boost::numeric::ublas::vector< double > vector_type;
 typedef boost::numeric::ublas::matrix< double > matrix_type;
 
 // System to be solved.
 // This is a functor: "operator()" overloads the operator "()" making the
 // struct behave like a function
 struct my_system
 {
   void operator()( const vector_type &x, vector_type &dxdt, const double t)
   {
     dxdt[0] = -3000*x[0] -2000*x[1];
     dxdt[1] =    -2*x[0]    -3*x[1];
   }
 };
 
 // Jacobian of the system.
 // Functor! See struct "my_system"
 struct jacobian
 {
   void operator()( const vector_type &x, matrix_type &J, double t, vector_type &dfdt )
   {
     J(0,0) = -3000;
     J(0,1) = -2000;
     J(1,0) = -2;
     J(1,1) = -3;
   }
 };
 
 // Observer, prints time and state when called (during integration)
 void my_observer( const vector_type &x, const double t )
 {
   std::cout << t << setw(15) << x[0] << setw(15) << x[1] << std::endl;
 }
 
 // ------  Main
 int main()
 {
   vector_type x0(2); // Initial condition, vector of 2 elements (position and velocity)
   x0[0] = 0.0;
   x0[1] = 1.0;
 
   // Integration parameters
   double t0 = 0.0;
   double t1 = 20.0;
   double dt = 1.0;
 
   // ----  Run integrations with the different steppers  ----
 
   // Stiff integration
   integrate_const(make_dense_output< rosenbrock4< double > >(1.0e-6, 1.0e-6),
                   make_pair(my_system(), jacobian()),
                   x0, t0, t1, dt, my_observer);
 }

That's it. I hope you enjoyed!
FOR ANY CRITICS/SUGGESTIONS/WHATEVER CONTACT ME!
For any spotted typo I will send you a cookie.

Cheers!

Back to HOMEPAGE