Wrong Solution from Helmholtz Solver

Hi!

I am working with a slightly more general version of the Helmholtz equation in Acoustics. My equation just includes two material damping parameters, \eta and \alpha. I derived the weak form here. I have implemented a FEniCS solver for it but it provides the wrong result. The details of my problem are below.

To verify that things work I have created a simple domain. It is a sphere of 0.1 meters radius. I cut off a sphere of 5 mm radius from the center of it. A slice of the mesh (Salome) is shown below.

We can call the inner surface (5 mm radius) surface 1. We can call the outer surface (0.1 m radius) surface 0. I want to apply an uniform velocity to surface 1. This is a Neumann boundary condition which will make the inner surface a pulsating sphere. Then I want to apply a matched impedance to surface 0 so that there will be no reflections. This is a Robin boundary condition.

My weak form is as follows:

- \gamma \int_{\Omega} \nabla u \cdot \nabla \phi^\star d\mathbf{x} + \beta \int_{\Omega} u \phi^{\star} d\mathbf{x} + \frac{j \omega \rho \gamma}{z \left( 0.1 \text{ m} \right)} \int_{\partial \Omega_{0}} u \phi^\star ds = - j \omega \rho \gamma U \int_{\partial \Omega_{1}} \phi^\star ds

Where:

  • \Omega is the domain, \partial\Omega_{0} and \partial\Omega_{1} are the outer and inner surfaces respectively;
  • u: \Omega \rightarrow \mathbb{C} is the trial function;
  • \phi: \Omega \rightarrow \mathbb{C} is the test function;
  • \gamma \doteq 1 + j k \frac{\eta}{c}, with \eta a damping parameter (set to 0 for this simulation);
  • \beta \doteq k^2 - jk \frac{\alpha}{c}, with \alpha a damping parameter (set to 0 for this simulation);
  • k = \frac{\omega}{c} = \frac{2 \pi \nu}{c}, with \nu the frequency of the simulation (1000 Hz) and c the speed of sound in air (343 m/s);
  • z is the uniform specific impedance of outgoing spherical waves: z\left(r\right)=\rho c \frac{jkr}{1+jkr}, with \rho the density of air (1.205 kg/m^3);
  • U is the uniform normal velocity of the inner surface, set to \frac{\sqrt{2}}{2} + j\frac{\sqrt{2}}{2} m/s for this simulation.
  • j is the imaginary unit. \star denotes complex conjugation.

z and U pop out of the integrals due to being uniform. Since \eta = \alpha = 0 I expect the solution to be that of the lossless pulsating sphere:

u\left(r\right) = z\left(a \right)\frac{aU}{r}\exp\left(-jk \left( r-a\right) \right)

With a the radius of the source (5 mm). However, my implementation below is quite wrong (sorry for it being 222 lines of code, this is the most I could squeeze it):

import pathlib
import meshio
import tempfile
import numpy as np
import fenics
import matplotlib.pyplot as plt


# This is a 3D domain which is a sphere of 0.1 m radius.
# A smaller sphere of 5 mm radius is cutout from its center.
# We will apply a uniform velocity (Neumann) boundary condition to the inner surface.
# We will apply an impedance (Robin) boundary condition to the outer surface.
# Since the mesh is in mm, we convert to m with `coordinate_scaling`.
mesh_path = pathlib.Path('spherecut.med')
coordinate_scaling = 1.0e-3

# We read te MED file with meshio
raw_mesh_data = meshio.read(mesh_path)
# MED cell tags are negative, but FEniCS doesn't like it. We convert to positive by applying this offset.
cell_tag_offset = np.abs(np.min(list(raw_mesh_data.cell_tags.keys())))

# This list will contain FEniCS meshes, in this order: line (1D), triangle (2D) and tetra (3D).
# To make the FEniCS meshes we crate meshio meshes for each entity, save it to a temporary location as xdmf, and then
# we read it back.
mesh = fenics.Mesh()
mvc_3d = fenics.MeshValueCollection('size_t', mesh, 3)
mvc_2d = fenics.MeshValueCollection('size_t', mesh, 2)
mvc_1d = fenics.MeshValueCollection('size_t', mesh, 1)
with tempfile.TemporaryDirectory() as xdmf_dir:
    target_xdmf_dir = pathlib.Path(xdmf_dir)

    for entity in ['tetra', 'triangle', 'line']:
        cells = raw_mesh_data.get_cells_type(entity)
        cell_data = raw_mesh_data.get_cell_data('cell_tags', entity)
        points = raw_mesh_data.points  # 3D Mesh: No pruning needed.
        edited_mesh_data = meshio.Mesh(
            points=points * coordinate_scaling,
            cells={entity: cells},
            cell_data={'marker': [cell_data + cell_tag_offset]}
        )

        tmp_path = target_xdmf_dir.joinpath(entity + '_mesh.xdmf')
        meshio.write(tmp_path.absolute().as_posix(), edited_mesh_data)
        with fenics.XDMFFile(tmp_path.absolute().as_posix()) as xdmf_file:
            if entity == 'tetra':
                xdmf_file.read(mesh)
                xdmf_file.read(mvc_3d, 'marker')
            if entity == 'triangle':
                xdmf_file.read(mvc_2d, 'marker')
            if entity == 'line':
                xdmf_file.read(mvc_1d, 'marker')

