Numerical results of the preserved magnetic field divergence-free

Hi, Everyone!
Currently, I am decoupling and solving a 3D model:

(\partial B)/(\partial t)+\curl E=0  in \Omega      B\cdot n=0   on \partial \Omega

When I take the divergence of this equation, I get:

\div B=0 in \Omega 

Discretize this equation and obtain the numerical scheme as follows:

(B_h^n-B_h^{n-1},C)+(\curl E_h^n,C)=0

It can be deduced from the numerical scheme:

\div B_h^n= \div B_h^{n-1}=...=\div B_h^0

When the initial condition \div B_h^0=0 is given, it can be obtained that \div B_h^n=0.

from petsc4py import PETSc
from mpi4py import MPI
import numpy as np
import ufl
from dolfinx import mesh, fem, default_real_type
from dolfinx.fem.petsc import assemble_matrix, assemble_vector, apply_lifting, create_vector, set_bc
import time 
from ufl import dx, grad, dot, lhs, rhs,inner,cross,curl,div,Constant,as_vector
from basix.ufl import mixed_element,element
from numpy import sin, cos, pi, exp
from ufl import sin as ufl_sin, cos as ufl_cos, pi as ufl_pi
# 精确解
Rm=1

left =0.0
right = 1.0  
bottom =  0.0
top = 1.0     
near = 0.0
far = 1.0
def  ExactU(x,t):
    factor = exp(-t) + 1.0
    temp1=factor * sin(pi * x[0]) *sin(pi * x[1]) *sin(pi * x[2])
    temp2=np.zeros_like(x[0])
    temp3=factor * (x[2]**2 - x[2]) *sin(pi * x[0]) * sin(pi * x[1])
    return [temp1,temp2,temp3]




def ExactE(x,t):
    sint = sin(pi*t)+1.5
    temp1=sint*(x[1]**2-x[1])*sin(pi*x[2])
    temp2=sint*(x[0]**2-x[0])*sin(pi*x[2])
    temp3=sint*(x[0]**2-x[0])*sin(pi*x[1])
    return [temp1,temp2,temp3]

def ExactB1(x,t):
    cost=(1/ufl_pi)*cos(ufl_pi*t)-1.5*t
    temp1=cost*ufl_pi*(x[0]**2-x[0])*(ufl_cos(ufl_pi*x[1])-ufl_cos(ufl_pi*x[2]))
    temp2=cost*(ufl_pi*(x[1]**2-x[1])*ufl_cos(ufl_pi*x[2])-(2*x[0]-1)*ufl_sin(ufl_pi*x[1]))
    temp3=cost*(2*x[0]-2*x[1])*ufl_sin(ufl_pi*x[2])
    return  as_vector([temp1, temp2, temp3])

def ExactB(x,t):
    cost=(1/pi)*cos(pi*t)-1.5*t
    temp1=cost*pi*(x[0]**2-x[0])*(cos(pi*x[1])-cos(pi*x[2]))
    temp2=cost*(pi*(x[1]**2-x[1])*cos(pi*x[2])-(2*x[0]-1)*sin(pi*x[1]))
    temp3=cost*(2*x[0]-2*x[1])*sin(pi*x[2])
    return  [temp1, temp2, temp3]
       
# 固定终止时间
T = 1 / 2
# 结果记录
hs = []
errors_L2 = []
errors_max = []  
div_Bh=[]
start_time = time.time()
for i in range(3):
    n = 2 ** (i + 2)               # 空间划分数 n = 2^{i+2}
    h = 1.0 / n
    num_steps = 2 ** (2 * i + 3)   # 时间划分数 tau = 2^{2i+4}
    dt = T / num_steps
    t = 0.0
# 创建 3D 网格(单位立方体)
    domain = mesh.create_unit_cube(MPI.COMM_WORLD, n, n, n, mesh.CellType.tetrahedron)
    x = ufl.SpatialCoordinate(domain)
