$\textbf{Alternating-Direction-Implicit Method}$

For my final project for AMATH 581, I will be implementing the alternating-direction-implicit method (ADI). The method is outlined here.

This method can be used for any elliptic PDE. For this project, I will consider the Laplace Equation

$$u_{xx} + u_{yy} = 0$$

Using 2nd order difference scheme for both $u_{xx}$ and $u_{yy}$, choosing $\Delta x = \Delta y = h$, and $N$ points in each direction, we have the following:

$$u(x - h,y) + u(x + h,y) + u(x, y - h) + u(x, y + h) - 4u(x,y) =0$$

I discretize the grid so that $x = 0, h, 2h,...$ correspond to $x_i$ and $y = 0, h, 2h,...$ correspond to $y_j$, and $u(x_i, y_j) = u_{i,j}$:

$1)$ \begin{equation} u_{i - 1,j} + u_{i+1,j} + u_{i, j-1} + u_{i, j+1} - 4u_{i,j} = 0 \end{equation}

If all the terms with index $j$ on the left hand side, we have:

$2)$ $$u_{i - 1,j} - 4u_{i,j} + u_{i+1,j} = -u_{i, j-1} -u_{i, j+1}$$

Likewise, rearranging $1)$ so the terms with index $i$ are on the left hand side:

$3)$ $$u_{i,j-1} - 4u_{i,j} + u_{i,j+1} = -u_{i-1, j} -u_{i+1, j}$$

$\textbf{Algorithm:}$

Every iteration $m$ of the ADI method has two stages. In the first stage, we will use equation $2)$ to update the grid, and for the second stage, we will use equation $3)$ to update the grid. The first update using equation $2)$ is:

$4)$ $$u_{i - 1,j}^{m+1/2} - 4u_{i,j}^{m+1/2} + u_{i+1,j}^{m+1/2} = -u_{i, j-1}^{m} -u_{i, j+1}^{m}$$

Here, the superscipts represent the iteration of the solver. For the first stage, represented by the $m+1/2$ superscript, we set up a system of equations using $4)$ for each of the interior points along the line horizontal line $y = y_j$ and solve for $u_{1,j}^{m+1/2}, u_{2,j}^{m+1/2},..., u_{N - 1,j}^{m+1/2}$. We do this for each $y_j$.

We then compute the second stage, represented by the $m+1$ superscript, with equation $3)$:

$5)$ $$u_{i,j-1}^{m+1} - 4u_{i,j}^{m+1} + u_{i,j+1}^{m+1} = -u_{i-1, j}^{m+1/2} -u_{i+1, j}^{m+1/2}$$

Now we use the equation above for each point along the horizontal line of $x = x_i$ and solve for $u_{i,1}^{m+1},u_{i,2}^{m+1},...,u_{i,N-1}^{m+1}$. We do this for each $x_i$.

Every time we complete stage one or two, we solve a relatively small $N \space \text{by} N$ system of equations $A \vec{x} = \vec {b}$, where the matrix $A$ has the following form:

$$A = \begin{bmatrix} -4 & 1\\ 1 & -4 & 1\\\ & 1& -4 & 1\\ & & & ... \end{bmatrix} $$

Although we solve this small $N$ by $N$ $2N$ system $2N$ times per iteration, this method can be advantageous much more advantageous than solving for all points simultaneously, which would involve solving a banded system that is $N^2$ by $N^2$. For example, problems which have a very high number of grid points will be unfeasible to solve directly and instead solving many smaller tridiagonal systems can be much faster. Comparing ADI to other iterative methods, like Jacobi iteration or Gauss Seidel, which iteratively solve for each point in the grid, ADI solves iteratively solves for each line of points. In this way, ADI lies somewhere between a iterative solver and a direct solver.

$\textbf{Example Problem}$

This example was taking from the following paper: https://iopscience.iop.org/article/10.1088/2399-6528/abbd76/pdf

Consider the following boundary value problem:

