Method Plug-in Framework

Introduction

The control mechanisms set up for NDSolve have been set up so that you can define your own numerical integration algorithms and use them as specifications for the Method option of NDSolve.

NDSolve accesses its numerical algorithms and the information it needs from them in an object-oriented manner. At each step of a numerical integration, NDSolve keeps the method in a form so that it can keep private data as needed.

The structure for method data used in NDSolve.

NDSolve does not access the data associated with an algorithm directly, so you can keep the information needed in any form that is convenient or efficient to use. The algorithm and information which might be saved in its private data are accessed only through method functions of the algorithm object.

Required method functions for algorithms used from NDSolve.

These method functions must be defined for the algorithm work with NDSolve. The Step method function should always return a list, but the length of the list depends on whether the step was successful or not. Also, some methods may need to compute the function value rhs[t + h, y + Δy] at the step end, so, to avoid recomputation, you can add that to the list.

Interpretation of Step method output.

Classical Runge-Kutta

Here is an example of how to set up and access a simple numerical algorithm.

This defines a method function to take a single step towards integrating an ODE using the classical fourth order RungeKutta method. Since the method is so simple, it is not necessary to save any private data.

CRK4[]["Step"[rhs_,  t_,  h_,  y_,  yp_]]  := 
      Module[{k0, k1, k2, k3}, 
k0 = 
          h yp; 
k1 = 
          h rhs[t + h/2, y + k0/2]; 
k2 = 
          h rhs[t + h/2, y + k1/2]; 
k3 = 
          h rhs[t + h, 
              y + k2]; 
{h,  (k0 + 2  k1 + 2  k2 + 
                k3)/6}]

This defines a method function so that NDSolve can obtain the proper difference order to use for the method. The ___ template is used because the difference order for the method is always 4.

CRK4[___]["DifferenceOrder"[]]  :=  4

This defines a method function for the step mode so that NDSolve will know how to control time steps. This algorithm method does not

CRK4[___]["Stepmode"[]]  :=  Fixed

This integrates the simple harmonic oscillator equation with fixed step size.