#  磁场空间与电场空间的定义
    VB = fem.functionspace(domain, ("RT", 1))   #  degree=1, 表示 RT0元
    VE = fem.functionspace(domain, ("N1curl", 1)) # degree=1, 表示 ND0元(最低阶棱单元)
     
    E_n=fem.Function(VE)
    B_n = fem.Function(VB)
    B_n.interpolate(lambda x: ExactB(x, t)) 

    B_D = fem.Function(VB)
    B_D.interpolate(lambda x: ExactB(x, t)) 
    
    
# 施加 Dirichlet 边界条件(3D 边界)
    tdim = domain.topology.dim  
    fdim = tdim - 1  
    domain.topology.create_connectivity(fdim, tdim)
    boundary_facets = mesh.exterior_facet_indices(domain.topology)
    bc = fem.dirichletbc(B_D, fem.locate_dofs_topological(VB, fdim, boundary_facets))


# 变分形式(3D 形式相同,但 dx 现在是 3D 积分)
    B, C = ufl.TrialFunction(VB), ufl.TestFunction(VB)
    G = inner(B,C)*dx - inner(B_n,C)*dx + dt*inner(curl(E_n),C)*dx   
    
    a = fem.form(lhs(G))
    L = fem.form(rhs(G))

# 组装矩阵和向量
    A = assemble_matrix(a, bcs=[bc])
    A.assemble()
    b = create_vector(L)
    B_h =fem.Function(VB) 

# 定义求解器(直接法:LU 分解)
    solver = PETSc.KSP().create(domain.comm)
    solver.setOperators(A)
   # solver.setType(PETSc.KSP.Type.PREONLY)
  #  solver.getPC().setType(PETSc.PC.Type.LU)

    solver.setType(PETSc.KSP.Type.BCGS)
    pc1 = solver.getPC()
    pc1.setType(PETSc.PC.Type.HYPRE)
    pc1.setHYPREType("boomeramg")


# 时间步进
    for n_step in range(num_steps):
      t += dt
      E_n.interpolate(lambda x: ExactE(x,t))
      B_D.interpolate(lambda x: ExactB(x, t)) 
      bc = fem.dirichletbc(B_D, fem.locate_dofs_topological(VB, fdim, boundary_facets))
      # 更新矩阵
      A.zeroEntries()
      assemble_matrix(A, a, bcs=[bc])
      A.assemble()

      # 重新组装右端向量
      with b.localForm() as loc_b:
        loc_b.set(0)
      assemble_vector(b, L)

      # 施加边界条件
      apply_lifting(b, [a], [[bc]])
      b.ghostUpdate(addv=PETSc.InsertMode.ADD_VALUES, mode=PETSc.ScatterMode.REVERSE)
      set_bc(b, [bc])

      # 求解线性系统
      solver.solve(b, B_h.x.petsc_vec)
      B_h.x.scatter_forward()
      B_n.x.array[:] = B_h.x.array
      
    
     

# 计算误差(在更高阶空间)
  #  V_dg = fem.functionspace(domain, ("Discontinuous Lagrange", 2, (3,)))
    V_dg = fem.functionspace(domain, ("Lagrange", 1, (3,)))
    B_ex = fem.Function(V_dg)
    B_ex.interpolate(lambda x: ExactB(x,t))

    B_ex1 = fem.Function(VB)
    B_ex1.interpolate(lambda x:ExactB(x,t))

# L2 误差
    error_L2 = np.sqrt(domain.comm.allreduce(fem.assemble_scalar(fem.form((B_h - B_ex)**2 * ufl.dx)), op=MPI.SUM))
    div_Bh = np.sqrt(domain.comm.allreduce(fem.assemble_scalar(fem.form(div(B_h)**2 * dx)), op=MPI.SUM))
    error_max= np.max(np.abs(B_ex1.x.array - B_h.x.array))
    if MPI.COMM_WORLD.rank == 0:
        hs.append(h)
        errors_L2.append(error_L2)
        errors_max.append(error_max)
        print(f"[i={i}] n={n}, num_steps={num_steps}, dt={dt:.3e}, h={h:.3e}")
        print(f"        → L2 error = {error_L2:.3e}, max error={error_max:.3e}, divBh={div_Bh:.3e} ")

