Two Dimensional Wave FDM Simulation.¶

Mathematical Backgroud¶

If you haven’t already read the baby steps mathematical explanation used to create this animation, which focuses on how the Finite Difference Method is applied to solve the one-dimensional wave partial differential equation—rather than providing a general explanation of how the method itself works—please do so first. At the very least, make sure you understand the basic idea of how the Finite Difference Method is used in this context before continuing with this mathematical explanation.

Now that we did baby steps with one dimension, lets solve a more general version of the PDE mentioned above in a $\Omega \subseteq \mathbb{R}^2$ domain and the $(0,T]$ time period.

I am going to create a FDM scheme for the partial differential equation

\begin{align*} u_{tt} &= c^2 (u_{xx} + u_{yy} ) + f && (x,y) \in \Omega , t \in (0,T] \\ u(x,y,0) &= I(x,y) && \text{Initial Position in } (x,y) \in \Omega \\ u_t(x,y,0) &= V(x,y) && \text{Initial Velocity } (x,y)\in \Omega \\ u(x,y,t) &= 0 && \text{Dirichlet Boundary Conditions } (x,y) \in \partial \Omega, t \in (0,T] \end{align*}

For this problem we will create a mesh of the type

\begin{align*} x_i && \text{for } && i = 0, \dots ,N_x && \text{where } x_0 = 0 < x_1 < \dots < x_{N_x} = L \\ y_i && \text{for } && i = 0, \dots ,N_y && \text{where } y_0 = 0 < y_1 < \dots < y_{N_y} = L \\ t_n && \text{for } && n = 0, \dots ,N_t && \text{where } t_0 = 0 < t_1 < \dots < t_{N_t} = T \end{align*}

The solution $u(x,y,t)$ is sought at the meshpoints. For notation, we will denote $u_{i,j}^n$ the approximation at the point $(x_i,y_i,t_n)$. Given that the notation has been set, we will approximate $u_{tt},u_{xx}, u_{yy}$ as

\begin{align*} u_{tt} &\approx \frac{u_{i,j}^{n+1} - 2u_{i,j}^{n} + u_{i,j}^{n-1} }{\Delta t^2} \\ u_{xx} &\approx \frac{u_{i+1,j}^{n} - 2u_{i,j}^{n} + u_{i-1,j}^{n} }{\Delta x^2} \\ u_{yy} &\approx \frac{u_{i,j+1}^{n} - 2u_{i,j}^{n} + u_{i,j-1}^{n} }{\Delta x^2} \end{align*}

And we will denote

\begin{align*} D_x D_x = \frac{u_{i+1,j}^{n} - 2u_{i,j}^{n} + u_{i-1,j}^{n} }{\Delta x^2} \\ D_y D_y = \frac{u_{i,j+1}^{n} - 2u_{i,j}^{n} + u_{i,j-1}^{n} }{\Delta x^2} \end{align*}

Just like before we will use the centered finite difference to approximate $u_{t}(x_i,y_i,0)$ as

$$ u_{t}(x_i,y_i,0) \approx \frac{u_{i,j}^1 - u_{i,j}^{-1}}{2 \Delta t} $$

Similar to what we did in the 1D case, lets define the Courant numbers as

$C_x = \Delta t / \Delta x , C_y = \Delta t / \Delta y$ . Then, using the initial velocity condition we will find that

$$ u_{i,j}^1 = \frac{1}{2} \Delta t V_{i,j} + u_{i,j}^0 + \frac{1}{2} C_x^2 (u_{i+1,j}^0 - 2u_{i,j}^0 + u_{i-1,j}^{0}) + \frac{1}{2} C_y^2 (u_{i,j+1}^0 - 2u_{i,j}^0 + u_{i,j-1}^{0}) $$

And for the rest of the points the finite difference scheme will be

$$ u_{i,j}^{n+1} = -u_{i,j}^{n-1} + 2 u_{i,j}^{n} + c_x^2 (u_{i+1,j}^n - 2 u_{i,j}^n + u_{i-1,j}^n) + c_y^2 (u_{i,j+1}^n - 2 u_{i,j}^n + u_{i,j-1}^n) + \Delta t^2 f_{i,j}^n $$

And we produce the following python class

In [1]:
import numpy as np
from numpy import ndarray
from typing import Tuple, Callable

