Posts Numerov Algorithm
Post
Cancel

Numerov Algorithm

Numerov’s method

Numerov’s method1 (also called Cowell’s method) is a numerical method to solve ordinary differential equations of second order in which the first-order term does not appear. It is a fourth-order linear multistep method. The method is implicit, but can be made explicit if the differential equation is linear. Numerov’s method was developed by the Russian astronomer Boris Vasil’evich Numerov.

Given the differential equation of this form

(1)d2ydx2=g(x)y(x)+s(x);x[a,b]

where g(x) and s(x) are continuous function on the domain [a,b]. To derive the Numerov’s method, we first discretize the interval [a,b] using equally spaced grid, where the grid space is h=xn+1xn. We then proceed with the Taylor expansion of the function y(x) around the point xn:

y(xn+1)=y(xn)+y(xn)h+y(xn)2!h2+y(xn)3!h3(2)+y(xn)4!h4+y′′′′′(xn)5!h5+y′′′′′′(xn)6!h6+

Similarly, we have

y(xn1)=y(xn)y(xn)h+y(xn)2!h2y(xn)3!h3(3)+y(xn)4!h4y′′′′′(xn)5!h5+y′′′′′′(xn)6!h6+

By summing the two equations, we have

(4)yn+12yn+yn1=h2yn+h412yn+O(h6)

To get an expression for yn, remember that yn=gnyn+snzn. By repeating the same procedure we did above

h2yn=zn+12zn+zn1+O(h4)=gn+1yn+1+sn+1=+2gnyn2sn(5)=gn1yn1+sn1+O(h4)

If we substitute this into the proceeding equation, we get

yn+12yn+yn1=h2(gnyn+sn)(6)=+h212(gn+1yn+1+sn+1+2gnyn2sngn1yn1+sn1)+O(h6)

or equivalently

(7)yn+1(1+h212gn+1)2yn(15h212gn)+yn1(1+h212gn1)=h212(sn+1+10sn+sn1)+O(h6)

This yields the Numerov’s method if we ignore the term of order h6.

yn+1=2yn(15h212gn)yn1(1+h212gn1)+h212(sn+1+10sn+sn1)1+h212gn+1(8)=(1210fn)ynyn1fn1+h212(sn+1+10sn+sn1)fn+1

where fn=1+h212gn. It is easy to show that

(9)yn1=(1210fn)ynyn+1fn+1+h212(sn+1+10sn+sn1)fn1

With this relation, we can now find the value of y(xn+1) given two previous values y(xn) and y(xn1). All we have to do to find an approximate solution is to find the values y(x1) and y(x2) at the first two points x1 and x2, then we use the Numerov’s method to find the value of all following points in the interval [a,b].

Case 1. Hydrogen-like atom

The Schrödinger equation for hydrogen-like atom is 2

(10)[22m2Ze24πε0r]ψ(r)=Eψ(r)

Here, we assume that there is only one electron and thus neglecting the Coulomb interaction between the electrons. By variable separation, the wavefunction can be written as ψ(r)=u(r)rYlm(r^), where Ylm(r^) is the spherical harmonics and u(r) follows the equation

(11)22mu+[22ml(l+1)r2Ze24πε0rE]u(r)=0

Introducing the dimensionless variables

(12)x=ra0;a0=4πε02me20.529(13)ϵ=EE0;E0=22ma02=1Ry13.60eV

where a0 is the Bohr radius and E0 is the Rydberg energy.

The equation for u(r) then becomes

(14)u(x)[l(l+1)x22Zxϵ]u(x)=0

The boundary conditions are

(15)u(0)=0;u()=0

1. From known energy eigenvalue to the wavefunctions

As is well known that the energy levels of hydrogen-like atoms are

(16)En=Z2n2;n=1,2,3,

Let’s first show that given the correct energy levels, we can obtain the corresponding wavefunctions.

CLICK TO SHOW THE CODE!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#!/usr/bin/env python

import numpy as np
from scipy.integrate import odeint, simps

def radial_schrod_deriv(u, r, l, E, Z=1):
    '''
    The radial Schrödinger equation for hydrogen-like atom is

        u''(x) - [(l(l+1)/x^2) - (2Z/x) - E]u(x) = 0 

    Since it is a second-order equation, i.e. 
    
        y'' + g(x)y(x) = 0

    To use scipy.integrate.odeint, we can turn this equation into two
    first-order equation by defining a new dependent variables
    
        y'(x) = z(x)
        z'(x) = -g(x)y(x)

    Then we can solve this system of ODEs using "odeint" wit list. 
    '''
    
    y, z = u

    return np.array([
        z, 
        ((l*(l+1) / r**2) - (2*Z / r) - E) * y
    ])


