Runge-Kutta

GWM Runge-Kutta methods of solving ODE Ordinary Differential Equations are numerical techniques to find the solution to an ODE. R-K methods are more stable and accurate than Euler Methods. The Tcllib package math::calculus has a R-K implementation; this page is about a simple code to show how RK works. A slightly more accurate solver is the Runge-Kutta-Fehlberg solver which has automatic integration step reduction and error estimation.

A number of steps are used to advance a parameter through time (or space, depending on the equation). For example:

d/dt(N)=-k.N

(rate of change of number proportional to the population).

Euler method would try:

 loop (until fed up) {
   N=N-k.N*dt
   time=time+dt
 }

Which deviates from the true solution very quickly since the population is changing during the time step; very short time steps will reduce the error but take a long time.

R-K methods use a numerical integration procedure which takes several (often 4) intermediate calculations to find an approximate integral of the equation. See reference 1 below for more details. The following code will solve d(N)/dt=-2*n with good accuracy.

 # tcl runge-kutta solution of ODES
 # single variable - use rk4step (included for simplicity)
 proc decay { time xn } { ;# simple decay proportional to amount (gives radioactive half life, eg)
  return [expr -2.0*$xn]
 }

 proc rk4step {x time dt dxdtproc } { ;# proc is such that dx/dt = dxdtproc(t)
  set k1 [$dxdtproc  $time  $x] ;# k1 = f (tn, xn)
  set k2 [$dxdtproc  [expr $time+($dt*0.5)] [expr $x + $dt*0.5*$k1]] ;# k2 = f (tn + h?2, xn + h?2 k1)
  set k3  [$dxdtproc [expr $time +$dt*0.5] [expr $x + $dt*0.5*$k2] ] ;# k3= f (tn + h?2, xn + h?2 k2)
  set k4  [$dxdtproc [expr $time +$dt] [expr $x + $dt*$k3] ] ;# k4= f (tn + h, xn + h k3) 
  return  [expr $x + $dt/6.0 *($k1 + 2*$k2 + 2*$k3 + $k4)] ;# h/6(simpsons rule)
 }

 proc test {} {
   set y 0
   set mass 1
   set dt 0.1 ;# try shortening dt to see numerical inaccuracy
 # use single equation R_K scheme - log decay of radioactivity eg (dN/dt=-n-->> N(t)=exp(-t)
   for {set time 0} {[expr $time<=2] } { set time [expr $time+$dt]} {
    puts "At time $time $mass"
    set mass [rk4step $mass $time $dt decay] ;# dmass/dt = -mass - mass=m0(1-exp(time/k))
   }
 }

 test

at time =0.5 decay should be exp(-1)=0.367879441171 the solver gives 0.367885238126 at time =1.0 decay should be exp(-2)=0.135335283237 the solver gives 0.135339548431


If you have more complex ODEs such as the damped harmonic oscillator (of order 2, ie uses d2N/dt2):

y" -k.y' + om.y=0

you should break this into 2 equations using v=y':

 v' = k.v - om.y
 y' = v

