2

I'm implementing an RKF4(5) integrator, and I can't figure out if my code is working and I don't understand local truncation error, or if my code isn't working.

I apologize for the size of the code block, but the minimum reproducible example is rather large in this case.

import numpy as np

def RKF45(state, derivative_function, h):
    """
    Calculate the next state with the 4th-order calculation, along with a 5th-order error
    check.

    Inputs:
    state: the current value of the function, float
    derivative_function: A function which takes a state (given as a float)
                         and returns the derivative of a function at that point
    h: step size, float
    """
    k1 = h * derivative_function(state)
    k2 = h * derivative_function(state + (k1 / 4))
    k3 = h * derivative_function(state + (k1 * (3/32)) + (k2 * (9/32)))
    k4 = h * derivative_function(state + (k1 * (1932/2197)) + (k2 * (-7200/2197)) + (k3 * (7296/2197)))
    k5 = h * derivative_function(state + (k1 * (439/216)) + (k2 * (-8)) + (k3 * (3680/513)) + (k4 * (-845/4104)))
    k6 = h * derivative_function(state + (k1 * (-8/27)) + (k2 * (2)) + (k3 * (-3544/2565)) + (k4 * (1859/4104)) + (k5 * (-11/40)))
    y1 = state + ((25/216) * k1) + ((1408/2565) * k3) + ((2197/4101) * k4) - ((1/5)*k5)
    y2 = state + ((16/135) * k1) + ((6656/12825) * k3) + ((28561/56430) * k4) - ((9/50) * k5) + ((2/55) * k6)
    return(y1, y2)


def integrate_RKF45(t0, tmax, tol, h_init, x_0, df, verbose = False):
    """
    integrate a function whose derivative is df from t0 to tmax
    t0: starting time
    tmax: end time
    h_init: initial timestep
    x_0: starting position
    df: a function which takes x and returns the derivative of a function at x
    """
    h = h_init
    x_i = x_0
    t = t0
    while t < tmax:
        h = min(h, tmax - t)
        y1, y2 = RKF45(x_i, df, h)
        err_i = np.abs(y1 - y2)
        R = 2 * err_i / h
        delta = (tol/R)**(1/4)
        if verbose:
            print(f"t: {t:0.2e}, dt: {h:0.2e}, x: {x_i:0.2e}, err: {err_i:0.2e}")
        if err_i < tol:
            t += h
            x_i = y1
        elif err_i > tol:        
            h *= delta
    return(x_i)


def exponential(x_0, k=1):
    """
    A simple test function, this returns the input, so it'll integrate to e^x.
    """
    return(k * x_0)

if __name__ == "__main__":
    integrate_RKF45(t0 = 0., 
                    tmax = 0.15,
                    tol = 1e-4, 
                    h_init = 1e-2, 
                    x_0 = 1.,
                    df = exponential,
                    verbose=True)

So, this code works to the extent that it returns an approximation of the integral of whatever function I give it. However, the local truncation errors seem way too big. Running the above code will output:

t: 0.00e+00, dt: 1.00e-02, x: 1.00e+00, err: 3.95e-06
t: 1.00e-02, dt: 1.00e-02, x: 1.01e+00, err: 3.99e-06
t: 2.00e-02, dt: 1.00e-02, x: 1.02e+00, err: 4.03e-06
t: 3.00e-02, dt: 1.00e-02, x: 1.03e+00, err: 4.07e-06
t: 4.00e-02, dt: 1.00e-02, x: 1.04e+00, err: 4.11e-06
t: 5.00e-02, dt: 1.00e-02, x: 1.05e+00, err: 4.16e-06
t: 6.00e-02, dt: 1.00e-02, x: 1.06e+00, err: 4.20e-06
t: 7.00e-02, dt: 1.00e-02, x: 1.07e+00, err: 4.24e-06
t: 8.00e-02, dt: 1.00e-02, x: 1.08e+00, err: 4.28e-06
t: 9.00e-02, dt: 1.00e-02, x: 1.09e+00, err: 4.32e-06
t: 1.00e-01, dt: 1.00e-02, x: 1.11e+00, err: 4.37e-06
t: 1.10e-01, dt: 1.00e-02, x: 1.12e+00, err: 4.41e-06
t: 1.20e-01, dt: 1.00e-02, x: 1.13e+00, err: 4.46e-06
t: 1.30e-01, dt: 1.00e-02, x: 1.14e+00, err: 4.50e-06
t: 1.40e-01, dt: 1.00e-02, x: 1.15e+00, err: 4.55e-06

