If I want my solution to decay to 0 as space approaches infinity, what would be the best approach?

I want to see if Dolfinx can solve the following Poisson Equation for a 2D fluid flow

\nabla^2 \chi = \nabla\cdot\textbf{V}
\lim_{\textbf{|x|}\rightarrow \infty}\chi(x,y) = 0

and in order to simulate the infinite space, I opted to try what was used in the electromagnetics example by increasing the size of the domain and setting the values on the boundary on the larger domain to 0. To test this result, I compared it to the result from convolution with the free space Green’s function

\chi(x,y) = \int_\Omega G(\textbf{x},\textbf{x}_0) \nabla \cdot \textbf{V}(\textbf{x}_0) d\textbf{x}_0
G(\textbf{x},\textbf{x}_0) = - \frac{1}{2\pi}\ln(|\textbf{x}- \textbf{x}_0|)

Below is a comparison of the results using real divergence data on a [0\le x\le 1; 0\le y \le 1] Cartesian square (ignore the first 3 solution plots as they used the discrete sine transform-I, fast Fourier transform, and fenicsx’s LU solver methods to get results for homogeneous Dirichlet boundary conditions on the original domain; the last two plots are our focus as they compare the results over the assumption that the solution goes to 0 over infinite space)

The lower-right plot which used fenicsx’s LU solver with homogeneous Dirichlet boundary conditions set on the boundary of the domain 5 times larger than the original to simulate an infinite space should look similar to the result from convoluting with Green’s function. Now, let’s observe a manufactured case where the solution is a Gaussian function (a function which naturally decays to 0 as space goes to infinity) centered at (0.5,0.5)

\chi(x,y) = e^{-a\big((x-0.5)^2 + (y-0.5)^2\big)}

the Poisson Equation is then

\nabla^2 \chi = -4a\Big(a\big((x-0.5)^2 + (y-0.5)^2\big) - 1 \Big) e^{-a\big((x-0.5)^2 + (y-0.5)^2\big)}

For the solution to be accurate, we need the Gaussian source to be relatively compared to the size of the domain (observe that each local maxima and minima of divergence in the data driven example were also relatively small compared to the overall area of the grid), so I have set a = 50 to produce a sharp concentric peak. Below are plots comparing the results

where it is difficult to visually see the difference, so I have checked the L2 errors. For the L2 errors of the Green’s function solution, I have:

Error_L2: 1.61e-02
Error_max: 2.55e-02

and for the LU solver which attempted to simulate an infinite space, I have

Error_L2: 8.62e-02
Error_max: 1.61e-01

so the overall the errors produced by Fenicsx’s LU solver are relatively much higher with extended space, but not ruinously so for this case. From what we can see for the case involving data, these results probably would be an issue.

Unless I messed something up in my code somewhere, it looks like the method of extending the space might not be sufficient for all cases, and overall produces significantly more error. So, I want to know if there is an alternative method to simulate infinite space which fenicsx can handle to produce more accurate results? Or maybe a different solver than LU would handle this better? Let me know anything that might work!


Here is my code:

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import fftconvolve
from scipy.fft import dst, idst
from scipy.interpolate import RegularGridInterpolator
import xarray as xr
import dolfinx as df
from dolfinx.fem.petsc import LinearProblem
from mpi4py import MPI
import ufl
import gmsh

# Helper function
def error_L2(y_ex, y_num):
    difference = y_ex - y_num
    l2_error = np.linalg.norm(difference, ord=2)
    abs_max_error = np.linalg.norm(difference, ord=np.inf)
    print(f"\nError_L2: {l2_error:.2e}")
    print(f"Error_max: {abs_max_error:.2e}\n")


# Matplotlib plotter functions ############################################################
def cells_at_xy(X_grid,Y_grid, mesh, return_points):
    points = np.vstack((X_grid.ravel(), Y_grid.ravel(), np.zeros(X_grid.size))).T
    bb_tree = df.geometry.bb_tree(mesh, mesh.topology.dim)
    cell_indices = []
    for point in points:
        cells_candidates = df.geometry.compute_collisions_points(bb_tree, point)
        colliding_cells = df.geometry.compute_colliding_cells(mesh, cells_candidates, point)
        cell_index = colliding_cells.links(0)[0]
        cell_indices.append(cell_index)
    if return_points:
        return points, cell_indices
    else:
        return cell_indices
        
def xy_grid(mesh):
    #Nx = len(mesh.geometry.x[:,0])
    #Ny = len(mesh.geometry.x[:,1])
    Nx = np.arange(mesh.geometry.x[:,0].min(),mesh.geometry.x[:,0].max(),h).shape[0]
    Ny = np.arange(mesh.geometry.x[:,1].min(),mesh.geometry.x[:,1].max(),h).shape[0]
    x_ = np.linspace(mesh.geometry.x[:, 0].min(), mesh.geometry.x[:, 0].max(), Nx+1)
    y_ = np.linspace(mesh.geometry.x[:, 1].min(), mesh.geometry.x[:, 1].max(), Ny+1)
    X_, Y_ = np.meshgrid(x_, y_)    
    return X_, Y_