markers = fenics.MeshFunction('size_t', mesh, mvc_3d)
sub_domains_2d = fenics.MeshFunction('size_t', mesh, mvc_2d)
sub_domains_1d = fenics.MeshFunction('size_t', mesh, mvc_1d)

# We have the mesh, now for the simulation. The simulation parameters are below.
frequency = 1000.0
speed_of_sound = 343.0
density = 1.205
eta = 0.0
alpha = 0.0
source_velocity = (1.0 + 1.0j) / np.sqrt(2.0)

# We now define the function space. The PDE is complex, so we use mixed space.
base_element = fenics.FiniteElement(
    family='Lagrange',
    cell=mesh.ufl_cell(),
    degree=1
)
# element = fenics.MixedElement([base_element, base_element])
# f_space = fenics.FunctionSpace(mesh, element)
f_space = fenics.FunctionSpace(mesh, base_element * base_element)

# Now we can get the trial and test functions (both parts)
trial_re, trial_im = fenics.TrialFunction(V=f_space)
test_re, test_im = fenics.TestFunction(V=f_space)

# We compute a bunch of useful quantities here.
angular_frequency = 2.0 * np.pi * frequency
wave_number = angular_frequency / speed_of_sound
gamma = 1.0 + 1.0j * wave_number * eta / speed_of_sound
beta = wave_number ** 2 - 1.0j * alpha / speed_of_sound
g_flux = 1.0j * angular_frequency * density * gamma

# Nice. Let's start with the first two terms of the bilinear form.
# This is - \gamma \int_{\Omega} \nabla u \cdot \nabla \phi^\star d\mathbf{x}
# (all expanded in real and imaginary parts)
u_gd_re = fenics.grad(trial_re)
u_gd_im = fenics.grad(trial_im)
p_gd_re = fenics.grad(test_re)
p_gd_im = fenics.grad(test_im)

md_dot_re = fenics.dot(u_gd_re, p_gd_re) + fenics.dot(u_gd_im, p_gd_im)
md_dot_im = fenics.dot(u_gd_im, p_gd_re) - fenics.dot(u_gd_re, p_gd_im)
g_md_dot_re = fenics.Constant(-gamma.real)
g_md_dot_im = fenics.Constant(-gamma.imag)
md_dot_term_re = g_md_dot_re * md_dot_re - g_md_dot_im * md_dot_im
md_dot_term_im = g_md_dot_re * md_dot_im + g_md_dot_im * md_dot_re

# This is \beta \int_{\Omega} u \phi^{\star} d\mathbf{x}
# (all expanded in real and imaginary parts)
md_prod_re = trial_re * test_re + trial_im * test_im
md_prod_im = trial_im * test_re - trial_re * test_im
g_md_prod_re = fenics.Constant(beta.real)
g_md_prod_im = fenics.Constant(beta.imag)
md_prod_term_re = g_md_prod_re * md_prod_re - g_md_prod_im * md_prod_im
md_prod_term_im = g_md_prod_re * md_prod_im + g_md_prod_im * md_prod_re

# Now that we have all the bits we sum them together.
bilinear_form = (
        md_dot_term_re * fenics.dx +
        md_dot_term_im * fenics.dx +
        md_prod_term_re * fenics.dx +
        md_prod_term_im * fenics.dx
)

# For the boundary conditions, we produce the measure. According to `raw_mesh_data.cell_tags` the outer layer is marked
# with `-7`, the inner with `-6`. We need to add the offset (`7`). So the outer is marked with `0` and the inner with
# `1`.
ds_c = fenics.Measure('ds', domain=mesh, subdomain_data=sub_domains_2d)
# `ds_c(0)` should return a measure over the outer shell.
# `ds_c(1)` should return a measure over the inner shell.

