Sample Helmholtz solver

This is a sample solution to the Lab 18 problem of converting a Poisson solver into a Helmholtz solver.

From http://faculty.washington.edu/rjl/classes/am583s2014/notes/labs/lab18.html, R. J. LeVeque


In [ ]:
%pylab inline

In [ ]:
from scipy import sparse  # to define sparse matrices
from scipy.sparse.linalg import spsolve   # to solve sparse systems

Direct solver for the boundary value problem

The boundary value problem $u''(x) + k^2 u(x) = -f(x)$ is discretized to get the linear system of equations $U_{i-1} - 2U_i + U_{i+1} + k^2\Delta x^2 U_i = -\Delta x^2 f(x_i)$ for $i = 1, ~2,~\ldots,~n$. Recall that $U_0$ and $U_{n+1}$ are specified by the two boundary conditions required for this problem.

This function solves the BVP directly by setting up a tridiagonal matrix and using spsolve to efficiently solve the system.


In [ ]:
def solve_BVP_direct(f,x,u_left,u_right,k):
    """
    On input:
    f should be the function defining the ODE  u''(x) = f(x),
    x should be an array of equally spaced points where the 
        solution is to be computed,
    u_left should be the boundary condition at the left edge x[0],
    u_right should be the boundary condition at the right edge x[-1],
    
    Returns: 
    u, vector of approximate values to solution at the points x.
    """
    
    n = len(x) - 2  # number of interior points
    print "Solving tridiagonal system with n =%4i" % n
    dx = x[1]-x[0]
    
    # Form the matrix:
    d1 = ones(n)
    d0 = (k**2 * dx**2 - 2) * ones(n) 
    A = sparse.spdiags([d1,d0,d1], [-1,0,1],n,n,format='csc')
    
    # Form the right-hand side
    fx = f(x)
    rhs = -dx**2 * fx[1:-1] 
    rhs[0] = rhs[0] - u_left  
    rhs[-1] = rhs[-1] - u_right
    
    # Solve the system for the interior points:
    u_int = spsolve(A,rhs)   # vector of length n-2
    
    # Append the known boundary values:
    u = hstack((u_left, u_int, u_right)) 
    return u

Define a function to compare the computed solution to the true solution:


In [ ]:
def check_solution(x,u,u_true):
    # u should be a vector of approximate solution values at the points x
    # u_true should be a function defining the true solution.
    plot(x,u,'ro')
    plot(x,u_true(x),'b')
    error = u - u_true(x)
    error_max = abs(abs(error)).max()
    print "Maximum error is %10.3e" % error_max

To test this out, define the function $f(x)$ and the true solution, and set the boundary values.


In [ ]:
k = 15.
u_left = 2.
u_right = -1.
c1 = u_left
c2 = (u_right - c1*cos(k)) / sin(k)
print "c1 = %g, c2 = %g" % (c1,c2)
f = lambda x: 0. * x
u_true = lambda x: c1 * cos(k*x) + c2 * sin(k*x)


n = 50  # number of interior points
x = linspace(0, 1, n+2)
u = solve_BVP_direct(f,x,u_left,u_right,k)

check_solution(x,u,u_true)

In [ ]:
nvals = 10*array([10,20,40,80,160])
errors = zeros(nvals.shape)
for j,n in enumerate(nvals):
    x = linspace(0, 1, n+2)
    u = solve_BVP_direct(f,x,u_left,u_right,k)
    errors[j] = abs(u - u_true(x)).max()   # maximum abs error over interval

print "\n   n         error"
for j,n in enumerate(nvals):
    print "%4i  %15.9f" % (n,errors[j])
loglog(nvals,errors,'o-')

Divide and conquer approach to solving the BVP

Suppose we have 2 threads available and wish to parallelize the solution to the BVP. Solving a tridiagonal system by Gauss Elimination is inherently quite sequential, but we can try the following approach. Suppose we knew the value of the solution at the midpoint of the interval. Then we could solve 2 disjoint BVPs, one on the left half and one on the right half. In principle the two could be solved in parallel.