$$\frac{\partial^2 u}{\partial x^2} + \frac{\partial^2 u}{\partial y^2} = 0$$$$0 < x, y < 4$$$$u(x,0) = -\frac{1}{2}\sin \left( \frac{1}{4}\pi x \right), \space u(0,y) = \frac{1}{2} \sin \left( \frac{1}{2} \pi y \right), \space u(x,4) = \frac{1}{2}\sin\left( \frac{1}{4}\pi x \right), \space u(4,y) = -0.3 \sin \left( \frac{3}{4} \pi y\right)$$

In this report, I will implement the ADI method using an intitial guess of u(x,y) = 0 and compare the ADI method to the Gauss-Seidel iterative method and to the Direct method of simultaneously at every point in the grid.

Below, are my implementations of each method to solve this problem. The direct solver is implemented in two ways: one makes use of a sparse matrix solver $\texttt{scipy.sparse.linalg.spsolve}$, and the other way uses the basic matrix solver $\texttt{scipy.linalg.solve}$. The ADI method makes use of $\texttt{scipy.linalg.solve_banded}$ for the tridiagonal matrix solves. The Gauss-Seidel simply solves for $u(i,j)$ in equation $1)$ point by point throughout the grid.

In [2]:
#important functions

#Boundary conditions
def bot(x):
    return -0.5*np.sin(0.25*np.pi*x)

def top(x):
    return 0.5*np.sin(0.25*np.pi*x)

def left(y):
    return 0.5*np.sin(0.5*np.pi*y)

def right(y):
    return -0.3*np.sin(0.75*np.pi*y)

def f(x,y):
    return 0
In [3]:
#Direct Solver with the Finite difference
import numpy as np
import matplotlib.pyplot as plt
import scipy

def solve_prob_FD_sparse(dx):
    pi = np.pi

    x0 = 0
    xN = 4
    y0 = 0
    yN = 4

    #dx = 1
    dy = dx

    x = np.arange(x0, xN + dx/2, dx)
    y = np.arange(y0, yN + dy/2, dy)

    Nx = len(x)
    Ny = len(y)

    N_total = (Nx - 2) * (Ny - 2)
    
    
    def point2ind(m, n):
        return (n - 1) * (Nx - 2) + m - 1


    A = scipy.sparse.dok_array((N_total, N_total))# make a sparse matrix instead
    b = np.zeros((N_total, 1))

    #filling matrix A and b, b needs subtract the boundaries if u(m,n) is next to a boundary and include the right side of the pde
    #u_(m,n)
    for n in range(1, Ny-1): #loop for n and m is slightly different because we got rid of boundary points
        for m in range(1, Nx-1): 
            k = point2ind(m, n)
            A[k, k] = -2 / dx ** 2 - 2/dy**2
            if m > 1:# good
                A[k, k - 1] = 1 / dx ** 2
            if n < Ny - 2: #good
                A[k, k + Nx - 2] = 1 / dy ** 2
            if m < Nx - 2:
                A[k, k + 1] = 1 / dx ** 2
            if n > 1:
                A[k, k - (Nx - 2)] = 1 / dy ** 2
            if n == 1:
                b[k] = b[k] - bot(x[m]) / dy ** 2
            if n == Ny-2:
                b[k] = b[k] - top(x[m]) / dy ** 2
            if m == 1:
                b[k] = b[k] - left(y[n]) / dx ** 2
            if m == Nx-2:
                b[k] = b[k] - right(y[n]) / dx ** 2

            b[k] = b[k] + f(x[m], y[n])

    # Convert A to CSR format for efficient solving
    A = A.tocsr()
    u = scipy.sparse.linalg.spsolve(A, b)

    U_int = u.reshape((Ny-2, Nx-2))
    U = np.zeros((Ny, Nx))
    U[1:(Ny-1), 1:(Nx-1)] = U_int
    U[0, :] = bot(x) #lower boundary
    U[-1,:] = top(x)
    U[:,0] = left(y)
    U[:,-1] = right(y)

    return U