# We also compute the radii of the inner and outer shells straight from the mesh data.
outer_radius = np.max(np.sqrt(np.sum((coordinate_scaling * raw_mesh_data.points) ** 2, axis=1)))
inner_radius = np.min(np.sqrt(np.sum((coordinate_scaling * raw_mesh_data.points) ** 2, axis=1)))

# For the outer shell, we apply an impedance that is matched to that of a spherical wave (no reflections).
# This is a Robin boundary condition.
# This adds this term to the bilinear form:
# j \omega \rho \gamma \int_{\partial \Omega_{0}} \frac{u}{z} \phi^\star ds
jkr = 1.0j * wave_number * outer_radius
impedance = density * speed_of_sound * jkr / (1.0 + jkr)
g_rb = g_flux / impedance

rb_prod_re = trial_re * test_re + trial_im * test_im
rb_prod_im = trial_im * test_re - trial_re * test_im
g_rb_re = fenics.Constant(g_rb.real)
g_rb_im = fenics.Constant(g_rb.imag)
rb_term_re = g_rb_re * rb_prod_re - g_rb_im * rb_prod_im
rb_term_im = g_rb_re * rb_prod_im + g_rb_im * rb_prod_re

bilinear_form += rb_term_re * ds_c(0) + rb_term_im * ds_c(0)

# For the inner shell we apply a normal velocity.
# This is a Neumann boundary condition.
# This adds this term to the linear form:
# - j \omega \rho \gamma \int_{\partial \Omega_{1}} w \phi^\star ds
g_nm = -1.0 * g_flux * source_velocity
g_nm_re = fenics.Constant(g_nm.real)
g_nm_im = fenics.Constant(g_nm.imag)
nm_prod_re = g_nm_re * test_re + g_nm_im * test_im
nm_prod_im = g_nm_im * test_re - g_nm_re * test_im

linear_form = nm_prod_re * ds_c(1) + nm_prod_im * ds_c(1)

# Cool, we are ready to solve!
u = fenics.Function(f_space)
fenics.solve(
    bilinear_form == linear_form,
    u,
    solver_parameters={
        'linear_solver': 'umfpack',
        # 'linear_solver': 'bicgstab',
        # 'preconditioner': 'ilu',
    },
)

# To check the results over the mesh nodes I like to write to VTU and read back.
# (it is easier than using the `u_re` and `u_im` objects).
u_re, u_im = u.split(deepcopy=True)
u_re.rename('re', 're')
u_im.rename('im', 'im')

vtk_file_1 = fenics.File('test_re.pvd')
vtk_file_1 << u_re

vtk_file_2 = fenics.File('test_im.pvd')
vtk_file_2 << u_im

u_re_vtu = meshio.read('test_re000000.vtu')
u_im_vtu = meshio.read('test_im000000.vtu')
nodes = u_re_vtu.points
u_re_at_nodes = u_re_vtu.point_data['re']
u_im_at_nodes = u_im_vtu.point_data['im']
u = u_re_at_nodes + 1j * u_im_at_nodes

# We compare with the exact lossless solution.
radii = np.sqrt(np.sum(nodes**2, axis=1))
jka = 1.0j * wave_number * inner_radius
z_a = density * speed_of_sound * jka / (1.0 + jka)
u_exact = z_a * inner_radius * source_velocity * np.exp(-1j * wave_number * (radii - inner_radius)) / radii

idx = np.argsort(radii)
error = u / u_exact

fig = plt.figure('Error Plot')
axs = fig.subplots(2, 1, sharex=True)
axs[0].grid(True, which='both')
axs[1].grid(True, which='both')
axs[1].set_xlabel('r [m]')
axs[0].set_ylabel('Magnitude [dB]')
axs[1].set_ylabel('Phase [rad]')
axs[0].plot(radii[idx], 20 * np.log10(np.abs(error[idx])))
axs[1].plot(radii[idx], np.angle(error[idx]))
plt.show()

fig = plt.figure('Solution Plot')
axs = fig.subplots(2, 1, sharex=True)
axs[0].grid(True, which='both')
axs[1].grid(True, which='both')
axs[1].set_xlabel('r [m]')
axs[0].set_ylabel('SPL [dBSPL]')
axs[1].set_ylabel('Phase [rad]')
axs[0].plot(radii[idx], 20 * np.log10(np.abs(u_exact[idx]) / (20e-6 * np.sqrt(2))))
axs[0].plot(radii[idx], 20 * np.log10(np.abs(u[idx]) / (20e-6 * np.sqrt(2))))
fig.legend(['Exact', 'FEniCS'])
axs[1].plot(radii[idx], np.angle(u_exact[idx]))
axs[1].plot(radii[idx], np.angle(u[idx]))
plt.show()