fixed  =  
      NDSolve[{x''[t]  +  x[t]  ==  0,  
          x[0]  ==  1,  x'[0]  ==  0}, 
        x, {t, 0, 2 π},  Method -> CRK4]

Generally using a fixed step size is less efficient than allowing the step size to vary with the local difficulty of the integration. Modern explicit Runge-Kutta methods (accessed in NDSolve with Method->ExplicitRungeKutta) have a so-called embedded error estimator which makes it possible to very efficiently determine appropriate step sizes. An alternative is to use built in step controller methods which use extrapolation. The method DoubleStep uses an extrapolation based on integrating a time step with a single step of size h and two steps of size h/2. The method Extrapolation does a more sophisticated extrapolation and modifies the degree of extrapolation automatically as the integration is performed, but is generally used with base methods of difference order 1 and 2.

This integrates the simple harmonic oscillator using the classical fourth order Runge-Kutta method with steps controlled by using the DoubleStep method.

dstep  =  
      NDSolve[{x''[t]  +  x[t]  ==  0,  
          x[0]  ==  1,  x'[0]  ==  0}, 
        x, {t, 0, 2 π},  
        Method -> {DoubleStep,  Method -> CRK4,  
            StiffnessTest -> False}]

This makes a plot comparing the error in the computed solutions at the step ends.  The error for the DoubleStep method is shown in blue.

ploterror[{sol_},  opts___]  :=  
        Module[{
points  =  
              x@Coordinates[1]  /.  sol,  
values  =  
              x@ValuesOnGrid[]  /.  sol},  
ListPlot[
            Transpose[{points,  values  -  Cos[points]}],  
            opts]
];
Show[
        Block[{$DisplayFunction  =  
              Identity}, {
ploterror[
              fixed], 
ploterror[dstep,  
              PlotStyle -> 
                RGBColor[0, 0, 1]]
}]];

The fixed step size ended up with smaller overall error mostly because the steps are so much smaller: it required more than 3 times as many steps. For a problem where the local solution structure changes more significantly, the difference can be even greater.

In computing the solution above, the option StiffnessTest->False was used for the DoubleStep adaptive step method.  For simple equations like the harmonic oscillator that are known not to be stiff, this setting is fine.  However, if you plan to work with equations where the properties of the solution are not known, it is better to leave the stiffness test active, since otherwise you may get solutions corrupted from instability.  The stiffness test requires the LinearStabilityBoundary property be defined for the method.

One way to gain some understanding of the LinearStabilityBoundary property is to use the OrderStar package, which can plot the linear stability region for methods.

This loads the OrderStar package.

Needs["NumericalMath`OrderStar`"];

The linear stability region for a method is the region in the complex z = hλ plane where the method with step h applied to the test equation x' = λx has modulus less than |x|, so that errors are not amplified.

This computes symbolically a step for the CRK4 method as defined above.

CRK4[]["Step"[Function[{t, x},  λ x],  t, h, 
        x, λ x]]

This computes the factor multiplying x, converting hλ->z.  Note that x had to be added to the result of the method because the "Step" function was defined to only return the increment.

poly  = 
      Expand[(x  + Last[%])/
            x  /.  {h -> 1,  λ -> z}]

The absolute stability region for the method is shown in white.

OrderStar[poly,  1,  Ticks -> True];

You can see from the plot that the linear stability boundary is at about -2.8.  You could use this value, but for higher precision solutions, it is good to have an exact value, if possible.   Mathematica's symbolic capabilities make this quite feasible.

This computes the factor exactly using the symbolic command Reduce.

Reduce[{poly  == 1,  z  <  0}, z]

This sets the "LinearStabilityBoundary" property

CRK4[___]["LinearStabilityBoundary"[]]  =  
      Root[24 + 12 #1 + 4 #(1^2) + #(1^3) &, 1]

Now it is possible to compute the solution without the stiffness test disabled.  The solution is the same as the one computed above because this equation is not stiff.

dstep  =  
      NDSolve[{x''[t]  +  x[t]  ==  0,  
          x[0]  ==  1,  x'[0]  ==  0}, 
        x, {t, 0, 2 π},  
        Method -> {DoubleStep,  Method -> CRK4}]

For more sophisticated methods, it may be necessary or more efficient to set up some data for the method to use. When NDSolve uses a particular numerical algorithm for the first time, it calls an initialization function. You can define rules for the initialization that will set up appropriate data for your method.

Initializing a method from NDSolve.

As a system symbol, InitializeMethod is protected, so to attach rules to it, you would need to unprotect it first. It is better to keep the rules associated with your method. A tidy way to do this is to make the initialization definition using TagSet as shown above.

As an example, suppose we want to redefine the Runge-Kutta method shown above so that instead of using exact coefficients 2, 1/2, and 1/6 numerical values with the appropriate precision are used instead to make the computation slightly faster.

This defines a method function to take a single step towards integrating an ODE using the classical fourth order RungeKutta method using saved numerical values for the required coefficients.

CRK4[{two_,  half_,  sixth_}]["Step"[rhs_,  t_,  h_,  
          y_,  yp_]]  := 
      Module[{k0, k1, k2, k3}, 
k0 = 
          h yp; 
k1 = 
          h rhs[t + half h, y + half k0]; 
k2 = 
          h rhs[t + half h, y + half k1]; 
k3 = 
          h rhs[t + h, y + k2]; 
{h,  
          sixth (k0 + two (k1 + k2) + k3)}]

This defines a rule which will initialize the algorithm object with the data to be used later.

CRK4  /:  
      NDSolve`InitializeMethod[CRK4,  stepmode_,  rhs_,  state_,  
        opts___]  :=  
      Module[{prec}, 
prec  =  
          state@"WorkingPrecision"[]; 
CRK4[
          N[{2,  1/2,  1/6},  prec]]]

Having the numerical values of the numbers saved gives between 5 and 10 percent speedup for a longer integration using DoubleStep.

EventLocator

A method for event location is described within EventLocator.

Adams methods

In terms of the NDSolve framework, it is not really any more difficult to write an algorithm which controls steps automatically. However, the requirements of estimating error and determining an appropriate step size usually make this much more difficult from both the mathematical and programming standpoint. The example which follows is a partial adaptation of the FORTRAN DEABM code of Shampine and Watts to fit into the NDSolve framework. The algorithm adaptively chooses both step size and order based on criteria described in [SG75].

The first stage is to define the coefficients. The integration method uses variable step size coefficients. Given a sequence of step sizes {(Subscript[h, n - k + 1]),  (Subscript[h, n - k + 2]),   ... ,  
        (Subscript[h, n])}, where (Subscript[h, n])is the current step to take, the coefficients for the method with Adams-Bashforth predictor of order k and Adams-Moulton corrector of order k + 1, (Subscript[g, j])(n)such that

where the (Subscript[Φ, j])(n)are the divided differences

This defines a function which computes the coefficients (Subscript[Φ, j])and (Subscript[β, j]), along with (Subscript[σ, j])which are used in error estimation. The formulas are from [HNW93] and use essentially the same notation.

AdamsBMCoefficients[hlist_List] := 
      Module[{k, h, Δh, brat, β, α, σ, 
          c}, 
k = Length[hlist]; 
h = 
          Last[hlist]; 
Δh = 
          Drop[FoldList[Plus, 0, Reverse[hlist]], 
            1]; 
brat = 
          Drop[Δh, -1]/(Drop[Δh, 1] - h); 
β = 
          FoldList[Times, 1, brat]; 
α = 
          (h/Δ)h; 
σ = 
          FoldList[Times, 
            1, α Range[Length[α]]]; 
c[0] = 
          Table[(1/q), {q, 1, k}]; 
c[1] = 
          Table[(1/(q (q + 1))), {q, 1, k}]; 
        Do[c[j] = 
            Drop[c[j - 
                    1], -1] - ((Drop[c[j - 1], 1] h)/Δ)h[[j]], {j, 2, 
            k}]; {(First[c[#1]] &) /@ 
            Range[0, k], β, σ}]

hlist is the list of step sizes {(Subscript[h, n - k]),  (Subscript[h, n - k + 
                1,]) …,  (Subscript[h, n])}from past steps. The constant coefficient Adams coefficients can be computed once, and much more easily. Since the constant step size Adams-Moulton coefficients are used in error prediction for changing the method order, it makes sense to define them once with rules that save the values.

This defines a function which computes and saves the values of the constant step size Adams-Moulton coefficients.

Moulton[0] = 1;
Moulton[
        m_] := Moulton[
          m] = -Sum[
            Moulton[k]/(1 + m - k), {k, 0, m - 1}]

The next stage is to set up a data structure which will keep the necessary information between steps and define how that data should be initialized. The key information which needs to be saved is the list of past step sizes, hlist, and the divided differences, Φ. Since the method will be doing error estimation, it needs to get the correct norm to use from the NDSolveStateData object. Some other data such as precision is saved for optimization and convenience.

This defines a rule for initializing the AdamsBM method from NDSolve.

\(\(AdamsBM /: 
NDSolve`InitializeMethod[AdamsBM, 
          Automatic, rhs_, ndstate_, opts___] := 
        Module[{prec, norm, hlist, Φ, mord}, 
          mord = MaxDifferenceOrder /. Flatten[{opts, 
                  Options[AdamsBM]}]; 
If[
            mord\  ≠ \ ∞\  && \ \(! \((IntegerQ[
                      mord]\  && \ mord\  > \ 0)\)\), 
            Return[$Failed]]; 
prec = 
            ndstate["WorkingPrecision"[]]; 
norm = 
            ndstate["Norm"[]]; 
hlist = {}; 
Φ = {ndstate["SolutionDerivativeVector"["Active"]]}; 
          AdamsBM[{{hlist, Φ, 
                N[0, prec]\ Φ[[1]]}, {norm, prec, mord, 0, True}}]];\)\)

hlist is initialized to {} since at initialization time there have been no steps. Φ is initialized to the derivative of the solution vector at the initial condition since the (0^th)divided difference is just the function value. Note that Φ is a matrix. The third element, which is initialized to a zero vector is used for determining the best order for the next step: it is effectively an additional divided difference. The use of the other parts of the data will be made clear in the definition of the stepping function.

The initialization is also set up to get the value of an option which can be used to limit the maximum order of the method which will be used. In the code DEABM, this is limited to 12, because this is a practical limit for machine precision calculations. However, in Mathematica, computations can be done in higher precision where higher order methods may of significant advantage, so there is no good reason for an absolute limit of this sort. Thus, we set the default of the option to be ∞.

This sets the default for the MaxDifferenceOrder option of the AdamsBM method.

Options[
          AdamsBM]  =  {MaxDifferenceOrder -> ∞};

Before proceeding to the more complicated Step method functions, it makes sense to define the simple Stepmode and DifferenceOrder method functions.

This defines the step mode for the AdamsBM method to always be Automatic. This means that it cannot be called from step controller methods which request fixed step sizes of possibly varying sizes.

AdamsBM[___]["Stepmode"[]] = Automatic;

This defines the difference order for the AdamsBM method. This varies with the number of past values saved.

AdamsBM[data_]["DifferenceOrder"[]] := 
        Length[data[[1, 2]]];

Finally, the definition of the Step function. The actual process of taking a step is only a few lines. The rest of the code handles the automatic step size and order selection following very closely the DEABM code of Shampine and Watts.

This defines the Step method function for AdamsBM which returns step data according to the templates described above.

\(\(\(AdamsBM[data_]\)["Step"[rhs_, t_, h_, y_, yp_]] := 
        Module[{prec, norm, hlist, Φ, Φ1, ns, 
            starting, k, zero, g, β, σ, p, f, Δy, 
            normh, ev, err, PE, knew, hnew, 
            temp}, {{hlist, Φ, Φ1}, {norm, prec, 
                mord, ns, starting}} = data; 
 (*\ 
            Norm\ scaling\ will\ be\ based\ on\ current\ solution\ \(\(y\)\(.\)\)\ *) normh = \((Abs[h]\ temp[#1, 
                      y] &)\) /. {temp -> 
                  norm}; 
k = 
            Length[Φ]; 
zero = 
            N[0, prec]; 
 (*\ 
            Keep\ track\ of\ number\ of\ steps\ at\ this\ stepsize\ \(\(h\)\(.\)\)\ *) 
If[
            Length[hlist] > 0 && Last[hlist] == h, \(ns++\), ns = 1]; 
          hlist = Join[
              hlist, {h}]; 
{g, β, σ} = 
            AdamsBMCoefficients[hlist]; 
 (*\ 
            Convert\ Φ\ to\ \(Φ\^*\)\ *) 
Φ = Φ\ Reverse[β]; 
 (*\ 
            PE : \ Predict\ and\ evaluate\ *) 
p = 
            Reverse[Drop[g, \(-1\)]] . Φ; 
f = 
            rhs[h + t, h\ p + y]; 
 (*\ 
            Update\ divided\ differences\ *) 
Φ = FoldList[Plus, 
              zero\ Φ1, Φ]; 
 (*\ 
            Compute\ scaled\ error\ estimate\ *) 
ev = 
            f - Last[Φ]; 
err = \((g[[\(-2\)]] - 
                  g[[\(-1\)]])\)\ normh[
                ev]; 
 (*\ 
            First\ order\ \(check : \ 
                determines\ if\ order\ should\ be\ lowered

              
              even\ in\ the\ case\ of\ a\ rejected\ step\)\ *) 
knew = 
            OrderCheck[PE, k, Φ, ev, 
              normh, σ]; 
If[
            err > 1, 
 (*\ 
              Rejected\ \(step : \ reduce\ h\ by\ half\), \ 
              make\ sure\ starting\ mode\ flag\ is\ unset\ and\ reset\ Φ\ to\ previous\ values\ *) 
hnew = 
              h\/2; Δy = $Failed; f = None; 
            starting = False; Φ = 
              data[[1, 
                2]], 
 (*\ 
              Sucessful\ \(step : 
\(CE : \ 
                    Correct\ and\ evaluate\)\)\ *) 
Δy = h\ \((p + ev\ Last[g])\); 
f = 
              rhs[h + t, y + Δy]; 
            temp = f - Last[Φ]; 
 (*\ 
              Update\ the\ divided\ differences\ *) 
Φ = \((temp + #1 &)\) /@ Φ; 
 (*\ 
              Determine\ best\ order\ and\ stepsize\ for\ the\ next\ step\ *) 
Φ1 = temp - Φ1; 
            knew = ChooseNextOrder[starting, PE, k, knew, Φ1, 
                normh, σ, mord, ns]; 
            hnew = ChooseNextStep[PE, knew, h]]; 
 (*\ 
            Truncate\ hlist\ and\ Φ\ to\ the\ appropriate\ length\ for\ the\ chosen\ \(\(order\)\(.\)\)\ *) 
hlist = 
            Take[hlist, 1 - knew]; 
If[
            Length[Φ] > 
              knew, Φ1 = Φ[[Length[Φ] - knew]]; Φ = 
              Take[Φ, \(-knew\)];]; 
 (*\ 
            Return\ step\ data\ along\ with\ updated\ method\ data\ *) 
{hnew, Δy, f, 
            AdamsBM[{{hlist, Φ, Φ1}, {norm, prec, 
                  mord, ns, starting}}]}];\)\)

There are a few deviations from DEABM in the code above. The most significant is that coefficients are recomputed at each step, whereas DEABM computes only those that need updating. This modification was made to keep the code simpler, but does incur a clear performance loss, particularly for small to moderately sized systems. A second significant modification is that much of the code for limiting rejected steps is left to NDSolve, so there are no checks in this code to see if the step size is too small or the tolerances are too large. The stiffness detection heuristic has also been left out. The order and step size determination code has been modularized into separate functions.

This defines a function which constructs error estimates (Subscript[PE, j]) for j == k-2, k-1, and k and determines if the order should be lowered or not.

OrderCheck[PE_, k_, Φ_, ev_, normh_, σ_] := 
        Module[{knew = k}, 
(Subscript[PE, k]) = 
            Abs[σ[[
                  k + 1]] Moulton[k] normh[ev]]; 
          If[k > 1, 
(Subscript[PE, k - 1]) = 
              Abs[σ[[k]] Moulton[
                    k - 1] normh[
                    ev + Φ[[2]]]]; 
If[
              k > 2, 
(Subscript[PE, k - 2]) = 
                Abs[σ[[
                      k - 1]] Moulton[k - 2] normh[
                      ev + Φ[[3]]]]; 
              If[Max[(Subscript[PE, k - 1]), (Subscript[PE, k - 2])] < (Subscript[PE, k]), 
                knew = k - 1]], 
If[
                (Subscript[PE, k - 1]) < ((Subscript[PE, k])/2), 
                knew = k - 
                    1];
]; 
knew
];

This defines a function which determines the best order to use after a successful step.

SetAttributes[ChooseNextOrder, HoldFirst];
ChooseNextOrder[starting_, PE_, k_, knw_, Φ1_, 
          normh_, σ_, mord_, ns_] := 
        Module[{knew = knw}, 
starting = 
            starting && knew ≥ k && 
              k < mord; 
If[
            starting, 
knew = k + 1; 
            (Subscript[PE, k + 1]) = 
              0, 
If[
                knew ≥ k && 
                  ns ≥ 
                    k + 1, 
(Subscript[PE, k + 1]) = 
                  Abs[Moulton[
                        k + 1] normh[Φ1]]; 
If[
                  k > 1, 
If[
                    (Subscript[PE, k - 1]) ≤ 
                      Min[(Subscript[PE, k]), (Subscript[PE, k + 1])], 
knew = 
                      k - 1, 
If[
                      (Subscript[PE, k + 1]) < (Subscript[PE, k]) && k < mord, 
                      knew = k + 
                          1]
], 
If[
                    (Subscript[PE, k + 1]) < ((Subscript[PE, k])/2), 
                    knew = k + 
                        1]
];
];
]; 
knew
];

This defines a function which determines the best step size to use after a successful step of size h.

ChooseNextStep[PE_, k_, h_] := 
If[
          (Subscript[PE, k]) < (2^(-(k + 2))), 
2 h, 
If[(Subscript[PE, k]) < (1/2), h, 
            h Max[(1/2), 
                Min[(9/10), ((((1/(2 (Subscript[PE, k])))))^((1/(k + 1))))]]]
];

Once these definitions are entered, you can access the method in NDSolve by simply using Method->AdamsBM.

This solves the harmonic oscillator equation with the Adams method defined above.

asol  =  
      NDSolve[{x''[t]  +  x[t]  ==  0,  
          x[0]  ==  1,  x'[0]  ==  0}, 
        x, {t, 0, 2 π},  Method -> AdamsBM]

This shows the error of the computed solution. It is apparent that the error is kept within reasonable bounds. Note that after the first few points, the step size has been increased.

ploterror[asol];

Where this method has the potential to outperform some of the built in methods is with high precision computations with strict tolerances. This is because the built-in methods are adapted from codes with the restriction to order 12.

LorenzEquations  =  {
{x'[
                t]  ==  -3 (x[t]  -  y[t]),  
            x[0]  ==  0}, {y'[t]  ==  -x[t] z[t]  +  
                53/2 x[t]  -  y[t], 
            y[0]  ==  1}, {z'[t]  ==  x[t] y[t]  -  z[t],  
            z[0]  ==  0}};
vars  =  {x[t], y[t], z[t]};

A lot of time in coefficient computation.

Timing[
      NDSolve[LorenzEquations,  vars,  {t, 0, 20},  
        Method -> AdamsBM]]

This is not using as high an order as I would expect.
In any case, about half the time is spent generating coefficients, so to make it better, we need to figure out coefficient update.

Timing[
      NDSolve[LorenzEquations,  vars,  {t, 0, 20},  
        Method -> AdamsBM,  WorkingPrecision -> 32]]