#!/usr/bin/env python # coding: utf-8 # This tutorial forms part of the documentation for Bempp. Find out more at bempp.com # # Simple FEM-BEM Coupling for the Helmholtz Equation # ## Background # For this problem, you will need FEniCS installed alongside Bempp. If FEniCS is not available on your system you can use the Docker image from bempp.com # # In this tutorial, we will solve the problem of a wave travelling through a unit cube, $\Omega = [0,1]^3$ with different material parameters inside and outside the domain. The incident wave is given by # # $$ # u^\text{inc}(\mathbf{x})=\mathrm{e}^{\mathrm{i} k \mathbf{x}\cdot\mathbf{d}}, # $$ # # where $\mathbf{x}=(x,y,z)$ and $\mathbf{d}$ is the direction of the incident wave. In the implementation we use, $\mathbf{d} = \frac{1}{\sqrt{3}}(1,1,1)$. # # The PDE is # # $$ # \Delta u + n(\mathbf{x})^2 k^2 u = 0, \quad \text{ in } \Omega\\ # \Delta u + k^2 u = 0, \quad \text{ in } \mathbb{R}^3 \backslash \Omega # $$ # # In this example, we use # # $$ # n(\mathbf{x}) = 0.5 # $$ # Since the interior wavenumber is constant one could have also used a BEM/BEM coupling approach. However, here we demonstrate the use of FEM for the interior problem using the FEniCS finite element package. # ### FEM Part # In $\Omega$, the FEM part is formulated as # # $$ # \int_\Omega \nabla u\cdot\nabla v -k^2\int_\Omega n^2uv - \int_{d\Omega} v\frac{\partial u}{\partial \nu} = 0, # $$ # # or # # $$ # \langle\nabla u,\nabla v\rangle_\Omega - k^2\langle n^2u,v\rangle_\Omega - \langle \lambda,v\rangle_\Gamma=0, # $$ # # where $\lambda=\frac{\partial u}{\partial \nu}$. # # Later, we will write this as the following operator equation # # $$ # \mathsf{A}u-k^2 \mathsf{M}u-\mathsf{M}_\Gamma \lambda = 0 # $$ # ### BEM Part # In $\mathbb{R}^3 \backslash \Omega$, we let $u = u^\text{inc}+u^\text{s}$, where $u^\text{inc}$ is the incident wave and $u^\text{s}$ is the scattered wave. As given in Integral equation methods in scattering theory by Colton & Kress, # # $$ # 0 = \mathcal{K}u^\text{inc}-\mathcal{V}\frac{\partial u^{inc}}{\partial \nu},\\[2mm] # u^\text{s} = \mathcal{K}u^\text{s}-\mathcal{V}\frac{\partial u^{s}}{\partial \nu}, # $$ # where $\mathcal{K}$ and $\mathcal{V}$ are the double single layer potential operators. Adding these, we get # # $$ # u^\text{s} = \mathcal{K}u-\mathcal{V}\lambda. # $$ # # This representation formula will be used to find $u^\text{s}$ for plotting later. # # Taking the trace on the boundary gives # # $$ # u-u^\text{inc} = \left(\tfrac{1}{2}\mathsf{Id}+\mathsf{K}\right)u -\mathsf{V}\lambda. # $$ # # This rearranges to # # $$ # u^\text{inc} = \left(\tfrac{1}{2}\mathsf{Id}-\mathsf{K}\right)u+\mathsf{V}\lambda. # $$ # ### Full Formulation # The full blocked formulation is # # $$ # \begin{bmatrix} # \mathsf{A}-k^2 \mathsf{M} & -\mathsf{M}_\Gamma\\ # \tfrac{1}{2}\mathsf{Id}-\mathsf{K} & \mathsf{V} # \end{bmatrix} # \begin{bmatrix} # u\\ # \lambda # \end{bmatrix}=\begin{bmatrix} # 0\\ # u^\text{inc} # \end{bmatrix}. # $$ # # This formulation is not stable for all frequencies due to the possibility of interior resonances. But it is sufficient for this example and serves as a blueprint for more complex formulations. # ## Implementation # We begin by importing Dolfin, the FEniCS python library, Bempp and NumPy. # In[1]: import dolfin import bempp.api import numpy as np # Next, we set the wavenumber ``k`` and the direction ``d`` of the incoming wave. # In[2]: k = 6. d = np.array([1., 1., 1]) d /= np.linalg.norm(d) # We create a Dolfin mesh. Later, the boundary mesh will be extracted from this. # # A mesh could be created from a file changing this line to ``mesh = dolfin.Mesh('/path/to/file.xml')``. # In[3]: mesh = dolfin.UnitCubeMesh(10, 10, 10) # Next, we make the Dolfin and Bempp function spaces. # # The function ``coupling.fenics_to_bempp_trace_data`` will extract the trace space from the Dolfin space and create the matrix ``trace_matrix``, which maps between the dofs (degrees of freedom) in Dolfin and Bempp. # In[4]: from bempp.api import fenics_interface fenics_space = dolfin.FunctionSpace(mesh, "CG", 1) trace_space, trace_matrix = \ fenics_interface.coupling.fenics_to_bempp_trace_data(fenics_space) bempp_space = bempp.api.function_space(trace_space.grid, "DP", 0) print("FEM dofs: {0}".format(mesh.num_vertices())) print("BEM dofs: {0}".format(bempp_space.global_dof_count)) # We create the boundary operators that we need. # In[5]: id_op = bempp.api.operators.boundary.sparse.identity( trace_space, bempp_space, bempp_space) mass = bempp.api.operators.boundary.sparse.identity( bempp_space, bempp_space, trace_space) dlp = bempp.api.operators.boundary.helmholtz.double_layer( trace_space, bempp_space, bempp_space, k) slp = bempp.api.operators.boundary.helmholtz.single_layer( bempp_space, bempp_space, bempp_space, k) # We create the Dolfin function spaces and the function (or in this case constant) ``n``. # In[6]: u = dolfin.TrialFunction(fenics_space) v = dolfin.TestFunction(fenics_space) n = 0.5 # We make the vectors on the right hand side of the formulation. # In[7]: def u_inc(x, n, domain_index, result): result[0] = np.exp(1j * k * np.dot(x, d)) u_inc = bempp.api.GridFunction(bempp_space, fun=u_inc) # The rhs from the FEM rhs_fem = np.zeros(mesh.num_vertices()) # The rhs from the BEM rhs_bem = u_inc.projections(bempp_space) # The combined rhs rhs = np.concatenate([rhs_fem, rhs_bem]) # We are now ready to create a ``BlockedLinearOperator`` containing all four parts of the discretisation of # $$ # \begin{bmatrix} # \mathsf{A}-k^2 \mathsf{M} & -\mathsf{M}_\Gamma\\ # \tfrac{1}{2}\mathsf{Id}-\mathsf{K} & \mathsf{V} # \end{bmatrix}. # $$ # In[8]: from bempp.api.fenics_interface import FenicsOperator from scipy.sparse.linalg.interface import LinearOperator blocks = [[None,None],[None,None]] trace_op = LinearOperator(trace_matrix.shape, lambda x:trace_matrix*x) A = FenicsOperator((dolfin.inner(dolfin.nabla_grad(u), dolfin.nabla_grad(v)) \ - k**2 * n**2 * u * v) * dolfin.dx) blocks[0][0] = A.weak_form() blocks[0][1] = -trace_matrix.T * mass.weak_form().sparse_operator blocks[1][0] = (.5 * id_op - dlp).weak_form() * trace_op blocks[1][1] = slp.weak_form() blocked = bempp.api.BlockedDiscreteOperator(np.array(blocks)) # Next, we solve the system, then split the solution into the parts assosiated with u and λ. For an efficient solve, preconditioning is required. # In[9]: from scipy.sparse.linalg import LinearOperator # Compute the sparse inverse of the Helmholtz operator # Although it is not a boundary operator we can use # the SparseInverseDiscreteBoundaryOperator function from # BEM++ to turn its LU decomposition into a linear operator. P1 = bempp.api.InverseSparseDiscreteBoundaryOperator( blocked[0,0].sparse_operator.tocsc()) # For the Laplace slp we use a simple mass matrix preconditioner. # This is sufficient for smaller low-frequency problems. P2 = bempp.api.InverseSparseDiscreteBoundaryOperator( bempp.api.operators.boundary.sparse.identity( bempp_space, bempp_space, bempp_space).weak_form()) # Create a block diagonal preconditioner object using the Scipy LinearOperator class def apply_prec(x): """Apply the block diagonal preconditioner""" m1 = P1.shape[0] m2 = P2.shape[0] n1 = P1.shape[1] n2 = P2.shape[1] res1 = P1.dot(x[:n1]) res2 = P2.dot(x[n1:]) return np.concatenate([res1, res2]) p_shape = (P1.shape[0] + P2.shape[0], P1.shape[1] + P2.shape[1]) P = LinearOperator(p_shape, apply_prec, dtype=np.dtype('complex128')) # Create a callback function to count the number of iterations it_count = 0 def count_iterations(x): global it_count it_count += 1 from scipy.sparse.linalg import gmres soln, info = gmres(blocked, rhs, M=P, callback=count_iterations) soln_fem = soln[:mesh.num_vertices()] soln_bem = soln[mesh.num_vertices():] print("Number of iterations: {0}".format(it_count)) # Next, we make Dolfin and Bempp functions from the solution. # In[10]: # Store the real part of the FEM solution u = dolfin.Function(fenics_space) u.vector()[:] = np.ascontiguousarray(np.real(soln_fem)) # Solution function with dirichlet data on the boundary dirichlet_data = trace_matrix * soln_fem dirichlet_fun = bempp.api.GridFunction(trace_space, coefficients=dirichlet_data) # Solution function with Neumann data on the boundary neumann_fun = bempp.api.GridFunction(bempp_space, coefficients=soln_bem) # We now evaluate the solution on the slice $z=0.5$ and plot it. For the exterior domain, we use the respresentation formula # # $$ # u^\text{s} = \mathcal{K}u-\mathcal{V}\frac{\partial u}{\partial \nu} # $$ # # to evaluate the solution. # In[11]: # The next command ensures that plots are shown within the IPython notebook get_ipython().run_line_magic('matplotlib', 'inline') # Reduce the H-matrix accuracy since the evaluation of potentials for plotting # needs not be very accurate. bempp.api.global_parameters.hmat.eps = 1E-2 Nx=200 Ny=200 xmin, xmax, ymin, ymax=[-1,3,-1,3] plot_grid = np.mgrid[xmin:xmax:Nx*1j,ymin:ymax:Ny*1j] points = np.vstack((plot_grid[0].ravel(), plot_grid[1].ravel(), np.array([0.5]*plot_grid[0].size))) plot_me = np.zeros(points.shape[1], dtype=np.complex128) x,y,z = points bem_x = np.logical_not((x>0) * (x<1) * (y>0) * (y<1) * (z>0) * (z<1)) slp_pot= bempp.api.operators.potential.helmholtz.single_layer( bempp_space, points[:, bem_x], k) dlp_pot= bempp.api.operators.potential.helmholtz.double_layer( trace_space, points[:, bem_x], k) plot_me[bem_x] += np.exp(1j * k * (points[0, bem_x] * d[0] \ + points[1, bem_x] * d[1] \ + points[2, bem_x] * d[2])) plot_me[bem_x] += dlp_pot.evaluate(dirichlet_fun).flat plot_me[bem_x] -= slp_pot.evaluate(neumann_fun).flat fem_points = points[:, np.logical_not(bem_x)].transpose() fem_val = np.zeros(len(fem_points)) for p,point in enumerate(fem_points): result = np.zeros(1) u.eval(result, point) fem_val[p] = result[0] plot_me[np.logical_not(bem_x)] += fem_val plot_me = plot_me.reshape((Nx, Ny)) plot_me = plot_me.transpose()[::-1] # Plot the image from matplotlib import pyplot as plt fig=plt.figure(figsize=(10, 8)) plt.imshow(np.real(plot_me), extent=[xmin, xmax, ymin, ymax]) plt.xlabel('x') plt.ylabel('y') plt.colorbar() plt.title("FEM-BEM Coupling for Helmholtz") plt.show()