The mesh to run this code can be downloaded here.

Error plot in magnitude and phase:

Comparison with exact solution:

I have been trying to figure out what I did wrong for a couple of days without success, but I still feel like I am maybe missing something obvious.

It feels like there are reflections coming from the outer shell, so probably something dodgy with my impedance boundary condition. Any idea?

Hi Again!

So, I manage to fix it I think. I refactored my code quite a lot, to make sure I was doing things properly. However, what actually fixed it was to flip the sign of the impedance:

z\left(r\right)=-\rho c \frac{jkr}{1+jkr}

See the updated code and results below:

import pathlib
import meshio
import tempfile
import numpy as np
import fenics
import matplotlib.pyplot as plt


# This is a 3D domain which is a sphere of 0.1 m radius.
# A smaller sphere of 5 mm radius is cutout from its center.
# We will apply a uniform velocity (Neumann) boundary condition to the inner surface.
# We will apply an impedance (Robin) boundary condition to the outer surface.
# Since the mesh is in mm, we convert to m with `coordinate_scaling`.
mesh_path = pathlib.Path('spherecut.med')
coordinate_scaling = 1.0e-3

# We read te MED file with meshio
raw_mesh_data = meshio.read(mesh_path)
# MED cell tags are negative, but FEniCS doesn't like it. We convert to positive by applying this offset.
cell_tag_offset = np.abs(np.min(list(raw_mesh_data.cell_tags.keys())))

# To make a FEniCS mesh we create a new meshio mesh with the required properties and dump to file. Then we read back.
# All subdomains are read as `MeshValueCollection` objects, so that we can define markers and subdomains.
mesh = fenics.Mesh()
mvc_3d = fenics.MeshValueCollection('size_t', mesh, 3)
mvc_2d = fenics.MeshValueCollection('size_t', mesh, 2)
mvc_1d = fenics.MeshValueCollection('size_t', mesh, 1)
with tempfile.TemporaryDirectory() as xdmf_dir:
    target_xdmf_dir = pathlib.Path(xdmf_dir)

    for entity in ['tetra', 'triangle', 'line']:
        cells = raw_mesh_data.get_cells_type(entity)
        cell_data = raw_mesh_data.get_cell_data('cell_tags', entity)
        points = raw_mesh_data.points  # 3D Mesh: No pruning needed.
        edited_mesh_data = meshio.Mesh(
            points=points * coordinate_scaling,
            cells={entity: cells},
            cell_data={'marker': [cell_data + cell_tag_offset]}
        )

        tmp_path = target_xdmf_dir.joinpath(entity + '_mesh.xdmf')
        meshio.write(tmp_path.absolute().as_posix(), edited_mesh_data)
        with fenics.XDMFFile(tmp_path.absolute().as_posix()) as xdmf_file:
            if entity == 'tetra':
                xdmf_file.read(mesh)
                xdmf_file.read(mvc_3d, 'marker')
            if entity == 'triangle':
                xdmf_file.read(mvc_2d, 'marker')
            if entity == 'line':
                xdmf_file.read(mvc_1d, 'marker')

markers = fenics.MeshFunction('size_t', mesh, mvc_3d)
sub_domains_2d = fenics.MeshFunction('size_t', mesh, mvc_2d)
sub_domains_1d = fenics.MeshFunction('size_t', mesh, mvc_1d)

# We have the mesh, now for the simulation. The simulation parameters are below.
frequency = 1000.0
speed_of_sound = 343.0
density = 1.205
eta = 0.0
alpha = 0.0
source_velocity = (1.0 + 1.0j) / np.sqrt(2.0)

# We now define the function space. The PDE is complex, so we use mixed space.
base_element = fenics.FiniteElement(
    family='P',
    cell=mesh.ufl_cell(),
    degree=2
)
# element = fenics.MixedElement([base_element, base_element])
# f_space = fenics.FunctionSpace(mesh, element)
f_space = fenics.FunctionSpace(mesh, base_element * base_element)

# Now we can get the trial and test functions (both parts)
trial_re, trial_im = fenics.TrialFunction(V=f_space)
test_re, test_im = fenics.TestFunction(V=f_space)