def radial_wfc_scipy(r0, n=1, l=0, Z=1, direction='F', du=0.1):
    '''
    Get the radial wavefunction by integrating the equation with
    scipy.integrate.odeint.
    '''

    assert direction.upper() in ['F', 'B']

    E = -Z**2 / n**2

    # forward integration 
    if direction.upper() == 'F':
        ur = odeint(radial_schrod_deriv, [0.0, du], r0, args=(l, E, Z))[:,0]

    # back integration 
    else:
        ur = odeint(radial_schrod_deriv, [0.0, -du], r0[::-1], args=(l, E, Z))[:,0][::-1]

    # normalization
    ur /= np.sqrt(simps(ur**2, x=r0))

    return ur


def radial_wfc_numerov(r0, n=1, l=0, Z=1, du=0.001):
    '''
    Numerov algorithm
    
                  [12 - 10f(n)]*y(n) - y(n-1)*f(n-1)
        y(n+1) = ------------------------------------
                               f(n+1)

    where
        
        f(n) = 1 + (h**2 / 12)*g(n)

        g(n) = [E + (2*Z / x) - l*(l+1) / x**2]
    '''

    ur = np.zeros(r0.size)
    fn = np.zeros(r0.size)

    E      = -Z**2 / n**2
    ur[-1] = 0.0
    ur[-2] = du

    dr  = r0[1] - r0[0]
    h12 = dr**2 / 12.

    gn = (E + 2*Z / r0 - l*(l+1) / r0**2)
    fn = 1. + h12 * gn

    for ii in range(r0.size - 3, -1, -1):
        ur[ii] = (12 - 10*fn[ii+1]) * ur[ii+1] - \
                 ur[ii+2] * fn[ii+2]
        ur[ii] /= fn[ii]

    # normalization
    ur /= np.sqrt(simps(ur**2, x=r0))

    return ur

In the code above, we have defined two integration method to integrate the radial Schrödinger equation

  • radial_wfc_scipy uses odeint implemented in scipy.3

  • radial_wfc_numerov uses the Numerov’s method.

  • We implemented forward/backward integration scheme in radial_wfc_scipy function, where forward integration means integrating from u(0) and backward integraton starts from u().

  • Of course we can not integrate from r=, we used a large enough cutoff rc and assumes u(rc)=0.

CLICK TO SHOW THE CODE!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
################################################################################
# Define the radial grid
r0 = np.linspace(1E-10, 20, 500)

Z = 1
l = 0
n = 1

# forward integration from u(0)
ur1 = radial_wfc_scipy(r0, n, l, direction='F')
# backward integration from u(\infty)
ur2 = radial_wfc_scipy(r0, n, l, direction='B')
# backward integration from u(\infty) with Numerov method
ur3 = radial_wfc_numerov(r0, n, l)

################################################################################
import matplotlib as mpl
import matplotlib.pyplot as plt
plt.style.use('ggplot')

fig = plt.figure(
    figsize=(7.2, 3.0),
    dpi=300
)
axes = [plt.subplot(1,2,ii+1) for ii in range(2)]

################################################################################
ax = axes[0]

ax.plot(
    r0, ur1,
    ls='none',
    ms=4, marker='o', mfc='white', mew=1.0, 
    zorder=1,
    label=r'Forward integration',
)

ax.plot(
    r0, ur2,
    ls='none',
    ms=4, marker='o', mfc='white', mew=1.0, 
    zorder=1,
    label=r'Backward integration',
)
ax.plot(r0, 2*r0*np.exp(-r0), lw=1.0, color='cyan', zorder=2, label=r'Exact: $2r\cdot e^{-r}$')

ax.set_xlabel(r'$r$ [$a_0$]',   labelpad=5)
ax.set_ylabel(r'$u(r)$ [a.u.]', labelpad=5)

ylim = list(ax.get_ylim())
ylim[1] = 1.0
ax.set_ylim(ylim)
ax.legend(loc='best', fontsize='small')

#################################################################################
ax = axes[1]

ax.semilogy(
    r0, 2*r0*np.exp(-r0) - ur2,
    ls='-', alpha=0.8,
    zorder=1,
    label=r'scipy.integrate.odeint',
)

ax.semilogy(
    r0, 2*r0*np.exp(-r0) - ur3,
    ls=':', alpha=0.8,
    zorder=1,
    label=r'Numerov',
)
ax.legend(loc='lower right', fontsize='small')

