Adaptive RK4 Orbits#

We’ll use the same basic ideas in our fixed timestep RK4 integrator.

import numpy as np
import matplotlib.pyplot as plt
# global parameters
GM = 4.0 * np.pi**2  # assuming M = 1 solar mass

OrbitState#

The OrbitState class is unchanged.

class OrbitState:
    # a container to hold the star positions
    def __init__(self, x, y, u, v):
        self.x = x
        self.y = y
        self.u = u
        self.v = v

    def __add__(self, other):
        return OrbitState(self.x + other.x, self.y + other.y,
                          self.u + other.u, self.v + other.v)

    def __sub__(self, other):
        return OrbitState(self.x - other.x, self.y - other.y,
                          self.u - other.u, self.v - other.v)

    def __mul__(self, other):
        return OrbitState(other * self.x, other * self.y,
                          other * self.u, other * self.v)

    def __rmul__(self, other):
        return self.__mul__(other)

    def __str__(self):
        return f"{self.x:10.6f} {self.y:10.6f} {self.u:10.6f} {self.v:10.6f}"

Integrator class#

We’ll put all of the logical for the integration and stepsize control into a class, called OrbitsRK4. The main Runge-Kutta algorithm that we already explored is now in a function single_step().

class OrbitsRK4:
    """ model the evolution of a single planet around the Sun"""

    def __init__(self, a, e):
        """ a = semi-major axis (in AU),
            e = eccentricity """

        self.a = a
        self.e = e

        x0 = 0.0          # start at x = 0 by definition
        y0 = a*(1.0 - e)  # start at perihelion

        # perihelion velocity (see C&O Eq. 2.33 for ex)
        u0 = -np.sqrt( (GM/a)* (1.0 + e) / (1.0 - e) )
        v0 = 0.0

        self.history = [OrbitState(x0, y0, u0, v0)]
        self.time = [0.0]

        self.n_reset = None

    def kepler_period(self):
        """ return the period of the orbit in yr """
        return np.sqrt(self.a**3)

    def energy(self, n):
        """ return the energy (per unit mass) at time n """

        state = self.history[n]
        return 0.5 * (state.u**2 + state.v**2) - \
            GM / np.sqrt(state.x**2 + state.y**2)

    def rhs(self, state):
        """ RHS of the equations of motion."""

        # current radius
        r = np.sqrt(state.x**2 + state.y**2)

        # position
        xdot = state.u
        ydot = state.v

        # velocity
        udot = -GM * state.x / r**3
        vdot = -GM * state.y / r**3

        return OrbitState(xdot, ydot, udot, vdot)

    def single_step(self, state_old, dt):
        """ take a single RK-4 timestep from t to t+dt for the system
        ydot = rhs """

        # get the RHS at several points
        ydot1 = self.rhs(state_old)

        state_tmp = state_old + 0.5 * dt * ydot1
        ydot2 = self.rhs(state_tmp)

        state_tmp = state_old + 0.5 * dt * ydot2
        ydot3 = self.rhs(state_tmp)

        state_tmp = state_old + dt * ydot3
        ydot4 = self.rhs(state_tmp)

        # advance
        state_new = state_old + (dt / 6.0) * (ydot1 + 2.0 * ydot2 +
                                              2.0 * ydot3 + ydot4)

        return state_new

    def integrate(self, dt_in, err, tmax):
        """ integrate the equations of motion using 4th order R-K method with an
            adaptive stepsize, to try to achieve the relative error err.  dt
            here is the initial timestep
            """
        
        # start with the old timestep
        dt_new = dt_in
        dt = dt_in

        n_reset = 0
        t = 0

        while t < tmax:
            
            state_old = self.history[-1]

            if err > 0.0:

                # adaptive stepping iteration loop -- keep trying to take a step
                # until we achieve our desired error

                rel_error = 1.e10

                n_try = 0
                while rel_error > err:
                    dt = min(dt_new, tmax-t)

                    # take 2 half steps
                    state_tmp = self.single_step(state_old, 0.5*dt)
                    state_new = self.single_step(state_tmp, 0.5*dt)

                    # now take just a single step to cover dt
                    state_single = self.single_step(state_old, dt)

                    # state_new should be more accurate than state_single since it
                    # used smaller steps

                    # estimate the relative error now
                    dstate = state_new - state_single

                    rel_error = max(abs(dstate.x / state_single.x),
                                    abs(dstate.y / state_single.y),
                                    abs(dstate.u / state_single.u),
                                    abs(dstate.v / state_single.v))

                    # adaptive timestep algorithm for RK4

                    dt_est = dt * abs(err/rel_error)**0.2

                    # give a little safety in our dt_est, and limit the
                    # amount the timestep can change 
                    dt_new = min(max(0.9 * dt_est, 0.25 * dt), 4.0 * dt)

                    n_try += 1

                if n_try > 1:
                    # n_try = 1 if we took only a single try at the step
                    n_reset += (n_try-1)

            else:

                # take just a single step
                dt = min(dt_new, tmax-t)

                # take just a single step to cover dt
                state_new = self.single_step(state_old, dt)

            # successful step
            t += dt

            # store
            self.time.append(t)
            self.history.append(state_new)

        self.n_reset = n_reset

    def plot(self, points=False):
        """plot the orbit"""
        fig, ax = plt.subplots()
        x = [q.x for q in self.history]
        y = [q.y for q in self.history]

        # draw the Sun
        ax.scatter([0], [0], marker=(20, 1), color="y", s=250)

        if points:
            ax.scatter(x, y, marker=".")
        else:
            ax.plot(x, y)
        ax.set_aspect("equal")
        return fig