"""
Two-Dimensional Wave Equation Solver using Finite Difference Method (FDM)

This solver implements a numerical solution for the 2D wave equation with a source term:

    u_tt = c^2 (u_xx + u_yy) + f(x,y,t)

where:
    - (x,y) ∈ Ω (spatial domain)
    - t ∈ (0, T] (time domain)
    - c is the wave speed
    - f(x,y,t) is a source/forcing function

Initial conditions:
    u(x,y,0) = I(x,y)     (initial displacement)
    u_t(x,y,0) = V(x,y)   (initial velocity)

Boundary conditions:
    u = 0 on ∂Ω for all t ∈ (0, T]  (Dirichlet boundary conditions)

The solver uses an explicit finite difference scheme with second-order accuracy
in both space and time.
"""


class TwoDimensionalWaveFDM:

    _I: Callable[[float, float], float]
    _V: Callable[[float, float], float]
    _f: Callable[[float, float, float], float]
    _Nx: int
    _Ny: int
    _Nt: int
    _c: float
    _Lx: float
    _Ly: float
    _T: float
    _dx: float
    _dy: float
    _dt: float
    _Cx: float
    _Cy: float
    _u: ndarray
    _grid: Tuple[ndarray, ndarray, ndarray]

    def __init__(self,I:Callable[[float,float],float],V:Callable[[float,float],float],f:Callable[[float,float,float], float],Nx:int, Ny:int, Nt:int,c:float=1.0,Lx=1.0,Ly=1.0,T=1.0) -> None:
        self.I = I  # Initial displacement function
        self.V = V  # Initial velocity function
        self.f = f  # Source/forcing function
        self.Nx = Nx  # Number of spatial grid points in x
        self.Ny = Ny  # Number of spatial grid points in y
        self.Nt = Nt  # Number of time steps
        self.c = c  # Wave speed
        self.Lx = Lx  # Length of the domain in x
        self.Ly = Ly  # Length of the domain in y
        self.T = T  # Total time

        self.dx = Lx / (Nx - 1)  # Spatial step size in x
        self.dy = Ly / (Ny - 1)  # Spatial step size in y
        self.dt = T / (Nt - 1)   # Time step size

        self.Cx = (c * self.dt / self.dx) ** 2  # Courant number in x
        self.Cy = (c * self.dt / self.dy) ** 2  # Courant number in y

        self.u = np.zeros((Nx, Ny, Nt))  # Solution array
        self.grid = self.create_grid()  # Create spatial and temporal grid
        self.apply_initial_conditions()  # Apply initial conditions

    def create_grid(self) -> Tuple[ndarray, ndarray, ndarray]:
        x = np.linspace(0, self.Lx, self.Nx)  # Spatial points in x
        y = np.linspace(0, self.Ly, self.Ny)  # Spatial points in y
        t = np.linspace(0, self.T, self.Nt)   # Time points
        return np.meshgrid(x, y, t, indexing='ij')
    
    def apply_boundary_conditions(self) -> None:
        self.u[0,:,:] = 0  # u(0,y,t) = 0
        self.u[-1,:,:] = 0  # u(Lx,y,t) = 0
        self.u[:,0,:] = 0  # u(x,0,t) = 0
        self.u[:,-1,:] = 0  # u(x,Ly,t) = 0

    def apply_initial_displacement(self) -> None:
        X,Y,_ = self.grid
        
        xr = X[:,:,0]
        yr = Y[:,:,0]

        self.u[:,:,0] = self.I(xr, yr)
    
    def apply_initial_velocity(self) -> None:
        
        X,Y,_ = self.grid


        for i in range(1, self.Nx - 1):
            for j in range(1, self.Ny - 1):
                xi = X[i,0,0]
                yj = Y[0,j,0]
                ft = 0.5*self.dt * self.V(xi, yj)+self.u[i,j,0]
                p_x = 0.5*self.Cx * (self.u[i+1,j,0] - 2*self.u[i,j,0]+ self.u[i-1,j,0])
                p_y = 0.5*self.Cy * (self.u[i,j+1,0] - 2*self.u[i,j,0]+ self.u[i,j-1,0])
                lt = 0.5*self.dt**2 * self.f(xi, yj, 0)
                self.u[i,j,1] = ft + p_x + p_y + lt
                
    
    def solve(self) -> None:
        X,Y,T = self.grid        
        for n in range(1,self.Nt-1):
            for i in range(1,self.Nx-1):
                for j in range(1,self.Ny-1):
                    xi = X[i,0,0]
                    yj = Y[0,j,0]
                    tn = T[0,0,n]
                    ft = -self.u[i,j,n-1] + 2*self.u[i,j,n]
                    p_x = self.Cx * (self.u[i+1,j,n] - 2*self.u[i,j,n] + self.u[i-1,j,n])
                    p_y = self.Cy * (self.u[i,j+1,n] - 2*self.u[i,j,n] + self.u[i,j-1,n])
                    lt = self.dt**2 * self.f(xi, yj, tn)
                    self.u[i,j,n+1] = ft + p_x + p_y + lt

    def apply_initial_conditions(self) -> None:
        self.apply_initial_displacement()
        self.apply_boundary_conditions()
        self.apply_initial_velocity()

    # Getters and Setters
    @property
    def I(self) -> Callable[[float, float], float]:
        """Get the initial displacement function."""
        return self._I
    
    @I.setter
    def I(self, I: Callable[[float, float], float]) -> None:
        """Set the initial displacement function."""
        self._I = I

    @property
    def V(self) -> Callable[[float, float], float]:
        """Get the initial velocity function."""
        return self._V
    
    @V.setter
    def V(self, V: Callable[[float, float], float]) -> None:
        """Set the initial velocity function."""
        self._V = V

    @property
    def f(self) -> Callable[[float, float, float], float]:
        """Get the source/forcing function."""
        return self._f
    
    @f.setter
    def f(self, f: Callable[[float, float, float], float]) -> None:
        """Set the source/forcing function."""
        self._f = f

    @property
    def Nx(self) -> int:
        """Get the number of spatial grid points in x."""
        return self._Nx
    
    @Nx.setter
    def Nx(self, Nx: int) -> None:
        """Set the number of spatial grid points in x."""
        if Nx <= 0:
            raise ValueError("Nx must be positive")
        self._Nx = Nx

    @property
    def Ny(self) -> int:
        """Get the number of spatial grid points in y."""
        return self._Ny
    
    @Ny.setter
    def Ny(self, Ny: int) -> None:
        """Set the number of spatial grid points in y."""
        if Ny <= 0:
            raise ValueError("Ny must be positive")
        self._Ny = Ny

    @property
    def Nt(self) -> int:
        """Get the number of time steps."""
        return self._Nt
    
    @Nt.setter
    def Nt(self, Nt: int) -> None:
        """Set the number of time steps."""
        if Nt <= 0:
            raise ValueError("Nt must be positive")
        self._Nt = Nt

    @property
    def c(self) -> float:
        """Get the wave speed."""
        return self._c
    
    @c.setter
    def c(self, c: float) -> None:
        """Set the wave speed."""
        if c <= 0:
            raise ValueError("Wave speed c must be positive")
        self._c = c

    @property
    def Lx(self) -> float:
        """Get the length of the domain in x."""
        return self._Lx
    
    @Lx.setter
    def Lx(self, Lx: float) -> None:
        """Set the length of the domain in x."""
        if Lx <= 0:
            raise ValueError("Lx must be positive")
        self._Lx = Lx

    @property
    def Ly(self) -> float:
        """Get the length of the domain in y."""
        return self._Ly
    
    @Ly.setter
    def Ly(self, Ly: float) -> None:
        """Set the length of the domain in y."""
        if Ly <= 0:
            raise ValueError("Ly must be positive")
        self._Ly = Ly

    @property
    def T(self) -> float:
        """Get the total time."""
        return self._T
    
    @T.setter
    def T(self, T: float) -> None:
        """Set the total time."""
        if T <= 0:
            raise ValueError("T must be positive")
        self._T = T

    @property
    def dx(self) -> float:
        """Get the spatial step size in x."""
        return self._dx
    
    @dx.setter
    def dx(self, dx: float) -> None:
        """Set the spatial step size in x."""
        self._dx = dx

    @property
    def dy(self) -> float:
        """Get the spatial step size in y."""
        return self._dy
    
    @dy.setter
    def dy(self, dy: float) -> None:
        """Set the spatial step size in y."""
        self._dy = dy

    @property
    def dt(self) -> float:
        """Get the time step size."""
        return self._dt
    
    @dt.setter
    def dt(self, dt: float) -> None:
        """Set the time step size."""
        self._dt = dt

    @property
    def Cx(self) -> float:
        """Get the Courant number in x."""
        return self._Cx
    
    @Cx.setter
    def Cx(self, Cx: float) -> None:
        """Set the Courant number in x."""
        self._Cx = Cx

    @property
    def Cy(self) -> float:
        """Get the Courant number in y."""
        return self._Cy
    
    @Cy.setter
    def Cy(self, Cy: float) -> None:
        """Set the Courant number in y."""
        self._Cy = Cy

    @property
    def u(self) -> ndarray:
        """Get the solution array."""
        return self._u
    
    @u.setter
    def u(self, u: ndarray) -> None:
        """Set the solution array."""
        self._u = u

    @property
    def grid(self) -> Tuple[ndarray, ndarray, ndarray]:
        """Get the spatial and temporal grid."""
        return self._grid
    
    @grid.setter
    def grid(self, grid: Tuple[ndarray, ndarray, ndarray]) -> None:
        """Set the spatial and temporal grid."""
        self._grid = grid