and solve as suggested in reference 1 below. I have added a simple plotting algorithm (copied and simplified from A little graph plotter) to show the solution of this equation for a range of damping coefficients 'k'.

 # tcl runge-kutta solution of ODES
 # N variables - use rk4step_array. Can be used for higher order ODEs
 # A simple plotting algorithm on a canvas is added.
 set k -0.25
 set omega [expr 2*3.14159]

 # plotted example - damped oscillator;  dv/dt = -k.v -omega.y & dx/dt=v
 proc dxdt { time dt xn} { ;# xn  is array of current variables; dx/dt is first element = speed
        upvar 1 $xn x  ;# array of values
        return $x(0)
 }

 proc oscdvdt { time dt xn} { ;
        upvar 1 $xn x  ;# array of values
        global k omega ;# allows editing, eg to see negatively damped oscilation type "set k 0.2; plot"
        return [expr $k*$x(0)-$x(1)*$omega ] ;# if omega = 2Pi takes 1 sec to oscillate once (Slightly longer if highly damped!)
 }

 proc rk4step_array {vars time dt dxdts } { ;# proc is such that dx/dt = dxdtproc(t,x)
 # for each member of the array 'vars' there is function dxdtproc(i)
 # such that d/dt(vars(i) = dxdtproc(i)
 # dxdtproc(i) has args time, step, array of values
  upvar 1 $vars x  ;# upvar 1 means: use the variable in the calling routine - called $vars with local name x
  upvar 1 $dxdts dxdtproc ;# list of functions for the rhs of equations
  set neqs [array size dxdtproc] ;# number of equations being solved

  for {set i 0} {$i<$neqs} { incr i} {
      set k1($i) [ $dxdtproc($i)  $time $dt x] ;# k1 = f (tn,   xn,   yn)
      set xnu($i) [expr $x($i) + 0.5*$dt*$k1($i) ] ;# next values to evaluate df/dx
  }

 for {set i 0} {$i<$neqs} { incr i} {
         set k2($i) [$dxdtproc($i)   $time $dt xnu]
        set xnu2($i) [expr $x($i) + 0.5*$dt*$k2($i) ] ;# next values
 }

  for {set i 0} {$i<$neqs} { incr i} {
         set k3($i) [$dxdtproc($i) $time $dt xnu2]
        set xnu3($i) [expr $x($i) + $dt*$k3($i) ] ;# next values
  }

  for {set i 0} {$i<$neqs} { incr i} {
           set k4($i) [$dxdtproc($i) $time $dt xnu3]
 }
  for {set i 0} {$i<$neqs} { incr i} {
        set dv [expr  $k1($i) + 2*($k2($i) + $k3($i)) + $k4($i) ]
         set x($i)  [expr $x($i) + $dt*0.1666667 * $dv]
  }
 }

 proc plot {} { ;# uses the simple graph plotter found on https://wiki.tcl-lang.org/8552
  # --------------------------
  #   params
  # --------------------------
  # title
  # canvas width & height
  # delay between plots
  # x = f(t)
  # initial & final times
  array set params \
  {
    title     {Damped Oscillator}
    width     400
    height    400
    delay     0
    x         {$t / 50.}
    t0        0
    t1        400
  }

  # --------------------------
  #   plotting
  # --------------------------
  # computed heights used to scale vertical units
  set h $params(height)
  set h1 [expr {int($h * 0.5)}]  ;# canvas mid-height
  set h2 [expr {$h1 + 1}]
  set h3 [expr {int($h * 0.4)}]  ;# graph mid-height
  # canvas
 if [winfo exists .c] { ;# delete it. Same as clear.
  destroy .c
 }
  canvas .c -width $params(width) -height $h \
            -xscrollincrement 1 -bg beige
  pack .c
  # plotting
  wm title . $params(title)
  set t $params(t0)

 # GWM start up the oscillator solver
  array set fns [list 0 oscdvdt 1 dxdt] ;# dv/dt and dx/dt equations to be solved in 'parallel'.
  array set vars [list 0 0 1 0.5] ;# initial values of time and X

     set tspace [expr $params(t1)/8.0] ;# how often to draw grid
     set nexttic $tspace
      .c create text 20 10 -anchor n -text "Velocity" -fill red
      .c create text 20 25 -anchor n -text "Position" -fill black

  while {$t != $params(t1)} \
  {
    update
    set time [expr $t/30.0]

    rk4step_array vars $time 0.02 fns ;# integrate the second order ODE

    set x [expr $params(x)]
    set vv $vars(1)
    set v [expr {int($vv * $h3) + $h1}]
    if {$t >= $nexttic} {
      set nexttic [expr $nexttic+$tspace]
      .c create text $t 0 -anchor n -text $t -fill gray
      .c create line $t 0 $t $h -fill gray
    }
    .c create line $t $h1 $t $h2 -fill gray
    .c create rectangle $t $v $t $v
    set vv $vars(0)
    set v [expr {int($vv * $h3) + $h1}]
    .c create rectangle $t $v $t $v -outline red
    incr t
  }
 }

 for {set k 0 } {$k>-1.0} {set k [expr $k-0.1]} {
    after 120 ;# wait for a moment....
    plot
 }

If you have copied and pasted this code into Wish, when the automatic runs are ended use commands such as "set k -0.44;plot" to draw with damping term 0.44; "set k 0.2;plot" to show a growing sinusoid; "set omega 12;plot" to use a higher oscillation frequency.

The method (and the code) works for N variables - you could have 2 linked equations such as

 y" -k.y' + om.y + a.x=0
 x" -k.x' + om2.x + b.y=0

yielding

 u' = k.u - om2.x +b.y
 x' = u
 v' = k.v - om.y + a.x
 y' = v

Creating functions to evaluate u', v' and extending the array of variables to store x and u will solve the linked equations numerically.


You might prefer the plotting in Bernoulli using math::calculus.


This reference is simple & easy to follow: [L1 ] (and useful).

The pdf reference is very comprehensive [L2 ] including adaptive stepsize control and higher order methods for error limitation.

Recommended exercise: see the Runge-Kutta-Fehlberg page for a better solver. Cash-Karp automatic accuracy methods could also be implemented (see [L3 ].