Note

We have a lot of freedom in how we define our error. We could use an absolute error instead of a relative error, we could also only consider certain components (like \(x\) and \(y\)), etc.

Highly eccentric orbit#

Let’s create an orbit with a tolerance of \(10^{-5}\), and the a very high eccentricity (\(a = 1\), \(e = 0.95\))

o = OrbitsRK4(1.0, 0.95)
o.integrate(0.05, 1.e-5, o.kepler_period())

How many points did it take?

len(o.history)
92

and how many times did we reset?

o.n_reset
39

Let’s plot it

fig = o.plot(points=True)
fig.set_size_inches(5, 12)
../_images/77e5de9d53a94aefe9f8e3bfbe7ce81788f24613278cfda96c2a4f4569f4c779.png

Clearly we see that the size of the timestep is much larger at aphelion as compared to perihelion.

Let’s create a version with a fixed timestep, with a timestep of \(0.001~\mathrm{yr}\). We can do this by passing in a negative error.

o_fixed = OrbitsRK4(1.0, 0.95)
o_fixed.integrate(0.001, -1, o_fixed.kepler_period())
fig = o_fixed.plot()
fig.set_size_inches(5, 12)
../_images/be1676ef22fa0a867cc5817ad3cbfc527d9400b7e19237cd1fc42e0f78904ace.png
len(o_fixed.history)
1001

We need a really small fixed step size to integrate this at all reasonably

Timestep evolution#

Let’s look at the adaptive version some more. First, let’s look at the timestep evolution.

ts = np.array(o.time)

dt = ts[1:] - ts[:-1]
tc = 0.5 * (ts[1:] + ts[:-1])

fig, ax = plt.subplots()
ax.plot(tc, dt)
ax.set_xlabel("t [yr]")
ax.set_ylabel("dt [yr]")
ax.set_yscale("log")
../_images/184fc0d8e652ceb78249c309d3b1410e4563c2d614bb56b2101656168bfc90d3.png

Notice that the timestep changes by ~ 3 orders of magnitude over the evolution.

Energy conservation#

What about energy conservation?

e = []
ts = o.time
for n in range(len(o.history)):
    e.append(o.energy(n))
    