In [4]:
#Direct Solver with the Finite difference (Gaussian Elim)
import numpy as np
import matplotlib.pyplot as plt
import scipy

def solve_prob_FD_slow(dx):
    pi = np.pi

    x0 = 0
    xN = 4
    y0 = 0
    yN = 4

    #dx = 1
    dy = dx

    x = np.arange(x0, xN + dx/2, dx)
    y = np.arange(y0, yN + dy/2, dy)

    Nx = len(x)
    Ny = len(y)

    N_total = (Nx - 2) * (Ny - 2)
    
    
    def point2ind(m, n):
        return (n - 1) * (Nx - 2) + m - 1


    A = np.zeros((N_total, N_total))
    b = np.zeros((N_total, 1))

    #filling matrix A and b, b needs subtract the boundaries if u(m,n) is next to a boundary and include the right side of the pde
    #u_(m,n)
    for n in range(1, Ny-1): #loop for n and m is slightly different because we got rid of boundary points
        for m in range(1, Nx-1): 
            k = point2ind(m, n)
            A[k, k] = -2 / dx ** 2 - 2/dy**2
            if m > 1:# good
                A[k, k - 1] = 1 / dx ** 2
            if n < Ny - 2: #good
                A[k, k + Nx - 2] = 1 / dy ** 2
            if m < Nx - 2:
                A[k, k + 1] = 1 / dx ** 2
            if n > 1:
                A[k, k - (Nx - 2)] = 1 / dy ** 2
            if n == 1:
                b[k] = b[k] - bot(x[m]) / dy ** 2
            if n == Ny-2:
                b[k] = b[k] - top(x[m]) / dy ** 2
            if m == 1:
                b[k] = b[k] - left(y[n]) / dx ** 2
            if m == Nx-2:
                b[k] = b[k] - right(y[n]) / dx ** 2

            b[k] = b[k] + f(x[m], y[n])

    # Convert A to CSR format for efficient solving
    u = scipy.linalg.solve(A, b)

    U_int = u.reshape((Ny-2, Nx-2))
    U = np.zeros((Ny, Nx))
    U[1:(Ny-1), 1:(Nx-1)] = U_int
    U[0, :] = bot(x) #lower boundary
    U[-1,:] = top(x)
    U[:,0] = left(y)
    U[:,-1] = right(y)

    return U
In [5]:
import numpy as np
import matplotlib.pyplot as plt
from scipy.linalg import solve_banded
def solve_prob_ADI(U_FD, dx, tol):
    pi = np.pi
    
    error_scale = np.max(U_FD)

    xi = 0
    xf = 4
    x = np.arange(xi, xf + dx/2, dx)

    yi = 0
    yf = 4
    dy = dx
    y = np.arange(yi, yf + dy/2, dy)

    N = len(x)

    U = np.zeros((N, N))
    U[:, -1] = top(x)
    U[:,0] = bot(x)
    U[-1,:] = left(y)
    U[0,:] = right(y)
    
    U_FD = np.fliplr(U_FD).T

    errors = []
    cur_error = 100

    # Banded matrix A for the tridiagonal system
    A_banded = np.zeros((3, N-2))
    A_banded[0, 1:] = 1
    A_banded[1, :] = -4
    A_banded[2, :-1] = 1

    # Precompute fixed boundary conditions
    top_boundary = U[:, N-1]
    bottom_boundary = U[:, 0]
    left_boundary = U[0, :]
    right_boundary = U[N-1, :]

    while cur_error > tol:
        # Compute m + 0.5 step
        for j in range(1, N-1):
            RHS = -U[1:N-1, j-1] - U[1:N-1, j+1]
            RHS[0] -= left_boundary[j]
            RHS[-1] -= right_boundary[j]

            U[1:N-1, j] = solve_banded((1, 1), A_banded, RHS)

        # Compute m + 1 step
        for i in range(1, N-1):
            RHS = -U[i-1, 1:N-1] - U[i+1, 1:N-1]
            RHS[0] -= bottom_boundary[i]
            RHS[-1] -= top_boundary[i]

            U[i, 1:N-1] = solve_banded((1, 1), A_banded, RHS)

        max_error = np.max(np.abs(U - U_FD)) / error_scale
        errors.append(max_error)
        cur_error = max_error
        
    
    U = np.fliplr(U.T)

    return U, np.array(errors)