end_time = time.time()  # 计时结束
elapsed = end_time - start_time
# 输出收敛阶
if MPI.COMM_WORLD.rank == 0:
    print("\nEstimated convergence rates (L2):")
    for j in range(1, len(hs)):
        r1 = np.log(errors_L2[j-1]/errors_L2[j]) / np.log(hs[j-1]/hs[j])
        r2 = np.log(errors_max[j-1]/errors_max[j]) / np.log(hs[j-1]/hs[j])
        print(f"h={hs[j-1]:.3e} → h={hs[j]:.3e}, rate ≈ {r1:.2f},rate ≈ {r2:.2f} ")

    print(f"\nTotal runtime: {elapsed:.2f} seconds")

The code corresponding to B_h^0 is:

 VB = fem.functionspace(domain, ("RT", 1))   #  degree=1, 表示 RT0元
    VE = fem.functionspace(domain, ("N1curl", 1)) # degree=1, 表示 ND0元(最低阶棱单元)
     
    E_n=fem.Function(VE)
    B_n = fem.Function(VB)
    B_n.interpolate(lambda x: ExactB(x, t)

But I calculated that \div B_h^0 is not close to 0,I hope someone can help me solve this problem. How can this initial condition be guaranteed?
Looking forward to your reply!

So you’re asking how to ensure how the below yields a B_n for which \nabla\cdot B_n = 0?

from dolfinx import mesh, fem
from numpy import sin, cos, pi
from mpi4py import MPI

n = 5
domain = mesh.create_unit_cube(MPI.COMM_WORLD, n, n, n, mesh.CellType.tetrahedron)

def ExactB(x,t):
    cost=(1/pi)*cos(pi*t)-1.5*t
    temp1=cost*pi*(x[0]**2-x[0])*(cos(pi*x[1])-cos(pi*x[2]))
    temp2=cost*(pi*(x[1]**2-x[1])*cos(pi*x[2])-(2*x[0]-1)*sin(pi*x[1]))
    temp3=cost*(2*x[0]-2*x[1])*sin(pi*x[2])
    return  [temp1, temp2, temp3]

VB = fem.functionspace(domain, ("RT", 1))
B_n = fem.Function(VB)
B_n.interpolate(lambda x: ExactB(x, 0))

(The large amount of code and additional information you’re placing in your question makes it cumbersome for people to help you. Please make your question statement and MWE are truly minimal).

To test whether \nabla\cdot B_n = 0 pointwise:

import ufl
divBn2 = ufl.div(B_n)*ufl.div(B_n)*ufl.dx
print( "Should be zero:", fem.assemble_scalar(fem.form(divBn2)) )

Which indeed outputs some nonzero number.

You’ll need some specialized projection rather than interpolation. Consider:


import dolfinx.fem.petsc
DG0 = fem.functionspace(domain, ("DG", 0))
Q = ufl.MixedFunctionSpace(VB, DG0)
sigma, p = ufl.TrialFunctions(Q)
tau, q = ufl.TestFunctions(Q)
a = ufl.extract_blocks(
    ufl.inner(sigma, tau) * ufl.dx + ufl.inner(p, ufl.div(tau)) * ufl.dx + ufl.inner(ufl.div(sigma), q) * ufl.dx
)
L = [ufl.inner(B_n, tau)*ufl.dx, ufl.ZeroBaseForm((q,))]
sigma, u = fem.Function(VB, name="sigma"), fem.Function(DG0, name="u")
problem = fem.petsc.LinearProblem(a,L, kind="nest", u=[sigma, u],petsc_options_prefix="mysolver",petsc_options={"ksp_type": "preonly","pc_type": "lu"},bcs=[])
problem.solve()
B_n.x.array[:] = sigma.x.array[:]
print( "Should be zero:", fem.assemble_scalar(fem.form(divBn2)) )

Notes

  • The projection yields pointwise zero divergence due to the compatibility of the RT1 and DG0 spaces. This will not work for arbitrary spaces for B_n.
  • This works on fenics 0.10.0, you’ll probably have to edit the code to make it compatible with earlier versions. But the concept should be clear
1 Like

Hi @stein, I’m interested in the principles underlying your solution. Enforcing \nabla \cdot u = 0 may be important in other contexts outside magnetic fields, for instance in flow and particle flux. Are there references or resources or simple reasoning to help understand at a theoretical/mathematical/philosophical level why interpolate might violate \nabla \cdot u = 0 while a projection preserves it?

Sure.

Your question why interpolation does not yield identically zero divergence approxation is a very good one. I actually think that formal interpolation of some divergence free vector field onto the Raviart-Thomas basis should be divergence free. The dofs in the RT space are the face averages (or their higher-order moments for higher order elements), so an interpolation should preserve those face averages. I guess that’s not what fenics is doing. @dokken, any insights?

So then why does projection work? My suggestion for the projector is making use of a “div-conforming” element pair \nabla\cdot RT_{k} = DG_{k-1}, meaning that by construction of the RT space, for any choice v \in RT_k we have \nabla \cdot v \in DG_{k-1}. The projector then solves the Darcy equations:

\begin{cases} \ \int u^h\cdot v -p^h\, \nabla\cdot v =\int u^* \cdot v \qquad\forall \, u\in RT_{k}\\ \quad\qquad -\int q\, \nabla\cdot u^h = 0 \qquad\qquad\forall \, q\in DG_{k-1} \end{cases}

Which originates from the L^2 minimation of the error u^h-u^* under a discrete divergence free contraint.

That this actually yields a pointwise div=0 approximation can be proven quite easily; choose q = \nabla\cdot u^h, which is an allowed choice due to the div-conforming element pair. Then the second line says \int (\nabla\cdot u^h )^2 = 0. This requires \nabla\cdot u= 0 everywhere.

1 Like

The only thing I can think of is that the quadrature degree for the face integrals are too low, introducing a numerical error. I think @mscroggs has a script somewhere that illustrates this

I see it. Essentially the projection here imposes the zero-divergence condition explicitly, so it must be honoured (subject to numerical convergence tolerances).

In your example you imposed it via an effective “Darcy pressure” p. I presume it could be achieved by other methods of imposing constraints weakly, like Nitsche’s method.

Curious to see that p is serving as a kind of generalised Lagrange multiplier. With q made DG0, p is like an element-wise Lagrange multiplier, providing one constant Lagrange multiplier for each element.

Yes, although it is important to realize that having the block \int q\, \nabla\cdot u = 0 by itself is not sufficient: it is really due to the compatibility of the spaces. Taylor-Hood or MINI elements do not yield pointwise div-free results, as for them you cannot (for arbitrary u) choose q=\nabla\cdot u^h.

I’m quite sure that would not lead to exactly div-free fields though… The point of Nitsche is precisely to permit some slack (in a variationally consistent manner).

Yes! That’s very generally true though, also independent of discretization. For incompressible fluids, the pressure is the Lagrange multiplier that enforces the divergence free constaint.

1 Like

Thinking more about this.
This function is not in RT-1, and thus the interpolation is an approximation that converges with a given order (which is faster than if the RT-1 space had been defined with a point evaluation. See for instance figure 10 of: DOLFINx: The next generation FEniCS problem solving environment).

To illustrate this, consider the following

from dolfinx import mesh, fem
import numpy as np
import ufl
from mpi4py import MPI


def ExactB(mod, x, t):
    cost = (1 / mod.pi) * mod.cos(mod.pi * t) - 1.5 * t
    temp1 = (
        cost
        * mod.pi
        * (x[0] ** 2 - x[0])
        * (mod.cos(mod.pi * x[1]) - mod.cos(mod.pi * x[2]))
    )
    temp2 = cost * (
        mod.pi * (x[1] ** 2 - x[1]) * mod.cos(mod.pi * x[2])
        - (2 * x[0] - 1) * mod.sin(mod.pi * x[1])
    )
    temp3 = cost * (2 * x[0] - 2 * x[1]) * mod.sin(mod.pi * x[2])
    return [temp1, temp2, temp3]


errors = []
h = []
errors_div = []
for N in [5, 10, 20, 40]:
    domain = mesh.create_unit_cube(MPI.COMM_WORLD, N, N, N, mesh.CellType.tetrahedron)
    VB = fem.functionspace(domain, ("RT", 1))
    B_n = fem.Function(VB)
    B_n.interpolate(lambda x: ExactB(np, x, 0))

    B_ex = ufl.as_vector(ExactB(ufl, ufl.SpatialCoordinate(domain), 0))
    L2_B = fem.form(ufl.inner(B_n - B_ex, B_n - B_ex) * ufl.dx)
    errors.append(np.sqrt(fem.assemble_scalar(L2_B)))
    print(f"L2 norm of B: {errors[-1]:.2e}")

    divBn2 = ufl.div(B_n) * ufl.div(B_n) * ufl.dx
    print(f"Should be zero: {fem.assemble_scalar(fem.form(divBn2)):.2e}")

    divBn_ex = ufl.div(B_ex) * ufl.div(B_ex) * ufl.dx
    print(f"Should be zero: {fem.assemble_scalar(fem.form(divBn_ex)):.2e}")

    error_Hdiv = fem.form(ufl.inner(ufl.div(B_n - B_ex), ufl.div(B_n - B_ex)) * ufl.dx)
    errors_div.append(np.sqrt(fem.assemble_scalar(error_Hdiv)))
    print(f"H(div) norm of B: {errors_div[-1]:.2e}")
    h.append(1 / N)

errors = np.array(errors)
errors_div = np.array(errors_div)
h = np.array(h)
print("Rates", np.log(errors[:-1] / errors[1:]) / np.log(h[:-1] / h[1:]))
print("Rates H(div)", np.log(errors_div[:-1] / errors_div[1:]) / np.log(h[:-1] / h[1:]))

which yields

L2 norm of B: 7.53e-02
Should be zero: 8.20e-03
Should be zero: 8.54e-33
H(div) norm of B: 9.06e-02
L2 norm of B: 3.79e-02
Should be zero: 2.10e-03
Should be zero: 8.57e-33
H(div) norm of B: 4.59e-02
L2 norm of B: 1.90e-02
Should be zero: 5.29e-04
Should be zero: 8.54e-33
H(div) norm of B: 2.30e-02
L2 norm of B: 9.50e-03
Should be zero: 1.32e-04
Should be zero: 8.52e-33
H(div) norm of B: 1.15e-02
Rates [0.99023232 0.99755924 0.99938989]
Rates H(div) [0.98202759 0.9955634  0.99889434]

If we instead use RT-2, we get:

L2 norm of B: 7.84e-03
Should be zero: 9.53e-05
Should be zero: 8.54e-33
H(div) norm of B: 9.76e-03
L2 norm of B: 1.98e-03
Should be zero: 6.13e-06
Should be zero: 8.57e-33
H(div) norm of B: 2.48e-03
L2 norm of B: 4.96e-04
Should be zero: 3.86e-07
Should be zero: 8.54e-33
H(div) norm of B: 6.21e-04
L2 norm of B: 1.24e-04
Should be zero: 2.42e-08
Should be zero: 8.52e-33
H(div) norm of B: 1.55e-04
Rates [1.98658397 1.99664701 1.99916183]
Rates H(div) [1.97898581 1.99483796 1.99871504]

or RT-3

L2 norm of B: 5.32e-04
Should be zero: 8.26e-07
Should be zero: 8.54e-33
H(div) norm of B: 9.09e-04
L2 norm of B: 6.73e-05
Should be zero: 1.31e-08
Should be zero: 8.57e-33
H(div) norm of B: 1.14e-04
L2 norm of B: 8.44e-06
Should be zero: 2.05e-10
Should be zero: 8.54e-33
H(div) norm of B: 1.43e-05
L2 norm of B: 1.06e-06
Should be zero: 3.21e-12
Should be zero: 8.52e-33
H(div) norm of B: 1.79e-06
Rates [2.98274198 2.9957308  2.99893547]
Rates H(div) [2.99010528 2.99752784 2.99938208]

If one had pushed the number of integration points in the basix element to infinity, one could get better results for RT-1.

I attach a function that @mscroggs made to make a custom RT element, that one can specify the quadrature degree of:

def rt_quadrature(degree: int, qdegree: int):
    """Create a Raviart-Thomas element with the given quadrature degree."""
    wcoeffs = basix.ufl.element(
        basix.ElementFamily.RT,
        basix.CellType.tetrahedron,
        degree,
        basix.LagrangeVariant.equispaced,
    )._wcoeffs
    geom = basix.geometry(basix.CellType.tetrahedron)
    top = basix.topology(basix.CellType.tetrahedron)
    x = [[], [], [], []]
    M = [[], [], [], []]

    # Vertices
    for _ in top[0]:
        x[0].append(np.zeros([0, 3]))
        M[0].append(np.zeros([0, 3, 0, 1]))

    # Edges
    for e in top[1]:
        x[1].append(np.zeros([0, 3]))
        M[1].append(np.zeros([0, 3, 0, 1]))

    # Faces
    pts, wts = basix.make_quadrature(
        basix.CellType.triangle, max(degree * 2 - 1, qdegree)
    )
    poly = basix.tabulate_polynomials(
        basix.PolynomialType.legendre, basix.CellType.triangle, degree - 1, pts
    )
    for f, n in zip(top[2], basix.cell.facet_normals(basix.CellType.tetrahedron)):
        v0 = geom[f[0]]
        v1 = geom[f[1]]
        v2 = geom[f[2]]
        face_pts = np.array([v0 + p[0] * (v1 - v0) + p[1] * (v2 - v0) for p in pts])
        x[2].append(face_pts)

        mat = np.zeros((poly.shape[0], 3, len(pts), 1))
        for dof, p in enumerate(poly):
            for i, c in enumerate(n):
                mat[dof, i, :, 0] = c * p * wts
        M[2].append(mat)
    # Interior
    if degree <= 1:
        x[3].append(np.zeros([0, 3]))
        M[3].append(np.zeros([0, 3, 0, 1]))
    else:
        pts, wts = basix.make_quadrature(
            basix.CellType.tetrahedron, max(degree * 2 - 2, qdegree)
        )
        poly = basix.tabulate_polynomials(
            basix.PolynomialType.legendre, basix.CellType.tetrahedron, degree - 2, pts
        )
        x[3].append(pts)

        mat = np.zeros((3 * poly.shape[0], 3, len(pts), 1))
        for dof, p in enumerate(poly):
            mat[dof, 0, :, 0] = p * wts
            mat[poly.shape[0] + dof, 1, :, 0] = p * wts
            mat[2 * poly.shape[0] + dof :, 2, :, 0] = p * wts
        M[3].append(mat)

    return basix.ufl.custom_element(
        basix.CellType.tetrahedron,
        [3],
        wcoeffs,
        x,
        M,
        0,
        basix.MapType.contravariantPiola,
        basix.SobolevSpace.HDiv,
        False,
        degree - 1,
        degree,
    )
1 Like