Error Analysis.¶

For the error analysis, we will follow the same patterns as before, i.e.

  • We will perform $m$ simulations
  • The first simulation will be with a given $\Delta x_0,\Delta y_0, \Delta t_0$
  • In each simulation $i=1,2,3,...$ we will decreased the size of $\Delta t_0$ by $\Delta t_i = 2^{-i} \Delta t_0$
  • The value of the $u_{\text{approx}}(x_{N_x},y_{N_y},t_{N_x})$ approximation will be compared to the analytical value $u_{\text{exact}}(x_{N_x},y_{N_y},t_{N_x})$
  • That will produce an array of errors $E_i$ which will be used with the $L^2$ norm to study the convergence error as $\Delta t$ decreases

But, this time, we will go an step further, we will estimate the convergence rates $r_{i}$ based on the consecutive experiments i.e.

  • We will take the experiments $(\Delta t_{i-1},E_{i-1}),(\Delta t_{i},E_{i})$
  • Based on the fact that we expect $E_i = c \left( \Delta t_i \right)^r$ and $E_{i-1} = c \left( \Delta t_{i-1} \right)^r$ where $r$ is the convergence rate
  • The following sequence

$$ r_i = \frac{ \ln(E_{i-1}/E_i) }{\ln( \Delta t_{i-1}/t_i )} $$