# We compute a bunch of useful quantities here.
angular_frequency = 2.0 * np.pi * frequency
wave_number = angular_frequency / speed_of_sound
gamma = 1.0 + 1.0j * wave_number * eta / speed_of_sound
beta = wave_number ** 2 - 1.0j * alpha / speed_of_sound
g_flux = 1.0j * angular_frequency * density * gamma

# For the main terms we need the measure within the volume:
dx_volumes = fenics.Measure('dx', domain=mesh, subdomain_data=markers)

# Nice. Let's start with the first two terms of the bilinear form.
# This is - \gamma \int_{\Omega} \nabla u \cdot \nabla \phi^\star d\mathbf{x}
# (all expanded in real and imaginary parts)
bilinear_form = (
        -gamma.real * fenics.dot(fenics.grad(trial_re), fenics.grad(test_re)) * dx_volumes +
        -gamma.real * fenics.dot(fenics.grad(trial_im), fenics.grad(test_im)) * dx_volumes +
        -gamma.real * fenics.dot(fenics.grad(trial_im), fenics.grad(test_re)) * dx_volumes -
        -gamma.real * fenics.dot(fenics.grad(trial_re), fenics.grad(test_im)) * dx_volumes +
        -gamma.imag * fenics.dot(fenics.grad(trial_re), fenics.grad(test_re)) * dx_volumes +
        -gamma.imag * fenics.dot(fenics.grad(trial_im), fenics.grad(test_im)) * dx_volumes -
        -gamma.imag * fenics.dot(fenics.grad(trial_im), fenics.grad(test_re)) * dx_volumes +
        -gamma.imag * fenics.dot(fenics.grad(trial_re), fenics.grad(test_im)) * dx_volumes
)

# This is \beta \int_{\Omega} u \phi^{\star} d\mathbf{x}
# (all expanded in real and imaginary parts)
bilinear_form += (
    beta.real * trial_re * test_re * dx_volumes +
    beta.real * trial_im * test_im * dx_volumes +
    beta.real * trial_im * test_re * dx_volumes -
    beta.real * trial_re * test_im * dx_volumes +
    beta.imag * trial_re * test_re * dx_volumes +
    beta.imag * trial_im * test_im * dx_volumes -
    beta.imag * trial_im * test_re * dx_volumes +
    beta.imag * trial_re * test_im * dx_volumes
)

# For the boundary conditions, we produce the measure. According to `raw_mesh_data.cell_tags` the outer layer is marked
# with `-7`, the inner with `-6`. We need to add the offset (`7`). So the outer is marked with `0` and the inner with
# `1`.
ds_surfaces = fenics.Measure('ds', domain=mesh, subdomain_data=sub_domains_2d)
# `ds_surfaces(0)` should return a measure over the outer shell.
# `ds_surfaces(1)` should return a measure over the inner shell.
# We can compute the area of the boundary with `fenics.assemble(1 * ds_surfaces(0))`

# We also compute the radii of the inner and outer shells straight from the mesh data.
outer_radius = np.max(np.sqrt(np.sum((coordinate_scaling * raw_mesh_data.points) ** 2, axis=1)))
inner_radius = np.min(np.sqrt(np.sum((coordinate_scaling * raw_mesh_data.points) ** 2, axis=1)))

# For the outer shell, we apply an impedance that is matched to that of a spherical wave (no reflections).
# This is a Robin boundary condition.
# This adds this term to the bilinear form:
# j \omega \rho \gamma \int_{\partial \Omega_{0}} \frac{u}{z} \phi^\star ds
jkr = 1.0j * wave_number * outer_radius
impedance = density * speed_of_sound * jkr / (1.0 + jkr)
# For some reason we need to flip the sign to have the correct answer!!!
g_rb = -g_flux / impedance

bilinear_form += (
        g_rb.real * trial_re * test_re * ds_surfaces(0) +
        g_rb.real * trial_im * test_im * ds_surfaces(0) +
        g_rb.real * trial_im * test_re * ds_surfaces(0) -
        g_rb.real * trial_re * test_im * ds_surfaces(0) +
        g_rb.imag * trial_re * test_re * ds_surfaces(0) +
        g_rb.imag * trial_im * test_im * ds_surfaces(0) -
        g_rb.imag * trial_im * test_re * ds_surfaces(0) +
        g_rb.imag * trial_re * test_im * ds_surfaces(0)
)

# For the inner shell we apply a normal velocity.
# This is a Neumann boundary condition.
# This adds this term to the linear form:
# - j \omega \rho \gamma \int_{\partial \Omega_{1}} w \phi^\star ds
g_nm = -g_flux * source_velocity

