Best way to apply time dependent Dirichlet boundary conditions using dolfinx.fem.petsc NonlinearProblem

Hello,

I wonder whether there is a more efficient way to implement Dirichlet boundary conditions using dolfinx.fem.petsc NonlinearProblem. Especially, i think that creating a NonlinearProblem within the time stepping loop is not a good way (see code below). However, since only at the call of NonlinearProblem the parameter bcs=Dirichlet_bcs is handed over, i don’t know how to update it otherwise.

Thank you in advance for your help.

Cheers

Domi

import numpy as np
from mpi4py import MPI
from dolfinx import mesh
from dolfinx.io import XDMFFile
from dolfinx.fem import functionspace, Constant, Function, locate_dofs_topological, dirichletbc, Expression
from dolfinx.fem.petsc import NonlinearProblem
from dolfinx.nls.petsc import NewtonSolver
from basix.ufl import element
from ufl import TestFunction, dx, grad, inner
from petsc4py import PETSc

# 3d geometry parameters
box_length = 1
cells_number_per_dim = 9

# time stepping
t = 0
t_final = 10
dt_init = 1e-3

def generate_mesh(box_length, cells_number_per_dim):
    msh = mesh.create_box(MPI.COMM_WORLD,
                          [[-box_length/2, -box_length/2, -box_length/2], [box_length/2,box_length/2, box_length/2]],
                          [cells_number_per_dim, cells_number_per_dim, cells_number_per_dim])
    return msh

msh = generate_mesh(box_length, cells_number_per_dim)

CG1_elem = element("Lagrange", msh.basix_cell(), 1)

V = functionspace(msh, CG1_elem)

dt = Constant(msh, dt_init)

T_test = TestFunction(V)
T = Function(V)
T_ = Function(V)

tdim = msh.topology.dim
fdim = tdim - 1
msh.topology.create_connectivity(fdim, tdim)

boundary_conditions = [
    (lambda x: np.isclose(x[0], -box_length/2), 'dof_bdry_1'),
    (lambda x: np.isclose(x[0],  box_length/2), 'dof_bdry_2'),
    (lambda x: np.isclose(x[1], -box_length/2), 'dof_bdry_3'),
    (lambda x: np.isclose(x[1],  box_length/2), 'dof_bdry_4'),
    (lambda x: np.isclose(x[2], -box_length/2), 'dof_bdry_5'),
    (lambda x: np.isclose(x[2],  box_length/2), 'dof_bdry_6')
]

dof_boundaries = {}

for marker, dof_name in boundary_conditions:
    facets_bdry = mesh.locate_entities_boundary(msh, dim=fdim, marker=marker)
    dof_boundaries[dof_name] = locate_dofs_topological(V=V, entity_dim=fdim, entities=facets_bdry)


class T_exact_fun():
    def __init__(self, t):
        self.t = t

    def __call__(self, x):
        values = np.zeros((1, x.shape[1]), dtype=PETSc.ScalarType)
        values[0] = -np.exp(-self.t) * (x[0]**2 + 2*x[1]**2)
        return values

# Initial condition
T.interpolate(T_exact_fun(t))
T_.interpolate(T_exact_fun(t))

T_exact = Function(V)
T_fun_value = T_exact_fun(t)
T_exact.interpolate(T_fun_value)

# Weak form
F = T_test * (T - T_) * dx
F += dt * inner( grad(T_test), grad(T) ) * dx

def r_fun(x):
    return  x[0]**2 + 2*x[1]**2 + 6 * np.exp(-t)

r_space = functionspace(msh, CG1_elem)
r = Function(r_space)
r.interpolate(r_fun)

F -= dt * T_test * r * dx

def create_Dirichlet_bc(V, T_fun, t, boundary_key):
    bc_fun = Function(V)
    T_fun_value = T_fun(t)
    bc_fun.interpolate(T_fun_value)
    bc_T = dirichletbc(bc_fun, dof_boundaries[boundary_key])
    return bc_T