def fenics_2_numpy(var):
    X_,Y_ = xy_grid(var.function_space.mesh)
    points, cell_indices = cells_at_xy(X_,Y_, var.function_space.mesh,True)
    numpy_var = var.eval(points, cell_indices)
    numpy_var = numpy_var[:, 0].reshape(X_.shape)
    
    return numpy_var

# Solver functions ############################################################################################

def solve_poisson_green(f, h, pad = True):
    Ny, Nx = f.shape
    if pad: # 2N x 2N
        dist_y = np.linspace(-(Ny-1)*h, (Ny-1)*h, 2*Ny-1)
        dist_x = np.linspace(-(Nx-1)*h, (Nx-1)*h, 2*Nx-1)
    else: # N x N
        dist_y = np.linspace(0, (Ny-1) * h, Ny-1)
        dist_x = np.linspace(0, (Nx-1) * h, Nx-1)
    DX, DY = np.meshgrid(dist_x, dist_y)
    R = np.sqrt(DX**2 + DY**2)

    with np.errstate(divide='ignore'):
        G = -1/(2*np.pi) * np.log(R)
        
    G[np.where(dist_y == 0)[0], np.where(dist_x == 0)[0]] = -1/(2*np.pi) * np.log(h) + 0.16889 # Singularity correction: cell average
    chi = fftconvolve(f, G, mode='same') * h**2
    return chi

def solve_poisson_fft(f, h):

    f_int = f[1:-1,1:-1] # Truncate off the values at x,y = 0 & L
    m = f_int.shape[0]
    Ne = (m+1)*2
    
    # Boundary Conditions
    u0y = 0; 
    u1y = 0;
    ux0 = 0;
    ux1 = 0;

    # Apply BCs
    f[:,0] = f[:,0] - np.transpose(ux0) /h**2; 
    f[:,m-1] = f[:,m-1] - np.transpose(ux1) /h**2;
    f[0,:] = f[0,:] - u0y/h**2;
    f[m-1,:] = f[m-1,:] - u1y/h**2;

    # Odd extension
    g = np.hstack((np.zeros((m,1)), f_int, np.zeros((m,1)), -f_int[:,::-1]))    #  Odd extension of f in x
    g = np.vstack((np.zeros((1,Ne)), g, np.zeros((1,Ne)), -g[::-1,:]))          #  Odd extension of g in y

    ghat = np.fft.fft2(g);

    # wavenumbers/eigenvalues
    gridx, gridy = np.arange(0,Ne), np.arange(0,Ne)
    I, J  = np.meshgrid(gridx,gridy)                      #  New finite indices after extension
    omega_x, omega_y = 2*np.pi/Ne * I, 2*np.pi/Ne * J     #  Convert finite space into Fourier Space: 
                                                          #      omega_indice = 2pi/(number of elements along dimension) * indice
    mu = -(4/h**2)*(np.sin(omega_x/2)**2 + np.sin(omega_y/2)**2)
    mu[0,0] = 1
    
    v = np.real(np.fft.ifft2(ghat/mu))
    VP = v[:(m+2),:(m+2)] # Extract u from its odd extension
    
    return VP

def solve_poisson_DSTI(f):
    '''
    This Process prevents the need to perform an odd extension
    by instead using an Discrete Sine Transform technique.
    '''
    L = x.max()
    f_int = f[1:-1,1:-1]# Truncate off the values on the boundary where x,y = 0 & L
    m = f_int.shape[0]

    fhat = dst(dst(f_int, axis = 0, type = 1, norm = 'ortho'), axis = 1, type = 1, norm = 'ortho')

    # Wavenumbers/eigenvalues
    omega_x, omega_y = np.arange(1, m+1, 1) * np.pi/L, np.arange(1, m+1, 1) * np.pi/L # kx = pi x / L, ky = pi y / L
    Omega_X, Omega_Y = np.meshgrid(omega_x, omega_y, indexing= 'ij')

    mu = Omega_X**2 + Omega_Y**2

    uhat = -fhat/mu
    VP_int = idst(idst(uhat, axis = 0, type = 1, norm = 'ortho'), axis = 1, type = 1, norm = 'ortho')

    # Pad result with Dirichlet boundary values
    VP = np.pad(VP_int, pad_width = 1, mode = 'constant', constant_values = 0) 
    return VP