In [6]:
def solve_prob_Gauss_Seidel(U_FD, dx, tol):
    U_FD = np.fliplr(U_FD).T
    
    pi = np.pi
    
    error_scale = np.max(U_FD)

    xi = 0
    xf = 4
    x = np.arange(xi, xf + dx/2, dx)

    yi = 0
    yf = 4
    dy = dx
    y = np.arange(yi, yf + dy/2, dy)

    N = len(x)

    U = np.zeros((N, N))
    U[:, -1] = top(x)
    U[:,0] = bot(x)
    U[-1,:] = left(x)
    U[0,:] = right(x)


    errors = []
    cur_error = 100

    # Precompute fixed boundary conditions
    top_boundary = U[0, 1:N-1]
    bottom_boundary = U[N-1, 1:N-1]
    left_boundary = U[1:N-1, 0]
    right_boundary = U[1:N-1, N-1]

    while cur_error > tol:
        for i in range(1, N-1):
            for j in range(1, N-1):
                U[i, j] = (U[i, j - 1] + U[i - 1, j] + U[i + 1, j] + U[i, j + 1]) / 4        
        max_error = np.max(np.abs(U - U_FD)) / error_scale
        errors.append(max_error)
        cur_error = max_error
    
    U = np.fliplr(U.T)

    return U, np.array(errors)

Convergence

In [7]:
dx = 0.1
U_FD = solve_prob_FD_sparse(dx)

xi = 0
xf = 4
x = np.arange(xi, xf + dx/2, dx)
y = x

X,Y = np.meshgrid(x, y)


U_1,errors = solve_prob_ADI(U_FD,dx,99)
#print(errors)
U_2,_ = solve_prob_ADI(U_FD,dx, 0.5)
#print(errors)
U_3,_ = solve_prob_ADI(U_FD,dx,0.1)
#print(errors)

fig = plt.figure(figsize=(10, 8))

# Create the first subplot
ax1 = fig.add_subplot(221, projection='3d')
ax1.plot_surface(X, Y, U_1, cmap='viridis')
ax1.set_title('ADI m = 1')

# Create the second subplot
ax2 = fig.add_subplot(222, projection='3d')
ax2.plot_surface(X, Y, U_2, cmap='viridis')
ax2.set_title('ADI m = 3')

# Create the third subplot
ax3 = fig.add_subplot(223, projection='3d')
ax3.plot_surface(X, Y, U_3, cmap='viridis')
ax3.set_title('ADI m = 25')

# Create the fourth subplot
ax4 = fig.add_subplot(224, projection='3d')
ax4.plot_surface(X, Y, U_FD, cmap='viridis')
ax4.set_title('Direct Finite Difference Solution')

# Display the figure
plt.tight_layout()
plt.show()

The series of plots above show how the ADI solution will converge to the solution Direct finite difference solution. The plots illustrate the numerical solution from the ADI method at various iterations $m = 1, \space m = 3, \space \text{and } m = 25$ (Numerical solutions for $\Delta x = 0.1$).

In [52]:
dx = 0.04
U_FD = solve_prob_FD_sparse(dx)

tol = 1e-6

U,errors1 = solve_prob_ADI(U_FD,dx, tol)


dx = 0.08
U_FD = solve_prob_FD_sparse(dx)

U,errors2 = solve_prob_ADI(U_FD,dx, tol)

dx = 0.16
U_FD = solve_prob_FD_sparse(dx)