while (t < t_final):

    bc_T_1 = create_Dirichlet_bc(V, T_exact_fun, t, 'dof_bdry_1')
    bc_T_2 = create_Dirichlet_bc(V, T_exact_fun, t, 'dof_bdry_2')
    bc_T_3 = create_Dirichlet_bc(V, T_exact_fun, t, 'dof_bdry_3')
    bc_T_4 = create_Dirichlet_bc(V, T_exact_fun, t, 'dof_bdry_4')
    bc_T_5 = create_Dirichlet_bc(V, T_exact_fun, t, 'dof_bdry_5')
    bc_T_6 = create_Dirichlet_bc(V, T_exact_fun, t, 'dof_bdry_6')

    Dirichlet_bcs = [bc_T_1, bc_T_2, bc_T_3, bc_T_4, bc_T_5, bc_T_6]

    problem = NonlinearProblem(F, T, bcs=Dirichlet_bcs)
    solver = NewtonSolver(MPI.COMM_WORLD, problem)
    solver.convergence_criterion = "residual"  # 'residual' or 'incremental'

    t += dt.value
    n, converged = solver.solve(T)
    dt.value *= 1.05

See for instance

where you create the functions

Once per bc and then use interpolate to update it within the loop.

This seems to work, thanks a lot. :slight_smile:

However, there is one thing that still bothers me. The example I’m considering is a simple heat equation \partial_t T - \nabla^2T =r. Using T= e^{-t}\cdot (x^2+2y^2) I obtain r = -e^{-t}\cdot(x^2+2y^2+6) (method of manufactured solution). Visual comparison of the calculated and the exact solution looks ok. However, when refining the mesh, the L2 error remains constant, which is not what I expect. E.g. 8 cells per dimension gives 3.57e-03, wheres 16 gives 3.66e-03. Any ideas on why this is?

import numpy as np
from mpi4py import MPI
from dolfinx import mesh
from dolfinx.io import XDMFFile
from dolfinx.fem import (functionspace, Constant, Function, locate_dofs_topological, dirichletbc, Expression,
                         assemble_scalar, form)
from dolfinx.fem.petsc import NonlinearProblem
from dolfinx.nls.petsc import NewtonSolver
from basix.ufl import element
from ufl import TestFunction, dx, grad, inner
from petsc4py import PETSc

# 3d geometry parameters
box_length = 1
cells_number_per_dim = 8

# time stepping
t = 0
t_final = 10
dt_init = 1.

# diagnostics
output_folder = './output'


msh = mesh.create_box(MPI.COMM_WORLD,[[-box_length/2, -box_length/2, -box_length/2], [box_length/2,box_length/2, box_length/2]],
                      [cells_number_per_dim, cells_number_per_dim, cells_number_per_dim])

CG1_elem = element("Lagrange", msh.basix_cell(), 1)

V = functionspace(msh, CG1_elem)

dt = Constant(msh, dt_init)

T_test = TestFunction(V)
T = Function(V)
T_ = Function(V)

# Dirichlet boundary conditions on cube surface
tdim = msh.topology.dim
fdim = tdim - 1
msh.topology.create_connectivity(fdim, tdim)

boundary_conditions = [
    (lambda x: np.isclose(x[0], -box_length/2), 'dof_bdry_1'),
    (lambda x: np.isclose(x[0],  box_length/2), 'dof_bdry_2'),
    (lambda x: np.isclose(x[1], -box_length/2), 'dof_bdry_3'),
    (lambda x: np.isclose(x[1],  box_length/2), 'dof_bdry_4'),
    (lambda x: np.isclose(x[2], -box_length/2), 'dof_bdry_5'),
    (lambda x: np.isclose(x[2],  box_length/2), 'dof_bdry_6')
]

dof_boundaries = {}