def solve_poisson_fenics(f, inf_space = False, NAME = "uh"):
    '''
    Solves the Poisson Equation using the standard LU solver and Lagrangian functionspace.
    For inf_space = True, from the Electromagnetics example, 
    "Since we cannot solve the problem on an infinite domain, we will truncate the domain" 
    So we embed the domain of interest inside of a larger square and set the solution to 0 on the boundary of the large mesh
    to simulate a solution going to 0 as space goes to 0. 
    '''
    family = "Lagrange"
    order = 1
    def scipy_to_fenics_fun(x):
        '''
        moves from our scipy interpolated function 
        to a Dolfinx function
        '''
        # To return a scipy function value, we need to pass in a tuple (x,y)
        point = (x[0], x[1])
        return f(point)
    
    if inf_space: 
        gmsh.initialize()
        #gmsh.option.setNumber("General.Terminal", 0)# prevent output

        # ---- Create Graded mesh ---- 
        L = 1 # Length of actual domain
        L_outer = 5 # Length of outer domain (where space goes to "infinity") 
        
        coarse_h = h*10 
        fine_h = h      

        # lower-left corner coordinates of full and inner domain
        x_i, y_i, z_i = x.min(), y.min(), 0 # (0,0,0)
        x_f, y_f, z_f = x_i-(L_outer-L)/2, y_i-(L_outer-L)/2, 0 # (-2.5,-2.5,0)
        full_square = gmsh.model.occ.add_rectangle(x_f, y_f, z_f, dx = L_outer, dy = L_outer) # full domain
        inner_square = gmsh.model.occ.add_rectangle(x_i, y_i, z_i, dx = L, dy = L) # inner domain

        # overlap the squares
        gmsh.model.occ.fragment([(2, full_square)], [(2, inner_square)])
        gmsh.model.occ.synchronize()

        surfaces = gmsh.model.getEntities(dim=2)
        surface_ids = [s[1] for s in surfaces]
        surface_tag = 1

        # Tag physical entities
        gmsh.model.addPhysicalGroup(2, surface_ids, tag=surface_tag)
        gmsh.model.setPhysicalName(2, surface_tag, "msh_domain")

        gmsh.model.mesh.field.add("Distance", 1)
        gmsh.model.mesh.field.setNumbers(1, "FacesList", [inner_square])
        gmsh.model.mesh.field.add("Threshold", 2)
        gmsh.model.mesh.field.setNumber(2, "InField", 1)
        gmsh.model.mesh.field.setNumber(2, "SizeMin", fine_h)
        gmsh.model.mesh.field.setNumber(2, "SizeMax", coarse_h)
        gmsh.model.mesh.field.setNumber(2, "DistMin", 0)              # Grid starts coarsening immediately outisde of inner square
        gmsh.model.mesh.field.setNumber(2, "DistMax", L)              # Grid hits target coarseness resolution at L distance away from boundaries
        gmsh.model.mesh.field.setAsBackgroundMesh(2)
        gmsh.model.mesh.generate(2)

        # Create dolfinx mesh
        model_rank = 0
        mesh = df.io.gmshio.model_to_mesh(gmsh.model, MPI.COMM_WORLD, rank=0, gdim=2)[0]
        gmsh.finalize()
        
        prefix = "infinite_space"
    else:
        mesh = df.mesh.create_unit_square(MPI.COMM_WORLD, N-1,N-1)
        prefix = "standard"
    tdim = mesh.topology.dim
    fdim = tdim-1
    mesh.topology.create_connectivity(fdim, tdim)
    S = df.fem.functionspace(mesh, (family, order)) # Scalar space to store divergence

    # boundary condition
    boundary_facets = df.mesh.exterior_facet_indices(mesh.topology)
    boundary_dofs = df.fem.locate_dofs_topological(S,fdim, boundary_facets)
    bc = df.fem.dirichletbc(df.default_scalar_type(0),boundary_dofs,S)
    # forcing term
    forcing = df.fem.Function(S)
    forcing.interpolate(scipy_to_fenics_fun)
    
    # Solve
    u, v = ufl.TrialFunction(S), ufl.TestFunction(S) 
    a = ufl.dot(ufl.grad(u),ufl.grad(v)) * ufl.dx
    L = -forcing*v*ufl.dx
    problem = LinearProblem(a=a,L=L,bcs = [bc],
                       petsc_options_prefix=f"{prefix}_Poisson",petsc_options={"ksp_type": "preonly", "pc_type": "lu"}) 
    uh = problem.solve()

    return uh

USE_DATA = False
PLOT_ONLY_GREEN = True