Where the err value is the difference between the 4th- and 5th- order methods. I was under the impression that an n^th-order method had a local truncation error of order O(dt^(n+1)), which means that the above integration should have errors of around 1e-9 instead of 1e-6.

So is my code wrong or is my understanding wrong? Thanks!

David
  • 424
  • 3
  • 16

1 Answers1

5

See https://math.stackexchange.com/questions/2701385/adaptive-step-size-in-rk45-for-second-order-ode/2701678#2701678, you seem to have used the same corrupt source for the method coefficients.

The denominator 4101 in y1 is wrong, it has to be 4104.

The delta factor should be a little more mollified, delta = (tol/R)**(1/5) or delta = (tol/R)**(1/6), and applied in every step, also the successful ones.

The reference error for the local error err_i is tol*h, that's why in R you divide by h.

This then results for your test scenario in the radially less iteration steps

t: 0.000000e+00, dt: 1.00e-02, x: 1.000000e+00, err: 1.28e-13
t: 1.000000e-02, dt: 1.40e-01, x: 1.010050e+00, err: 6.60e-08
t: 1.500000e-01, dt: 3.88e-01, x: 1.161834e+00

or for a slightly longer interval to see the step size controller actually at work

t: 0.000000e+00, dt: 1.00e-02, x: 1.000000e+00, err: 1.28e-13
t: 1.000000e-02, dt: 2.27e-01, x: 1.010050e+00, err: 7.18e-07
t: 2.372490e-01, dt: 4.31e-01, x: 1.267757e+00, err: 2.02e-05
t: 6.680839e-01, dt: 4.76e-01, x: 1.950509e+00, err: 5.03e-05
t: 6.680839e-01, dt: 4.47e-01, x: 1.950509e+00, err: 3.73e-05
t: 1.115525e+00, dt: 3.84e-01, x: 3.051213e+00, err: 2.81e-05
t: 1.500000e+00, dt: 3.89e-01, x: 4.481769e+00

​ All the mentioned corrections give the new loop in RKF45

    while t < tmax:
        h = min(h, tmax - t)
        y1, y2 = RKF45(x_i, df, h)
        err_i = np.abs(y1 - y2)
        R = err_i / h
        delta = 0.95*(tol/R)**(1/5)
        if verbose:
            print(f"t: {t:0.6e}, dt: {h:0.2e}, x: {x_i:0.6e}, err: {err_i:0.2e}")
        if R < tol:
            t += h
            x_i = y1
        h *= delta
    if verbose:
        print(f"t: {t:0.6e}, dt: {h:0.2e}, x: {x_i:0.6e}")
Lutz Lehmann
  • 25,219
  • 2
  • 22
  • 51
  • Thanks a lot! I appreciate it. Yeah, it's surprisingly difficult to find authoritative sources on RK methods. I found many disagreements on what exactly was meant by "error", and what value should be compared with the tolerance to calculate the next step size, and even what the value of delta was. – David May 24 '20 at 19:17
  • And actually, @lutz-lehmann, do you happen to know of a good reference for RK methods? – David May 24 '20 at 20:22
  • 1
    The Hairer et al. books on "Solving ODE" contain good information on methods and tests to verify the order and functioning of step size controls. There is some more recent literature on "filter based" approaches that take the history of the "delta" values into account in some kind of moving average, like proposed in https://github.com/scipy/scipy/issues/9822. I have yet to find a test scenario that replicates those results, the bad behavior and the improvements, but it shows that this is a niche things remain to explore. – Lutz Lehmann May 24 '20 at 20:56