for marker, dof_name in boundary_conditions:
    facets_bdry = mesh.locate_entities_boundary(msh, dim=fdim, marker=marker)
    dof_boundaries[dof_name] = locate_dofs_topological(V=V, entity_dim=fdim, entities=facets_bdry)


class T_exact_fun():
    def __init__(self, t):
        self.t = t

    def __call__(self, x):
        values = np.zeros((1, x.shape[1]), dtype=PETSc.ScalarType)
        values[0] = np.exp(-self.t) * (x[0]**2 + 2*x[1]**2)
        return values

class r_fun():
    def __init__(self, t):
        self.t = t

    def __call__(self, x):
        values = np.zeros((1, x.shape[1]), dtype=PETSc.ScalarType)
        values[0] = -np.exp(-self.t) * (x[0]**2 + 2*x[1]**2 + 6)
        return values

# At t = 0
T.interpolate(T_exact_fun(t))
T_.interpolate(T_exact_fun(t))

T_exact = Function(V)
T_exact.interpolate(T_exact_fun(t))

r = Function(V)
r.interpolate(r_fun(t))

# Weak form
F = T_test * (T - T_) * dx + dt * inner( grad(T_test), grad(T) ) * dx - dt * T_test * r * dx

with XDMFFile(msh.comm, output_folder + "/T_sol.xdmf", "w") as T_sol_plot:
    T_sol_plot.write_mesh(msh)
with XDMFFile(msh.comm, output_folder + "/T_exact.xdmf", "w") as T_exact_plot:
    T_exact_plot.write_mesh(msh)

bc1_fun = Function(V)
bc1_fun.interpolate(T_exact_fun(t))
bc1 = dirichletbc(bc1_fun, dof_boundaries['dof_bdry_1'])

bc2_fun = Function(V)
bc2_fun.interpolate(T_exact_fun(t))
bc2 = dirichletbc(bc2_fun, dof_boundaries['dof_bdry_2'])

bc3_fun = Function(V)
bc3_fun.interpolate(T_exact_fun(t))
bc3 = dirichletbc(bc3_fun, dof_boundaries['dof_bdry_3'])

bc4_fun = Function(V)
bc4_fun.interpolate(T_exact_fun(t))
bc4 = dirichletbc(bc4_fun, dof_boundaries['dof_bdry_4'])

bc5_fun = Function(V)
bc5_fun.interpolate(T_exact_fun(t))
bc5 = dirichletbc(bc5_fun, dof_boundaries['dof_bdry_5'])

bc6_fun = Function(V)
bc6_fun.interpolate(T_exact_fun(t))
bc6 = dirichletbc(bc6_fun, dof_boundaries['dof_bdry_6'])

Dirichlet_bcs = [bc1, bc2, bc3, bc4, bc5, bc6]

problem = NonlinearProblem(F, T, bcs=Dirichlet_bcs)
solver = NewtonSolver(MPI.COMM_WORLD, problem)
solver.convergence_criterion = "incremental"  # 'residual' or 'incremental'

# write solution at t = 0
T_sol_plot.write_function(T, t)
T_exact_plot.write_function(T_exact, t)

while (t < t_final):

    t += dt.value

    # update functions and boundary conditions
    r.interpolate(r_fun(t))
    bc1_fun.interpolate(T_exact_fun(t))
    bc2_fun.interpolate(T_exact_fun(t))
    bc3_fun.interpolate(T_exact_fun(t))
    bc4_fun.interpolate(T_exact_fun(t))
    bc5_fun.interpolate(T_exact_fun(t))
    bc6_fun.interpolate(T_exact_fun(t))
    T_exact.interpolate(T_exact_fun(t))

    n, converged = solver.solve(T)

    T_sol_plot.write_function(T, t)
    T_exact_plot.write_function(T_exact, t)

    print(t)