if USE_DATA:
    file = "Heavy_rainfall_north_China_20120712_850mb_1800Z.grib"
    ds = xr.open_dataset(file,engine = "cfgrib")
    res = 1
    geopotential = ds.variables['z'][::res,::res].values
    gpot_height = geopotential/9.8
    lats = ds.variables['latitude'][::res].values
    lons = ds.variables['longitude'][:len(lats)].values

    div = ds.variables['d'][::res,:len(lats)].values

    center_lat = np.deg2rad(lats[(len(lats)-1)//2])
    center_lon = np.deg2rad(lons[(len(lons)-1)//2])
    LONS, LATS = np.meshgrid(lons,lats)

    # ---- map scale factor ----
    # define map scale factor
    radlats = np.deg2rad(LATS)
    n = np.sin(center_lat)
    m = np.cos(center_lat)/np.cos(radlats) * (np.tan(np.pi/4 - radlats/2)/np.tan(np.pi/4 - center_lat/2))**n
    # scale forcing term
    D = div/m**2

    N = D.shape[0]
    L = 1
    h = L/(N-1) # dx = dy = 1/60

    x = np.arange(0,h*N,h)
    y = np.arange(0,h*N,h)
    X,Y = np.meshgrid(x,y)

else:
    N = 60
    L = 1
    h = L/(N-1)
    x = np.arange(0,h*N,h)
    y = np.arange(0,h*N,h)
    X,Y = np.meshgrid(x,y)

    a = 50
    D = 4 * a * (a * ((X-1/2)**2 + (Y-1/2)**2) - 1) * np.exp(-a*((X-1/2)**2+(Y-1/2)**2)) 
    u_ex = np.exp(-a*((X-1/2)**2 + (Y-1/2)**2))

D_interp = RegularGridInterpolator(
        (y, x), 
        D, 
        bounds_error=False, fill_value=0.0)

# Collect solutions
u_green = solve_poisson_green(-D, h, pad = True)
u_fft = solve_poisson_fft(D, h)
u_dst = solve_poisson_DSTI(D)
u_LU = solve_poisson_fenics(D_interp, False)
u_LU_inf_space = solve_poisson_fenics(D_interp, True, NAME = "uh_inf")# Create functional expressions to put into Dolfinx

# extract infinite space solution over desired domain
x_ext = np.arange(u_LU_inf_space.function_space.mesh.geometry.x[:,0].min(),u_LU_inf_space.function_space.mesh.geometry.x[:,0].max()+h,h)
y_ext = np.arange(u_LU_inf_space.function_space.mesh.geometry.x[:,1].min(),u_LU_inf_space.function_space.mesh.geometry.x[:,1].max()+h,h)
left_bound = np.where(np.isclose(x_ext,x.min()))[0][0]
right_bound = np.where(np.isclose(x_ext,x.max()))[0][0]
lower_bound = np.where(np.isclose(y_ext,y.min()))[0][0]
upper_bound = np.where(np.isclose(y_ext,y.max()))[0][0]
u_LU_inf_space = fenics_2_numpy(u_LU_inf_space).T[lower_bound:upper_bound+1,left_bound:right_bound+1]

fig = plt.figure(figsize=(12,15))
if PLOT_ONLY_GREEN:
    ax1,ax2,ax3 = fig.add_subplot(321),fig.add_subplot(322),fig.add_subplot(323)

    if USE_DATA:
        D = D[::-1,::-1]
        u_green = u_green[::-1,::-1]
        u_LU_inf_space = u_LU_inf_space[::-1,::-1]
        contour_f = ax1.contourf(X,Y,D, levels = 11, cmap = 'bwr', vmin = -abs(D).max(), vmax = abs(D).max())
        ax1.set_title(r"divergence s^{-1}")
        ax2.contour(X,Y,D, levels = [-6e-4,-4.5e-4,-3e-4,-1.5e-4,-1e-4,1e-4,1.5e-4,2e-4,2.5e-4,3e-4,3.5e-4], cmap = 'bwr', vmin = -6e-4, vmax = 6e-4, linestyles = '-', linewidth = 1.5)
        ax3.contour(X,Y,D, levels = [-6e-4,-4.5e-4,-3e-4,-1.5e-4,-1e-4,1e-4,1.5e-4,2e-4,2.5e-4,3e-4,3.5e-4], cmap = 'bwr', vmin = -6e-4, vmax = 6e-4, linestyles = '-', linewidth = 1.5)
        plt.colorbar(contour_f)
    else:
        contour_u = ax1.contourf(X,Y,u_ex, levels = 11, cmap = 'bwr', vmin = -abs(u_ex).max(), vmax = abs(u_ex).max())
        ax1.set_title("exact solution")
        plt.colorbar(contour_u)
    
    contour_green = ax2.contourf(X,Y,u_green, levels = 11, cmap = 'bwr', vmin = -abs(u_green).max(), vmax = abs(u_green).max())
    contour_LU_inf = ax3.contourf(X,Y,u_LU_inf_space, levels = 11, cmap = 'bwr', vmin = -abs(u_LU_inf_space).max(), vmax = abs(u_LU_inf_space).max())
    
    
    ax1.set_xlabel('x');ax1.set_ylabel('y')
    ax2.set_xlabel('x');ax2.set_ylabel('y')
    ax2.set_title("inf. space green sol.")
    ax3.set_xlabel('x');ax3.set_ylabel('y')
    ax3.set_title("LU inf. space sol.")
    
else:
    # make u_LU plottable in matplotlib
    u_LU = fenics_2_numpy(u_LU).T
    
    fig = plt.figure(figsize=(12,15))
    ax1,ax2,ax3,ax4,ax5,ax6 = fig.add_subplot(321),fig.add_subplot(322),fig.add_subplot(323),fig.add_subplot(324),fig.add_subplot(325),fig.add_subplot(326) 
    if USE_DATA:
        D = D[::-1,::-1]
        u_dst = u_dst[::-1,::-1]
        u_fft = u_fft[::-1,::-1]
        u_LU = u_LU[::-1,::-1]
        u_green = u_green[::-1,::-1]
        u_LU_inf_space = u_LU_inf_space[::-1,::-1]
        
        contour_f = ax1.contourf(X,Y,D, levels = 11, cmap = 'bwr', vmin = -abs(D).max(), vmax = abs(D).max()) 
        ax2.contour(X,Y,D, levels = [-6e-4,-4.5e-4,-3e-4,-1.5e-4,-1e-4,1e-4,1.5e-4,2e-4,2.5e-4,3e-4,3.5e-4], cmap = 'bwr', vmin = -6e-4, vmax = 6e-4, linestyles = '-', linewidth = 1.5)
        ax3.contour(X,Y,D, levels = [-6e-4,-4.5e-4,-3e-4,-1.5e-4,-1e-4,1e-4,1.5e-4,2e-4,2.5e-4,3e-4,3.5e-4], cmap = 'bwr', vmin = -6e-4, vmax = 6e-4, linestyles = '-', linewidth = 1.5)
        ax4.contour(X,Y,D, levels = [-6e-4,-4.5e-4,-3e-4,-1.5e-4,-1e-4,1e-4,1.5e-4,2e-4,2.5e-4,3e-4,3.5e-4], cmap = 'bwr', vmin = -6e-4, vmax = 6e-4, linestyles = '-', linewidth = 1.5)
        ax5.contour(X,Y,D, levels = [-6e-4,-4.5e-4,-3e-4,-1.5e-4,-1e-4,1e-4,1.5e-4,2e-4,2.5e-4,3e-4,3.5e-4], cmap = 'bwr', vmin = -6e-4, vmax = 6e-4, linestyles = '-', linewidth = 1.5)
        ax6.contour(X,Y,D, levels = [-6e-4,-4.5e-4,-3e-4,-1.5e-4,-1e-4,1e-4,1.5e-4,2e-4,2.5e-4,3e-4,3.5e-4], cmap = 'bwr', vmin = -6e-4, vmax = 6e-4, linestyles = '-', linewidth = 1.5)
        ax1.set_title(r"divergence s^{-1}")
        plt.colorbar(contour_f)
    else:
        contour_u = ax1.contourf(X,Y,u_ex, levels = 11, cmap = 'bwr', vmin = -abs(u_ex).max(), vmax = abs(u_ex).max())
        plt.colorbar(contour_u)
        
    contour_dst = ax2.contourf(X,Y,u_dst, levels = 11, cmap = 'bwr', vmin = -abs(u_dst).max(), vmax = abs(u_dst).max())
    contour_fft = ax3.contourf(X,Y, u_fft, levels = 11, cmap = 'bwr', vmin = -abs(u_fft).max(), vmax = abs(u_fft).max())
    contour_LU = ax4.contourf(X,Y,u_LU, levels = 11, cmap = 'bwr', vmin = -abs(u_LU).max(), vmax = abs(u_LU).max())
    contour_green = ax5.contourf(X,Y,u_green, levels = 11, cmap = 'bwr', vmin = -abs(u_green).max(), vmax = abs(u_green).max())
    contour_LU_inf = ax6.contourf(X,Y,u_LU_inf_space, levels = 11, cmap = 'bwr', vmin = -abs(u_LU_inf_space).max(), vmax = abs(u_LU_inf_space).max())
    
    ax1.set_xlabel('x');ax1.set_ylabel('y')
    ax2.set_xlabel('x');ax2.set_ylabel('y')
    ax2.set_title("DST-I solution")
    ax3.set_xlabel('x');ax3.set_ylabel('y')
    ax3.set_title("FFT solution")
    ax4.set_xlabel('x');ax4.set_ylabel('y')
    ax4.set_title("LU Solution")
    ax5.set_xlabel('x');ax5.set_ylabel('y')
    ax5.set_title("inf. space green sol.")
    ax6.set_xlabel('x');ax6.set_ylabel('y')
    ax6.set_title("LU inf. space sol.")
    
    plt.colorbar(contour_dst)
    plt.colorbar(contour_fft)
    plt.colorbar(contour_LU)

plt.colorbar(contour_green)
plt.colorbar(contour_LU_inf)
plt.show()

error_L2(u_ex,u_green)
error_L2(u_ex,u_LU_inf_space)

That’s quite a lengthy problem statement…
Your conceptual MWE appears to be the Gaussian example. So let’s focusing on that:

At x=-5 and x=6 (the boundary of your extended domain, if I understand well), your analytical solution is pretty much exactly 0. So you’re not making any error in the problem statement. That means the L2 error of a properly implemented FEM should be purely discretization error. To confirm this, and get a feel for where that error originates from (your unit square or the extended domain), I’d:

  • Perform a unified mesh refinement study on the extended domain. The L2 error should decrease to zero at rate p+1. This would indicate your implementation is correct.
  • Prescribe the analytical solution at the boundary of the unit square. If there errors on the grid that you mean to use are the same, this indicates you’re not doing anything wrong in the domain extension but it’s just an internal mesh resolution issue.

For most problems, LU solvers simply “properly” solve the discrete system. That shouldn’t be your issue.

There are two ways for solving this issue for Poisson problems that I use:

  1. Use coordinate stretching in a boundary layer to effectively simulate a much larger domain. The example of PMLs does it for Helmholtz equation which needs complex stretching while you will just have to stretch the real space for Poisson equation.
  2. If all you need is to have a solution within your field of view that does not suffer from boundary effects then it can be achieved by solving the problem twice: once with Dirichlet and again with Neumann boundaries (you will still need one dof to be defined for uniqueness). The average of the two solutions will get rid of the images appearing across the boundary leaving you the infinite space solution within your simulation domain. The method of course works for linear problems only.

There is definitely something wrong in your post-processing, as the L2-error when using DOLFINx to compute it is an order of magnitude smaller:

u_LU_inf_space = solve_poisson_fenics(D_interp, True, NAME = "uh_inf")# Create functional expressions to put into Dolfinx

x ,y= ufl.SpatialCoordinate(u_LU_inf_space.function_space.mesh)
u_ex_ufl = ufl.exp(-a*((x-1/2)**2 + (y-1/2)**2))
err = u_LU_inf_space.function_space.mesh.comm.allreduce(dolfinx.fem.assemble_scalar(dolfinx.fem.form(ufl.inner(u_LU_inf_space-u_ex_ufl,u_LU_inf_space-u_ex_ufl)*ufl.dx)), op=MPI.SUM)
print(f"{np.sqrt(err):.5e}")

yielding

1.99896e-03

That being said, all of the other statements made by the others also makes sense.

This was more activity than I was expecting. Thank you @Stein, @sbhasan, and @dokken for your time!

The extended domain’s boundary was at x=-2, and x=3. While we’re on the subject, is there a good rule of thumb for how far to extend the domain?

I increased the number of points in both directions to double so that N=120, and the error fell to bit over half of what they were. Changing the order of the functionspace reduced the error to a bit under half. Does this reduction in error fall in line with the expected rate?

I changed the lines handling the boundary condition to

    uD = df.fem.Function(S)
    uD.interpolate(lambda x: np.exp(-50*((x[0]-1/2)**2 + (x[1]-1/2)**2)) ) 
    boundary_facets = df.mesh.exterior_facet_indices(mesh.topology)
    boundary_dofs = df.fem.locate_dofs_topological(S,fdim, boundary_facets)
    bc = df.fem.dirichletbc(uD,boundary_dofs)

so that the values at the boundary would be the true analytic values of the solution at those points. The error remained completely unchanged, so it looks like that test passed.

So, if I have performed the tests right, it probably was just discretization error and everything is functioning properly. The solutions were also interpolated over from being Dolfinx functions to numpy arrays, and the L2 error check was performed on the interpolated values. The L2 error of the Dolfinx functions are the same as those found by @dokken which was on the order of 1e-3, so the error was actually better than that of the Green’s function solution for the manufactured case.

I have never heard of a “perfectly matched layer” before or the concept of “coordinate stretching”, so I will need to take some time to look at the example. Also, the example you linked says it is solving Maxwell’s Equations, not Helmholtz. You said “complex stretching”, and I’m guessing that’s accomplished via the coordinate transform

x' = x\Bigg\{1 + j\frac{\alpha}{k_0}\frac{|x| - l_{dom}/2}{(l_{pml}/2-l_{dom}/2)^2}\Bigg\}

This method has more than a couple moving parts, so this might take a while. I will dive deeper into this and see if I cannot adapt it into my code to solve the problem and see how it stacks to the other methods.

As for your second suggestion, there are 2 things I want some clarification on. The first is just about definitions:

  1. You said “suffer from boundary effects”, could you clarify what you mean by “boundary effects”? I’m not familiar with it.

The second is about boundary conditions:

  1. Is the boundary condition in each case homogeneous i.e. 0 for Dirichlet and 0 for Neumann (as well as 0 for one of the nodes to produce a unique solution as you mentioned)? Or do I need the exact inhomogeneous values and fluxes for this to work?
1 Like

Maxwell’s equations are the starting point. For wave problems, they are reduced to Helmholtz equation (scalar or vector, depending upon some factors). I have a working model that uses infintie element (IE) but in axi-symmetry geometry (similar to official demo). For rectangular field of view, the following is a quick guess for the transformation you need:

x^\prime=x_0+x\alpha\left(\frac{|x-x_0|}{\delta}\right)^n,

where x_0 is the beginning of IE, \delta is its geometrical thickness, n is the polynomial order (I will try 1 or 2 but not more) that controls the smoothness and \alpha > 1 is the parameter which controls the magnitude of stretching (\alpha=1 means no stretching).

If the boundaries in your numerical model are not part of the experimental situation you are modeling then there may be unwanted effects on your solution due to boundaries. IE and the other approach I described are used to get rid of these effects.

In one case, use homogeneous Dirichlet boundaries. In the second case, apply homogeneous Neumann boundaries (basically do nothing since it’s the natural boundary condition). However, make sure to make just one dof at the boundary to be homogeneous Dirichlet which is required to get a unique solution. Then take average of the two results to get solution for infinite space.

1 Like

For your averaging approach which looked much simpler to test first (I have not attempted the stretching method yet), I have the following results for the manufactured example

where the L2 error for the dolfinx function containing the solution for the averaged method was 1.55000e-03. The function interpolated over into a numpy array also had much better L2 error at

Error_L2: 4.26e-02
Error_max: 8.40e-02

When I tested this method out on the data-driven example however, the results looked a bit strange with what I am assuming is an artifact in the upper-right corner

If you would like the exact data file I am using to see if you get similar results, you can direct message me an email and I can send it to you through that. Otherwise, please let me know what you think about the results!


I’ll post my code down here:

I edited my solver function to now pass in a type for whether we want to solve the problem with Dirichlet boundary conditions on the square, using an average, or using the extended space. Here is the updated function:

def solve_poisson_fenics(f, TYPE, NAME = "uh"):
    '''
    TYPE = "inf" uses the extended space domain
    TYPE = "pseudo" uses a workaround to achieve infinite space solution without extending domain
    TYPE = "finite" solves the poisson equation with homogeneous Dirichlet boundary conditions
    '''
    family = "Lagrange"
    order = 1
    def scipy_to_fenics_fun(x):
        '''
        function to move from our scipy interpolated function 
        to a Dolfinx function
        '''
        # To return a scipy function value, we need to pass in a tuple (x,y)
        point = (x[0], x[1])
        return f(point)
    
    if TYPE == "inf": 
        gmsh.initialize()
        #gmsh.option.setNumber("General.Terminal", 0)# prevent output

        # ---- Create Graded mesh ---- 
        L = 1 # Length of actual domain
        L_outer = 5 # Length of outer domain (where space goes to "infinity") 
        
        coarse_h = h*40 
        fine_h = h      

        # lower-left corner coordinates of full and inner domain
        x_i, y_i, z_i = x.min(), y.min(), 0 # (0,0,0)
        x_c, y_c = (x.max()-x.min())/2, (y.max()-y.min())/2
        x_f, y_f, z_f = x_c-(L_outer)/2, y_c-(L_outer)/2, 0 # (-2,-2,0)
        full_square = gmsh.model.occ.add_rectangle(x_f, y_f, z_f, dx = L_outer, dy = L_outer) # full domain
        inner_square = gmsh.model.occ.add_rectangle(x_i, y_i, z_i, dx = L, dy = L) # inner domain

        # overlap the squares
        gmsh.model.occ.fragment([(2, full_square)], [(2, inner_square)])
        gmsh.model.occ.synchronize()

        surfaces = gmsh.model.getEntities(dim=2)
        surface_ids = [s[1] for s in surfaces]
        surface_tag = 1

        # Tag physical entities
        gmsh.model.addPhysicalGroup(2, surface_ids, tag=surface_tag)
        gmsh.model.setPhysicalName(2, surface_tag, "msh_domain")

        gmsh.model.mesh.field.add("Distance", 1)
        gmsh.model.mesh.field.setNumbers(1, "FacesList", [inner_square])
        gmsh.model.mesh.field.add("Threshold", 2)
        gmsh.model.mesh.field.setNumber(2, "InField", 1)
        gmsh.model.mesh.field.setNumber(2, "SizeMin", fine_h)
        gmsh.model.mesh.field.setNumber(2, "SizeMax", coarse_h)
        gmsh.model.mesh.field.setNumber(2, "DistMin", 0)              # Grid starts coarsening immediately outisde of inner square
        gmsh.model.mesh.field.setNumber(2, "DistMax", L)              # Grid hits target coarseness resolution at L distance away from boundaries
        gmsh.model.mesh.field.setAsBackgroundMesh(2)
        gmsh.model.mesh.generate(2)

        # Create dolfinx mesh
        model_rank = 0
        mesh = df.io.gmshio.model_to_mesh(gmsh.model, MPI.COMM_WORLD, rank=0, gdim=2)[0]
        gmsh.finalize()
        
        prefix = "infinite_space"
    elif TYPE == "pseudo":
        mesh = df.mesh.create_unit_square(MPI.COMM_WORLD, N-1,N-1)
        prefix = "pseudo"
        # ---- Solve Neumann homogeneous ----
        # pin one dof to 0
        left_bound = x.min()
        low_bound = y.min()
        S = df.fem.functionspace(mesh, (family, order)) 
        def lower_left_corner(x):
            return np.isclose(x[0],0) & np.isclose(x[1],0)
        pinned_dof = df.fem.locate_dofs_geometrical(S,lower_left_corner)
        pinned_bc = df.fem.dirichletbc(df.default_scalar_type(0),pinned_dof,S)
        print(f"left bound: {left_bound}, lower bound: {low_bound}, dof: {pinned_dof}, pinned value: {pinned_bc.g.value}")

        # forcing
        forcing = df.fem.Function(S)
        forcing.interpolate(scipy_to_fenics_fun)
        
        u, v = ufl.TrialFunction(S), ufl.TestFunction(S)
        a = ufl.dot(ufl.grad(u), ufl.grad(v)) * ufl.dx
        L = -forcing * v * ufl.dx
        problem = df.fem.petsc.LinearProblem(a, L, bcs=[pinned_bc],
                                         petsc_options_prefix="homog_neu",petsc_options={"ksp_type": "preonly", "pc_type": "lu"})
        u_neu = problem.solve()
        
    else:
        mesh = df.mesh.create_unit_square(MPI.COMM_WORLD, N-1,N-1)
        prefix = "finite"
    tdim = mesh.topology.dim
    fdim = tdim-1
    mesh.topology.create_connectivity(fdim, tdim)
    if TYPE != "pseudo":
        S = df.fem.functionspace(mesh, (family, order)) # Scalar space to store divergence
        # forcing term
        forcing = df.fem.Function(S)
        forcing.interpolate(scipy_to_fenics_fun)
    
    # Dirichlet boundary condition
    boundary_facets = df.mesh.exterior_facet_indices(mesh.topology)
    boundary_dofs = df.fem.locate_dofs_topological(S,fdim, boundary_facets)
    bc = df.fem.dirichletbc(df.fem.Constant(mesh,df.default_scalar_type(0)),boundary_dofs,S)
    
    # Solve
    u, v = ufl.TrialFunction(S), ufl.TestFunction(S) 
    a = ufl.dot(ufl.grad(u),ufl.grad(v)) * ufl.dx
    L = -forcing*v*ufl.dx
    problem = LinearProblem(a=a,L=L,bcs = [bc],
                       petsc_options_prefix=f"{prefix}_Poisson",petsc_options={"ksp_type": "preonly", "pc_type": "lu"}) 
    uh = problem.solve()

    if TYPE == "pseudo":
        u_inf_space = df.fem.Function(S)
        u_inf_space.x.array[:] = (uh.x.array[:] + u_neu.x.array[:])/2
        uh = u_inf_space.copy()
    return uh

where if TYPE =="pseudo, it will use the unit square mesh and then solve the Poisson Equation with one degree of freedom pinned down to 0. The solution is then averaged with the solution for homogeneous dirichlet boundary values uh using

if TYPE == "pseudo":
        u_inf_space = df.fem.Function(S)
        u_inf_space.x.array[:] = (uh.x.array[:] + u_neu.x.array[:])/2
        uh = u_inf_space.copy()
return uh

I think it is a good idea to use the averaging approach instead of infinite elements. The latter takes some tweaking at first attempt to be sure they are working well. In contrast, the averaging approach is simple and there is no reason it can fail for linear problems.

I am missing what you mean by data-driven example and even what is anomalous in the current results. If all else is fine, I will suggest to move the reference point in the Neumann case away from the corner. I cannot say it is certainly problematic, but it is certainly a less trivial point compared to somewhere else on the boundary.

It just means an example in which we are using real world data, rather than a simple analytically defined function like in the manufactured solution example (the Gaussian with a known solution). Maybe that’s not official lexicon, in which case sorry for the confusion. So yeah, I’m just referring to the example in which the source term is the divergence data taken from the data file in my code.

I want to preface by saying that I noticed a mistake when I changed my scipy interpolated function for the forcing term into a Dolfinx function and realized that my x and y were swapped. Now that I have it corrected, I have found out that the “artifact” I mentioned in the upper-right corner was actually the point where the Dirichlet value was set to 0. So, now that we know the context of this absolute minimum in the data is simply where we set the dirichlet value, this wil make it easier to understand the plots when I move the node we assign a Dirichlet value to.

So, upon changing the node which we designate a 0 value to right next to the corner at (1/6,0), this resulted in the following plot (lower-right plot is the one using the averaged solution, other 2 solution plots are for comparison)

where we notice an absolute minimum at position (1/6,0). I tried setting it to the halfway point on the boundary as well by choosing coordinate (0.5,0), and the results looked roughly the same, except with the absolute minima shifting over to the right. The results show a more pronounced gradient in the solution (there barely was one prior), which is good, but needs to be stronger like the one in the Green’s function or LU over extended space solutions. I wonder why this method worked perfectly for the manufactured solution case (the Gaussian), but is having issues with the example using data.