Should converge to the convergence rate, as we are decreasing each $\Delta t_0$ by 2, then the sequence should converge to 2.

Lets do a code that implement this entire error analysis. In this case we will solve the PDE for the following conditions.

\begin{align*} I(x,y) &= 2 \sin(\pi x) \sin(\pi y) \\ V(x,y) &= 0 \\ f(x,y,t) &= (-1 + 2 \pi^2) \sin(\pi x) \sin(\pi y) \cos(t) \end{align*}

The domain region is $\Omega = [0,1] \times [0,1]$ and the time interval will be $T=(0,1]$

And the analytical solution will be

$$ u_e(x,y,t) = \sin(\pi x) \sin(\pi y) \cos(t) $$

In [ ]:
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

# --- Corrected Manufactured Solution ---
# u = sin(pi*x) * sin(pi*y) * cos(t)
# This ensures u_tt is not zero, allowing us to see O(dt^2)
def u_e(x, y, t):
    return np.sin(np.pi * x) * np.sin(np.pi * y) * np.cos(t)

def I(x, y):
    return np.sin(np.pi * x) * np.sin(np.pi * y)

def V(x, y):
    # Velocity is du/dt = -sin(pi*x)*sin(pi*y)*sin(t). At t=0, this is 0.
    return 0.0

def f(x, y, t):
    # f = u_tt - c^2(u_xx + u_yy). Here c = 1.
    # u_tt = -sin(pi*x) * sin(pi*y) * cos(t)
    # u_xx = -pi^2 * sin(pi*x) * sin(pi*y) * cos(t)
    # u_yy = -pi^2 * sin(pi*x) * sin(pi*y) * cos(t)
    # f = [-1 - (1^2 * (-pi^2 - pi^2))] * u_e
    return (-1.0 + 2.0 * np.pi**2) * np.sin(np.pi * x) * np.sin(np.pi * y) * np.cos(t)