linear_form = (
        g_nm.real * test_re * ds_surfaces(1) +
        g_nm.imag * test_im * ds_surfaces(1) +
        g_nm.imag * test_re * ds_surfaces(1) -
        g_nm.real * test_im * ds_surfaces(1)
)

# Cool, we are ready to solve!
u = fenics.Function(f_space)
fenics.solve(
    bilinear_form == linear_form,
    u,
    solver_parameters={
        'linear_solver': 'mumps',
        # 'linear_solver': 'bicgstab',  # This fails to converge!
        # 'preconditioner': 'ilu',
    },
)

# To check the results over the mesh nodes I like to write to VTU and read back.
# (it is easier than using the `u_re` and `u_im` objects).
u_re, u_im = u.split(deepcopy=True)
u_re.rename('re', 're')
u_im.rename('im', 'im')

vtk_file_1 = fenics.File('test_re.pvd')
vtk_file_1 << u_re

vtk_file_2 = fenics.File('test_im.pvd')
vtk_file_2 << u_im

u_re_vtu = meshio.read('test_re000000.vtu')
u_im_vtu = meshio.read('test_im000000.vtu')
nodes = u_re_vtu.points
u_re_at_nodes = u_re_vtu.point_data['re']
u_im_at_nodes = u_im_vtu.point_data['im']
u = u_re_at_nodes + 1j * u_im_at_nodes

# We compare with the exact lossless solution.
radii = np.sqrt(np.sum(nodes**2, axis=1))
jka = 1.0j * wave_number * inner_radius
z_a = density * speed_of_sound * jka / (1.0 + jka)
u_exact = z_a * inner_radius * source_velocity * np.exp(-1j * wave_number * (radii - inner_radius)) / radii

idx = np.argsort(radii)
error = u / u_exact

fig = plt.figure('Error Plot')
axs = fig.subplots(2, 1, sharex=True)
axs[0].grid(True, which='both')
axs[1].grid(True, which='both')
axs[1].set_xlabel('r [m]')
axs[0].set_ylabel('Magnitude [dB re Exact]')
axs[1].set_ylabel('Phase [rad re Exact]')
axs[0].plot(radii[idx], 20 * np.log10(np.abs(error[idx])))
axs[1].plot(radii[idx], np.angle(error[idx]))
plt.show()

fig = plt.figure('Solution Plot')
axs = fig.subplots(2, 1, sharex=True)
axs[0].grid(True, which='both')
axs[1].grid(True, which='both')
axs[1].set_xlabel('r [m]')
axs[0].set_ylabel('SPL [dBSPL]')
axs[1].set_ylabel('Phase [rad]')
axs[0].plot(radii[idx], 20 * np.log10(np.abs(u_exact[idx]) / (20e-6 * np.sqrt(2))))
axs[0].plot(radii[idx], 20 * np.log10(np.abs(u[idx]) / (20e-6 * np.sqrt(2))))
fig.legend(['Exact', 'FEniCS'])
axs[1].plot(radii[idx], np.angle(u_exact[idx]))
axs[1].plot(radii[idx], np.angle(u[idx]))
plt.show()


The errors look good (smaller than anything I could pickup with measurements).

I still have to double check I did not do rookie mistakes in the derivation, but I was wondering whether any of you have a clue why I might have had to switch the impedance sign? Could it be related to the direction of the normal vector on the boundary?

Hi Stefano,

I link you my derivation of the Helmholtz equation in weak form and you can see that the first term and the impedance term have the same sign.
It is not the impedance that is changing sign, but a lilttle mistake in the derivation of the weak form. Here is the link:

https://undaproject.com/acoustic-boundary-conditions-implementation-in-fenicsx

Antonio

2 Likes

Thanks @bay_swiss . It seems like my error originates from considering the flux as follows:

\nabla u \cdot \hat{\mathbf{n}} = j \omega \rho w

With w the normal velocity (either prescribed or originating from impedance). In your forms instead I see this:

\nabla u \cdot \hat{\mathbf{n}} = -j \omega \rho w

Which would do the trick I think. I guess the presence of that - sign relies on some implicit convention on where the normal vector \hat{\mathbf{n}} is pointing to (inside or outside \Omega). I will check the book I used as a reference and make sure my code knows what to do. Either way I think this is solved: some sign issue (such a classic…).

Cheers!

You’re welcome!

The flux condition is simply derived from the Euler equation, that in acoustics simplifies as beow:

image