# Compute L2 error and error at nodes
error_L2 = np.sqrt(msh.comm.allreduce(assemble_scalar(form((T - T_exact)**2 * dx)), op=MPI.SUM))
if msh.comm.rank == 0:
    print(f"L2-error: {error_L2:.2e}")

# Compute values at mesh vertices
error_max = msh.comm.allreduce(np.max(np.abs(T.x.array - T_exact.x.array)), op=MPI.MAX)
if msh.comm.rank == 0:
    print(f"Error_max: {error_max:.2e}")

I would suggest reducing deltar, your time step, as you have both a spatial and temporal error, and it is likely that the temporal error dominates

In order to exclude the temporal error, I solve the stationary version of the above PDE, i. e. -\nabla^2T=r, where T=x^2+2y^2 and consequently r=6.

By visual inspection of the output fields, I see that the calculated and exact solution are almost identical. The solver log reads:

[info] Newton iteration 0: r (abs) = 0.16705409960388973 (tol = 1e-10), r (rel) = inf (tol = 1e-09)
[info] PETSc Krylov solver starting to solve system.
[info] Newton iteration 1: r (abs) = 4.719147780319117e-15 (tol = 1e-10), r (rel) = 2.588306779401889e-16 (tol = 1e-09)
[info] Newton solver finished in 1 iterations and 1 linear solver iterations.
L2-error: 2.82e-01
Error_max: 6.40e-01

Even though both absolute and relative tolerances are very small (1e-15 and 1e-16, resp.), the calculated L2-error and error_max are around 1e-1. Besides that, I see the same unexpected behavior as in the time dependent case, i.e. when refining the mesh, L2-error and error-max do not decrease. Maybe I’m doing something wrong in the calculation of L2-error? I attached the code for stationary version as well below.

import numpy as np
from mpi4py import MPI
from dolfinx import mesh, log
from dolfinx.io import XDMFFile
from dolfinx.fem import (functionspace, Constant, Function, locate_dofs_topological, dirichletbc, Expression,
                         assemble_scalar, form)
from dolfinx.fem.petsc import NonlinearProblem
from dolfinx.nls.petsc import NewtonSolver
from basix.ufl import element
from ufl import TestFunction, dx, grad, inner
from petsc4py import PETSc

# 3d geometry parameters
box_length = 1
cells_number_per_dim = 16

# stationary
t = 0

# diagnostics
output_folder = './output'

msh = mesh.create_box(MPI.COMM_WORLD,[[-box_length/2, -box_length/2, -box_length/2], [box_length/2,box_length/2, box_length/2]],
                      [cells_number_per_dim, cells_number_per_dim, cells_number_per_dim])

CG1_elem = element("Lagrange", msh.basix_cell(), 1)

V = functionspace(msh, CG1_elem)

T_test = TestFunction(V)
T = Function(V)

# Dirichlet boundary conditions on cube surface
tdim = msh.topology.dim
fdim = tdim - 1
msh.topology.create_connectivity(fdim, tdim)

boundary_conditions = [
    (lambda x: np.isclose(x[0], -box_length/2), 'dof_bdry_1'),
    (lambda x: np.isclose(x[0],  box_length/2), 'dof_bdry_2'),
    (lambda x: np.isclose(x[1], -box_length/2), 'dof_bdry_3'),
    (lambda x: np.isclose(x[1],  box_length/2), 'dof_bdry_4'),
    (lambda x: np.isclose(x[2], -box_length/2), 'dof_bdry_5'),
    (lambda x: np.isclose(x[2],  box_length/2), 'dof_bdry_6')
]

dof_boundaries = {}

for marker, dof_name in boundary_conditions:
    facets_bdry = mesh.locate_entities_boundary(msh, dim=fdim, marker=marker)
    dof_boundaries[dof_name] = locate_dofs_topological(V=V, entity_dim=fdim, entities=facets_bdry)