U,errors3 = solve_prob_ADI(U_FD,dx, tol)



plt.plot(errors1, 'r', markersize=1)
plt.plot(errors2, 'b', markersize=1)
plt.plot(errors3, 'g', markersize=1)
plt.yscale('log')
plt.title(r'Error between ADI solution and Direct Solver')
plt.xlabel(r'$m$')
plt.ylabel('Error')
plt.legend([r'$\Delta x = 0.04$', r'$\Delta x = 0.08$', r'$\Delta x = 0.16$'])
plt.show()

The plot above shoves the error between the ADI numerical solution and the direct finite difference solution for different values of $\Delta x$. As we can see the convergence rate between the ADI and the finite difference solution depends on $\Delta x$. We can see in these plots that when $\Delta x$ was halved the number of iterations needed to reach an error below $10^{-6}$ increased by a factor of 4.

Runtime

In [46]:
import time

dx_list = np.array([0.2, 0.1, 0.08, 0.05, 0.04, 0.032])

FDslow_times = np.zeros_like(dx_list)
FDfast_times =np.zeros_like(dx_list)
ADI_times = np.zeros_like(dx_list)
GS_times = np.zeros_like(dx_list)

tol = 0.001

for i in range(len(dx_list)):
    dx = dx_list[i]
    
    start_time = time.time()
    U_FD = solve_prob_FD_sparse(dx)
    end_time = time.time()
    
    d_time = end_time - start_time
    FDfast_times[i] = d_time
    
    #---------------
    
    start_time = time.time()
    U_FD = solve_prob_FD_slow(dx)
    end_time = time.time()
    
    d_time = end_time - start_time
    FDslow_times[i] = d_time
    
    #------------------
    
    start_time = time.time()
    U,err = solve_prob_ADI(U_FD,dx,tol)
    end_time = time.time()
    
    d_time = end_time - start_time
    ADI_times[i] = d_time
    
    #-----------------
    
    start_time = time.time()
    U,err = solve_prob_Gauss_Seidel(U_FD,dx,tol)
    end_time = time.time()
    
    d_time = end_time - start_time
    GS_times[i] = d_time
    
    #--------------------
    
In [54]:
N_list = 4/dx_list

plt.loglog(N_list,FDslow_times,'o--', markersize = 4)
plt.loglog(N_list,FDfast_times,'o--', markersize = 4)
plt.loglog(N_list,ADI_times,'o--', markersize = 4)
plt.loglog(N_list,GS_times,'o--', markersize = 4)
plt.title(r'Runtime for Different Methods (tol $ = 0.001$)')
plt.xlabel(r'$N$')
plt.ylabel('Runtime [s]')
plt.legend(['Direct Solve', 'Direct Solve (Sparse)', 'ADI','Gauss-Seidel'])
plt.show()

In the plot above, I compare the runtime of a few different methods: direct solve the entire all $N^2$ gridpoints simultaneously with $\texttt{scipy.linalg.solve}$ (Direct Solve), direct solve with sparse matrix solver $\texttt{scipy.sparse.linalg.spsolve}$ (Direct Solve (Sparse)), my ADI implementation, and the Gauss-Seidel method. The ADI and Gauss-Seidel methods have a tolerance of $0.001$.

The computational complexity to solve the system directly with Gaussian elimination is $O(N^6)$. It can be seen that the runtime of the direct solving method quickly grows as the matrix needed to be solved becomes increasingly large. At about $N = 80$, the direct solver is solving a $6400 \space \text{by} \space 6400$ system, and ADI becomes faster. Although the plot does not exactly when (I was reaching a data capacity issue with direct solve), it can be seen that the runtime for Gauss-Seidel will eventually be faster than the direct solve method. However, out of my implementations, direct solving with a sparse matrix was by far the fastest, and it was difficult to see if and when ADI would become faster than sparse solve. It is possible with a more efficient implementation of ADI, it could be competitive with the $\texttt{scipy}$ sparse solver.