fig, ax = plt.subplots()
ax.plot(ts, e/e[0])
ax.set_xlabel("time [yr]")
ax.set_ylabel("E/E(t=0)")
ax.ticklabel_format(useOffset=False)
../_images/aeda15bfee4c3478a1b991838694432690045d8cbe664cdf5afc3fe0f1776f08.png

Now let’s do 10 orbits

o = OrbitsRK4(1.0, 0.95)
o.integrate(0.05, 1.e-5, 20*o.kepler_period())
e = []
ts = o.time
for n in range(len(o.history)):
    e.append(o.energy(n))
    
fig, ax = plt.subplots()
ax.plot(ts, e/e[0])
ax.set_xlabel("time [yr]")
ax.set_ylabel("E/E(t=0)")
ax.ticklabel_format(useOffset=False)
../_images/b73d7dd511380dd90d4490c56103ad91d7b2fe221b8f6737493954aa8895c3e3.png

Caution

We can see that RK4 does not conserve energy!

There is a steady decrease in the total energy over 20 orbits. If we wanted to evolve for millions of years, this would certainly be a problem. Making the tolerance tighter certainly will help, but it will also make things a lot more expensive.

The problem is that 4th order Runge-Kutta does not know anything about energy conservation.

C++ implementation#

A C++ implementation following the same ideas is available as orbit_adaptive.cpp

#include <iostream>
#include <vector>
#include <iomanip>
#include <cmath>
#include <numeric>
#include <limits>
#include <fstream>

const double GM = 4.0 * M_PI * M_PI;

struct OrbitState {
    // a container to hold the star positions
    double x{};
    double y{};
    double u{};
    double v{};

    OrbitState(double x0, double y0, double u0, double v0)
        : x{x0}, y{y0}, u{u0}, v{v0}
    {}

    OrbitState() {}

    OrbitState operator+(const OrbitState& other) const {
        return OrbitState(x + other.x, y + other.y, u + other.u, v + other.v);
    }

    OrbitState operator-(const OrbitState& other) const {
        return OrbitState(x - other.x, y - other.y, u - other.u, v - other.v);
    }

    OrbitState operator*(double a) const {
        return OrbitState(a * x, a * y, a * u, a * v);
    }

};

std::ostream& operator<< (std::ostream& os, const OrbitState& s) {
    os.precision(6);

    os << std::setw(14) << s.x
       << std::setw(14) << s.y
       << std::setw(14) << s.u
       << std::setw(14) << s.v;

    return os;
}

inline
OrbitState operator*(double a, const OrbitState& state) {
    return OrbitState(a * state.x, a * state.y, a * state.u, a * state.v);
}

class OrbitsRK4 {
    // model the evolution of a single planet around the Sun using gravitational
    // interaction of three stars

private:

    // a vector to store the history of our orbit
    std::vector<OrbitState> history;
    std::vector<double> time;
    int n_reset{0};

public:

    OrbitsRK4(const double a, const double e)  {

        OrbitState initial_conditions;

        // put the planet at perihelion

        initial_conditions.x = 0.0;
        initial_conditions.y = a * (1.0 - e);
        initial_conditions.u = std::sqrt((GM / a) * (1.0 + e) / (1.0 - e));
        initial_conditions.v = 0.0;

        history.push_back(initial_conditions);

        time.push_back(0.0);
    }

    int npts() {
        // return the number of integration points
        return time.size();
    }

    int get_n_reset() {
        // return the number of times a step was reset
        return n_reset;
    }

    double get_time(const int n) {
        // return the physical time for step n
        return time[n];
    }

    OrbitState& get_state(const int n) {
        // return a reference to the state at time index n
        return history[n];
    }

    double energy(const int n) {
        // return the energy of the system for timestep n

        const auto& state = history[n];

        // kinetic energy

        double KE = 0.5 * (state.u * state.u + state.v * state.v);
        double PE = -GM / std::sqrt(state.x * state.x + state.y * state.y);
        return KE + PE;
    }

