$\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.
#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
#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
#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
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)
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
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$).
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
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
#--------------------
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.