ax.set_xlabel(r'$r$ [$a_0$]',   labelpad=5)
ax.set_ylabel(r'Error', labelpad=5)

plt.tight_layout()
plt.savefig('fig1.png')
plt.show()
Figure 1. Left: forward and backward integration of the radial Schrödinger using scipy.integrate.odeint. The solid cyan line shows the exact solution. Right: the difference between the exact solution and the numerical results. Two different integration schemes are used, i.e. odeint in scipy and Numerov method.

We then try to get the radial wavefunction of hydrogen (Z=1) with quantum number n=1,l=0. Some remarks here:

  • It turns out that the forward integration of the equation is not stable, as can be seen in the left panel of Figure 1.

  • It is better to use logarithmic mesh for radial variable rather than linear. Radial functions need smaller number of points in logarithmic mesh. For example, use np.logspace(1E-6, 2, 500) to generate the radial grid and pass to the radial_wfc_scipy function. Note that Numerov’s method only support linear mesh, as can be seen in the method section.

One can show that the Numerov’s method also works fine for higher excited states. For example, below we compare the radial wavefunction unl(r) for n=1,2,3 obtained from Numerov method with the exact solutions. The script can be found here.

Figure 2. The lowest few radial wavefunctions unl(r) with n=1,2,3.

2. Unknown energy levels

If we don NOT know the correct energy levels, we can utilize the boundary condition to get the energy. Recall that the boundary condition is

(17)u(0)=0;u(rc)=0

where rc is large enough so that u(rc)=0. If E0 corresponds to the correct energy level, when we integrate the radial wavefunction u(r) from rc backward using Numerov’s method, we should get u(0)=0. A deviation of the energy from E0 will result in u(0)0. Therefore we can use the so called “shooting” method to get the correct energy. The basic procedure is

  1. Start wite a guess energy, e.g. E1. In practice, the guess energy should be smaller than the smallest potential energy. In our case, E1 should be smaller than Z2. With the guess energy E1, integrate the equation and get the value of the function at r=0, which we will denote as u1. Meanwhile, Set another energy E2=E1.

  2. Increase E2 by an amount δE and get a new energy E2=E2+δE. Integrate the equation to get the value at r=0, which we will denote as u2.

  3. Go back to step 2 if u1u2>0.

  4. At this step, we should have the correct energy enclosed in [E1,E2]. Use root finding method, e.g. scipy.optimize.brentq to get the correct energy.

CLICK TO SHOW THE CODE!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#!/usr/bin/env python

import numpy as np
from scipy.integrate import odeint, simps
from scipy.optimize import brentq


def radial_wfc_at0(E, r0, n=1, l=0, Z=1, du=0.01):
    '''
    Numerov algorithm
    
                  [12 - 10f(n)]*y(n) - y(n-1)*f(n-1)
        y(n+1) = ------------------------------------
                               f(n+1)

    where
        
        f(n) = 1 + (h**2 / 12)*g(n)

        g(n) = [E + (2*Z / x) - l*(l+1) / x**2]

    here, we use reverse integration from the other end.
    '''
    ur = np.zeros_like(r0)
    ur[-1] = 0.0
    ur[-2] = du

    dr  = r0[1] - r0[0]
    h12 = dr**2 / 12.
    gn  = E + 2*Z / r0 - l*(l+1) / r0**2
    fn  = 1. + h12 * gn

    for ii in range(gn.size - 3, -1, -1):
        ur[ii] = (12 - 10*fn[ii+1]) * ur[ii+1] - \
                 ur[ii+2] * fn[ii+2]
        ur[ii] /= fn[ii]

    # normalization
    ur /= np.sqrt(simps(ur**2, x=r0))

    # now extrapolate the wavefunction to u(0)
    # the first derivative equals at ur[0]
    # (u0 - ur[0]) / (0 - r0[0]) = (ur[1] - ur[0]) / (r0[1] - r0[0])
    u0 = ur[0] + (ur[1] - ur[0]) * (0 - r0[0]) / dr

    return u0


if __name__ == "__main__":
    r0 = np.linspace(1E-6, 30, 3000)

    E_lower = E_upper = -1.1
    dE      = 0.10
    u1      = radial_wfc_at0(E_lower, r0, n=1)

    while True:
        E_upper += dE
        u2 = radial_wfc_at0(E_upper, r0, n=1)
        if u1 * u2 < 0:
            break

    E = brentq(radial_wfc_at0, E_lower, E_upper, args=(r0, 1, 0, 1, 0.001)) 
    print(E)

References

This post is licensed under CC BY 4.0 by the author.