class T_exact_fun():
    def __init__(self, t):
        self.t = t

    def __call__(self, x):
        values = np.zeros((1, x.shape[1]), dtype=PETSc.ScalarType)
        values[0] = np.exp(-self.t) * (x[0]**2 + 2*x[1]**2)
        return values

class r_fun():
    def __init__(self, t):
        self.t = t

    def __call__(self, x):
        values = np.zeros((1, x.shape[1]), dtype=PETSc.ScalarType)
        values[0] = 6
        return values

def T_0(x):
    values = np.zeros((1, x.shape[1]))
    values[0] = 1
    return values

# At t = 0
#T.interpolate(T_0)
T.interpolate(T_exact_fun(t))

T_exact = Function(V)
T_exact.interpolate(T_exact_fun(t))

r = Function(V)
r.interpolate(r_fun(t))

# Weak form
F = T_test * (T) * dx + inner( grad(T_test), grad(T) ) * dx - T_test * r * dx

with XDMFFile(msh.comm, output_folder + "/T_sol.xdmf", "w") as T_sol_plot:
    T_sol_plot.write_mesh(msh)
with XDMFFile(msh.comm, output_folder + "/T_exact.xdmf", "w") as T_exact_plot:
    T_exact_plot.write_mesh(msh)

bc1_fun = Function(V)
bc1_fun.interpolate(T_exact_fun(t))
bc1 = dirichletbc(bc1_fun, dof_boundaries['dof_bdry_1'])

bc2_fun = Function(V)
bc2_fun.interpolate(T_exact_fun(t))
bc2 = dirichletbc(bc2_fun, dof_boundaries['dof_bdry_2'])

bc3_fun = Function(V)
bc3_fun.interpolate(T_exact_fun(t))
bc3 = dirichletbc(bc3_fun, dof_boundaries['dof_bdry_3'])

bc4_fun = Function(V)
bc4_fun.interpolate(T_exact_fun(t))
bc4 = dirichletbc(bc4_fun, dof_boundaries['dof_bdry_4'])

bc5_fun = Function(V)
bc5_fun.interpolate(T_exact_fun(t))
bc5 = dirichletbc(bc5_fun, dof_boundaries['dof_bdry_5'])

bc6_fun = Function(V)
bc6_fun.interpolate(T_exact_fun(t))
bc6 = dirichletbc(bc6_fun, dof_boundaries['dof_bdry_6'])

Dirichlet_bcs = [bc1, bc2, bc3, bc4, bc5, bc6]

problem = NonlinearProblem(F, T, bcs=Dirichlet_bcs)
solver = NewtonSolver(MPI.COMM_WORLD, problem)
solver.convergence_criterion = "residual"  # 'residual' or 'incremental'
log.set_log_level(log.LogLevel.INFO) # INFO, WARNING or ERROR


n, converged = solver.solve(T)

T_sol_plot.write_function(T, t)
T_exact_plot.write_function(T_exact, t)

# Compute L2 error and error at nodes
error_L2 = np.sqrt(msh.comm.allreduce(assemble_scalar(form((T - T_exact)**2 * dx)), op=MPI.SUM))
if msh.comm.rank == 0:
    print(f"L2-error: {error_L2:.2e}")