# --- Simulation Parameters ---
m = 5  # Number of refinements
Nx0, Ny0, Nt0 = 10, 10, 40 # Start with a slightly finer base for stability

delta_t_array = []
l2_errors = []

for i in range(m):
    # Halve steps by doubling intervals
    Nx = (Nx0 - 1) * 2**i + 1
    Ny = (Ny0 - 1) * 2**i + 1
    Nt = (Nt0 - 1) * 2**i + 1
    
    # Initialize and solve
    simulator = TwoDimensionalWaveFDM(I, V, f, Nx, Ny, Nt, c=1.0, T=1.0)
    simulator.solve()
    
    X, Y, T = simulator.grid
    dx, dy, dt = simulator.dx, simulator.dy, simulator.dt
    
    # --- Corrected Error Analysis ---
    # Compare the solution at the FINAL time step only
    u_approx_final = simulator.u[:, :, -1]
    u_exact_final = u_e(X[:, :, -1], Y[:, :, -1], T[:, :, -1])
    
    # L2 Error at time T
    error_final = u_exact_final - u_approx_final
    l2_e = np.sqrt(dx * dy * np.sum(error_final**2))
    
    l2_errors.append(l2_e)
    delta_t_array.append(dt)
    
    print(f"Level {i}: Grid {Nx}x{Ny}, dt={dt:.5f}, L2_Error={l2_e:.2e}")

# --- Calculate Rates ---
convergence_rates = [
    np.log(l2_errors[i-1]/l2_errors[i]) / np.log(delta_t_array[i-1]/delta_t_array[i])
    for i in range(1, len(l2_errors))
]

# --- Plotting Results ---

# Plot 1: Error vs Dt (Log-Log)
fig1, ax1 = plt.subplots(figsize=(10, 6))
ax1.loglog(delta_t_array, l2_errors, 'o-', label='L2 Error', linewidth=2.5, 
           markersize=8, color='#2E86AB')
ax1.set_xlabel('Time step size (Δt)', fontsize=12, fontweight='bold')
ax1.set_ylabel('L2 Error', fontsize=12, fontweight='bold')
ax1.set_title('Error Convergence: 2D Wave Equation FDM', fontsize=14, fontweight='bold', pad=15)
ax1.grid(True, alpha=0.3, linestyle='--', linewidth=0.7, which='both')
ax1.legend(loc='best', framealpha=0.9, fontsize=10, shadow=True)
ax1.set_facecolor('#F8F9FA')
fig1.patch.set_facecolor('white')
plt.tight_layout()
plt.show()

# Plot 2: Convergence Rates
fig2, ax2 = plt.subplots(figsize=(10, 6))
ax2.plot(range(1, len(l2_errors)), convergence_rates, 's-', color='#A23B72', 
         linewidth=2.5, markersize=8, label='Convergence Rate')
ax2.axhline(y=2.0, color='#2E86AB', linestyle='--', linewidth=1.5, 
            alpha=0.7, label='Expected O(h²)')
ax2.set_xlabel('Refinement Level', fontsize=12, fontweight='bold')
ax2.set_ylabel('Convergence Rate', fontsize=12, fontweight='bold')
ax2.set_title('Convergence Rate Analysis', fontsize=14, fontweight='bold', pad=15)
ax2.grid(True, alpha=0.3, linestyle='--', linewidth=0.7)
ax2.legend(loc='best', framealpha=0.9, fontsize=10, shadow=True)
ax2.set_facecolor('#F8F9FA')
fig2.patch.set_facecolor('white')
plt.tight_layout()
plt.show()
Level 0: Grid 10x10, dt=0.02564, L2_Error=4.45e-03
Level 1: Grid 19x19, dt=0.01282, L2_Error=1.09e-03
Level 2: Grid 37x37, dt=0.00641, L2_Error=2.70e-04
Level 3: Grid 73x73, dt=0.00321, L2_Error=6.74e-05
Level 4: Grid 145x145, dt=0.00160, L2_Error=1.68e-05
No description has been provided for this image
No description has been provided for this image