The problem is that we don't know the solution at the midpoint in general. But for this simple test problem we can try evaluating the exact solution at the midpoint to try this out...


In [ ]:
def solve_BVP_split(f,x,u_left,u_right,k):
    n2 = len(x)
    nmid = int(floor(n2/2.))
    xhalf1 = x[:nmid+1]
    xhalf2 = x[nmid:]
    u_mid = u_true(x[nmid])  # Assumes we know true solution!!
    print "u_mid = ",u_mid
    uhalf1 = solve_BVP_direct(f,xhalf1,u_left,u_mid,k)
    uhalf2 = solve_BVP_direct(f,xhalf2,u_mid,u_right,k)
    u = hstack((uhalf1, uhalf2[1:]))
    return u

In [ ]:
x = linspace(0, 1, 51)
u = solve_BVP_split(f,x,u_left,u_right,k)
check_solution(x,u,u_true)

Unfortunately, for this to work we need to know the solution in the middle. We do for this simple test problem but for a practical problem we would not. If you change the code defining solve_BVP_split above to set u_mid to some other arbitrary value, you will find that the solutions on each half do not match up smoothly and so the equation $u''(x) = -f(x)$ will not be satisfied at the midpoint.

Note that the finite difference equation at the midpoint is not being imposed. The equation $U_{i-1} - 2U_i + U_{i+1} = -\Delta x^2 f(x_i)$ is solved for $i=1,~2,~\ldots,nmid-1$ and for $i=nmid+1,~\ldots,~n$, but not at i = nmid.

This suggests an approach in which we guess the midpoint value, solve the two BVPs, and then determine the mismatch in the missing equation that we need to have satisfied at the midpoint. If this is non-zero then we can try to define a way to improve it, perhaps an iterative method, to drive the mismatch to zero.

First let's see how the mismatch varies as we change the guess for u_mid:


In [ ]:
def solve_BVP_split_mismatch(f,x,u_left,u_right,k):
    n2 = len(x)
    dx = x[1]-x[0]
    nmid = int(floor(n2/2.))
    xhalf1 = x[:nmid+1]
    xhalf2 = x[nmid:]
    x_mid = x[nmid]
    u_mid_guesses = linspace(0,2,5)
    mismatch = zeros(u_mid_guesses.shape)
    for j,u_mid in enumerate(u_mid_guesses):
        uhalf1 = solve_BVP_direct(f,xhalf1,u_left,u_mid,k)
        uhalf2 = solve_BVP_direct(f,xhalf2,u_mid,u_right,k)
        u = hstack((uhalf1, uhalf2[1:]))
        plot(x,u)
        mismatch[j] = (uhalf1[-2] + (k**2*dx**2 - 2.)*u_mid + uhalf2[1]) + dx**2 * f(x_mid)
        print "With u_mid = %g, the mismatch is %g" % (u_mid, mismatch[j])
    figure()
    plot(u_mid_guesses, mismatch,'o-')
    
x = linspace(0, 1, 40)
solve_BVP_split_mismatch(f,x,u_left,u_right,k)

The top plot above shows the computed solution for various guesses at u_mid. The bottom plot shows the mismatch as a function of the guess u_mid.

Note that the behavior is linear. (You might try to use linear algebra to convince yourself that it should be!)

This means we don't need an iterative method to find the right value of u_mid. If we compute the solution for any two distinct values of u_mid and call the solutions obtained v0 and v1 and the mismatches G0 and G1, then we can solve for a value z such that z*G0 + (1-z)*G1 = 0 and the solution we desire is z*v0 + (1-z)*v1 by linearity. Note also that if the original boundary conditions are satisfied by both v0 and v1 then the convex combination z*v0 + (1-z)*v1 also satisfies these boundary conditions (and the ODE).

This suggests the following code, which solves each problem twice and the produces the right linear combination.