    OrbitState rhs(const OrbitState& state) {
        // compute the ydot terms

        OrbitState ydot;

        ydot.x = state.u;
        ydot.y = state.v;

        double dx = state.x;
        double dy = state.y;

        double r = std::sqrt(dx * dx + dy * dy);

        ydot.u = -GM * dx / std::pow(r, 3);
        ydot.v = -GM * dy / std::pow(r, 3);

        return ydot;

    }

    OrbitState single_step(const OrbitState& state_old, const double dt) {
        /// take a single RK-4 timestep through dt

        auto ydot1 = rhs(state_old);

        OrbitState state_temp;

        state_temp = state_old + 0.5 * dt * ydot1;
        auto ydot2 = rhs(state_temp);

        state_temp = state_old + 0.5 * dt * ydot2;
        auto ydot3 = rhs(state_temp);

        state_temp = state_old + dt * ydot3;
        auto ydot4 = rhs(state_temp);

        OrbitState s = state_old + (dt / 6.0) *
            (ydot1 + 2.0 * ydot2 + 2.0 * ydot3 + ydot4);

        return s;
    }


    void integrate(const double dt_in, const double err, const double tmax) {
        // integrate the equations of motion using 4th order R-K
        // method with an adaptive stepsize, to try to achieve the
        // relative error err.  dt here is the initial timestep

        // if err < 0, then we don't do adaptive stepping, but rather
        // we always walk at the input dt

        // start with the old timestep
        double dt_new{dt_in};
        double dt{dt_in};

        double t{0.0};

        while (t < tmax) {

            const auto &state_old = history.back();

            // adaptive stepping iteration loop -- keep trying to take
            // a step until we achieve our desired error
            double rel_error = std::numeric_limits<double>::max();

            int n_try{0};

            OrbitState state_new;

            while (rel_error > err) {

                dt = std::min(dt_new, tmax - t);

                // take 2 half steps

                auto state_temp = single_step(state_old, 0.5*dt);
                state_new = single_step(state_temp, 0.5*dt);

                // now take just a single step to cover dt

                auto state_single = single_step(state_old, dt);

                // state_new should be more accurate than state_single
                // since it used smaller steps

                // estimate the relative error now

                double pos_err =
                    std::max(std::abs((state_new.x - state_single.x) /
                                      state_single.x),
                             std::abs((state_new.y - state_single.y) /
                                      state_single.y));

                double vel_err =
                    std::max(std::abs((state_new.u - state_single.u) /
                                      state_single.u),
                             std::abs((state_new.v - state_single.v) /
                                      state_single.v));

                rel_error = std::max(pos_err, vel_err);

                // adaptive timestep algorithm from Garcia (Eqs. 3.30
                // and 3.31)

                double dt_est = dt * std::pow(std::abs(err/rel_error), 0.2);
                dt_new = std::min(std::max(0.9 * dt_est, 0.25 * dt), 4.0 * dt);

                n_try++;
            }

            if (n_try > 1) {
                // n_try = 1 if we took only a single try at the step
                n_reset += (n_try - 1);
            }

            // successful step
            t += dt;

            // store the new solution -- this will automatically be
            // used as the "old" state in the next step

            time.push_back(t);
            history.push_back(state_new);

        }

    }
};


int main() {

    OrbitsRK4 o(1.0, 0.95);
    o.integrate(0.05, 1.e-5, 1.0);

    std::cout << "number of points = " << o.npts() << std::endl;
    std::cout << "number of times a step was rejected = "
              << o.get_n_reset() << std::endl;

    std::ofstream of("orbit_adaptive.dat");

    for (int n = 0; n < o.npts(); ++n) {
        const auto& state = o.get_state(n);

        of << std::setw(14) << o.get_time(n);

        of << state;

        of.precision(10);

        of << std::setw(18) << o.energy(n);

        of << std::endl;

    }

}