# Compute values at mesh vertices
error_max = msh.comm.allreduce(np.max(np.abs(T.x.array - T_exact.x.array)), op=MPI.MAX)
if msh.comm.rank == 0:
    print(f"Error_max: {error_max:.2e}")```

Sorry, I realized I messed up the sign or r and the weak form . Changing the former to r= -6 and the latter to

F = inner( grad(T_test), grad(T) ) * dx - T_test * r * dx

yields

[info] Newton iteration 0: r (abs) = 4.635332162466504e-17 (tol = 1e-10), r (rel) = inf (tol = 1e-09)
[info] Newton solver finished in 0 iterations and 0 linear solver iterations.
L2-error: 0.00e+00
Error_max: 0.00e+00

i.e. the solver seems to realize that the initial guess is already the solution and does not even start.

However, when changing to uniform zero initial guess by using

T.interpolate(T_0)

instead of

T.interpolate(T_exact_fun(t))

I still get the unexpected behavior of the L2 error:

cells_number_per_dim = 4: L2-error: 1.41e-16
cells_number_per_dim = 8: L2-error: 3.59e-16
cells_number_per_dim = 16: L2-error: 1.49e-15

Any ideas on why L2-error is not decreasing?

The L2 error is of machine precision. This happens when your analytical solution is in the discrete finite element space.

In your case, the solution does not, but you are not computing the error correctly, a you are comparing Th with the best approximation of T_exact in your space.

Use ufl.SpatialCoordinate to represent the exact solution in error computations, as done in

The following code produces the expected scaling:

x = SpatialCoordinate(msh)
T_ufl = x[0]**2 + 2*x[1]**2


# Compute L2 error and error at nodes
error_L2 = np.sqrt(msh.comm.allreduce(assemble_scalar(form((T - T_ufl)**2 * dx)), op=MPI.SUM))

Thank you again for your help.

Hello,
I tried to apply time-dependent Dirichlet boundary conditions using the NonlinearProblem in FEniCSx 0.10, in a similar way as shown by Domi above. However, I couldn’t move the problem definition outside the time loop. I suspect that the boundary conditions are imposed only once when the object is created, without a way to update them afterwards. Could you please double-check whether there is a way to avoid recreating a NonlinearProblem object within the loop? I would appreciate it if you could point me to relevant tutorials. Thanks.

Without an example of what you have tried to do I cannot really give any guidance

In the current NonlinearProblem, the boundary conditions (bcs) are effectively fixed at initialization time because the class never stores them as an attribute. Specifically, setJacobian(…) and setFunction(…) each receive a partial(…) with the bcs bound into the callback, and there is no later setter or self._bcs field to modify them (this is not the case in LinearProblem). As a result, one needs to re-instantiate the class within the time-stepping loop or interpolate the functions that calculate the boundary conditions (BCs) at time t.

while TimeLoopActiveCondition: 
    problem_u = NonlinearProblem(F_u, uh, petsc_options_prefix="basic_nonlinear_problem_", bcs = new_bcs, petsc_options=Nonlinear_Options)  

Please see a CustomNonlinearProblem (with minimal edits compared to the original as shown below), where we can update the bcs during time stepping using problem.bcs = new_bcs without creating a new SNES object or interpolating functions to calculate bcs at different time t as follows

problem_u = CustomNonlinearProblem(F_u, uh, petsc_options_prefix="basic_nonlinear_problem_", bcs = bc_field0, petsc_options=Nonlinear_Options)  

while TimeLoopActiveCondition: 
    problem_u.bcs = new_bcs

I haven’t tested this extensively, but I’ve seen the same results in a couple of problems. Please let me know if you spot any issues (or inefficiencies).

class CustomNonlinearProblem:
    ...
    def __init__(...)
        ...
        self._bcs = [ ] if bcs is None else bcs
        ...
        # Create the SNES solver and attach the corresponding Jacobian and
        # residual computation functions
        self._snes = PETSc.SNES().create(self.A.comm)  # type: ignore[attr-defined]
        self.solver.setJacobian(self._assemble_jacobian, self.A, self.P_mat)
        self.solver.setFunction(self._assemble_residual, self.b)
        ...
    def _assemble_jacobian(self, snes, x, J, P_mat):
        assemble_jacobian(self._u, self.J, self.preconditioner, self._bcs, snes, x, J, P_mat,)

    def _assemble_residual(self, snes, x, b):
        assemble_residual(self._u, self.F, self.J, self._bcs, snes, x, b,)
    ...
    @property
    def bcs(self):
        return self._bcs

    @bcs.setter
    def bcs(self, value):
        self._bcs = list(value)
1 Like