In [ ]:
def solve_BVP_split(f,x,u_left,u_right,k):
    n2 = len(x)
    dx = x[1]-x[0]
    nmid = int(floor(n2/2.))
    xhalf1 = x[:nmid+1]
    xhalf2 = x[nmid:]
    x_mid = x[nmid]
    
    # solve the sub-problems twice with different values of u_mid
    # Note that any two distinct values can be used.
    
    u_mid = 0.
    uhalf1 = solve_BVP_direct(f,xhalf1,u_left,u_mid,k)
    uhalf2 = solve_BVP_direct(f,xhalf2,u_mid,u_right,k)
    G0 = (uhalf1[-2] + (k**2*dx**2 - 2.)*u_mid + uhalf2[1]) + dx**2 * f(x_mid)
    v0 = hstack((uhalf1, uhalf2[1:]))
    
    u_mid = 1.
    uhalf1 = solve_BVP_direct(f,xhalf1,u_left,u_mid,k)
    uhalf2 = solve_BVP_direct(f,xhalf2,u_mid,u_right,k)
    G1 = (uhalf1[-2] + (k**2*dx**2 - 2.)*u_mid + uhalf2[1]) + dx**2 * f(x_mid)
    v1 = hstack((uhalf1, uhalf2[1:]))

    z =  G1 / (G1 - G0)
    u = z*v0 + (1-z)*v1
    
    return u

In [ ]:
x = linspace(0, 1, 80)
u = solve_BVP_split(f,x,u_left,u_right,k)
print x.shape, u.shape
check_solution(x,u,u_true)

Recursive version

The code above requires 4 calls to solve_BVP_direct and the four are independent and could be done in parallel. Suppose you have more threads or processes available. How would you use them? You could split the original interval into more pieces and have additional points where you would need to choose appropriate boundary conditions for each piece by requiring that the resulting solutions also satisfy the difference equations at these boundary points. It turns out this would give a linear system of equations to solve for these interface boundary values.

Another approach is to use recursion to split the interval into more pieces. Rather than calling solve_BVP_direct four times (which sets up and solves a tridiagonal system), we could recursively use the same strategy for each piece. This suggests the following recursive version:


In [ ]:
def solve_BVP_split_recursive(f,x,u_left,u_right,k):
    n2 = len(x)
    print "Entering solve_BVP_split_recursive with n2 =%4i, for x from %5.3f to %5.3f" \
            % (n2, x[0], x[-1])
    if n2 < 20:
        # Stop recursing if the problem is small enough:
        u = solve_BVP_direct(f,x,u_left,u_right,k)
        return u
    else:
        # recursive
        nmid = int(floor(n2/2.))
        x_mid = x[nmid]
        xhalf1 = x[:nmid+1]
        xhalf2 = x[nmid:]
        dx = x[1]-x[0]
        u_mid = 0.
        uhalf1 = solve_BVP_split_recursive(f,xhalf1,u_left,u_mid,k)
        uhalf2 = solve_BVP_split_recursive(f,xhalf2,u_mid,u_right,k)
        G0 = (uhalf1[-2] + (k**2*dx**2 - 2.)*u_mid + uhalf2[1]) + dx**2 * f(x_mid)
        v0 = hstack((uhalf1, uhalf2[1:]))

        u_mid = 1.
        uhalf1 = solve_BVP_split_recursive(f,xhalf1,u_left,u_mid,k)
        uhalf2 = solve_BVP_split_recursive(f,xhalf2,u_mid,u_right,k)
        G1 = (uhalf1[-2] + (k**2*dx**2 - 2.)*u_mid + uhalf2[1]) + dx**2 * f(x_mid)
        v1 = hstack((uhalf1, uhalf2[1:]))

        z =  G1 / (G1 - G0)
        u = z*v0 + (1-z)*v1
    
    return u

In [ ]:
x = linspace(0, 1, 40)
u = solve_BVP_split_recursive(f,x,u_left,u_right,k)
check_solution(x,u,u_true)

In [ ]:


In [ ]: