NonlinearMPCProblem: error with periodic structure and nonlinear material

Hi,
I want to create a periodic micromodel similar to this tutorial but with a hyperelastic material. I’m using dolfinx_mpc, and I am trying to adapt this test case with a nonlinear MPC problem to my application.
This is giving me an error related to the NonlinearMPCProblem class. What error am I making in my problem definition?

Here is the MWE (the classes are unchanged from the example):

import numpy as np
import ufl

from mpi4py import MPI
from dolfinx import fem, io, mesh
# from dolfinx.la.petsc import create_vector
from dolfinx.fem.petsc import create_vector
import dolfinx_mpc
import dolfinx.fem.petsc
import dolfinx.nls.petsc
import petsc4py.PETSc as PETSc
from ufl import ( Identity, grad, ln, tr, det, variable, derivative, TestFunction, TrialFunction )

"""
Solving functions copied from dolfinx_mpc/python/tests/test_nonlinear_assembly.py
"""

class NonlinearMPCProblem(dolfinx.fem.petsc.NonlinearProblem):
    def __init__(self, F, u, mpc, bcs=[], J=None, form_compiler_options={}, jit_options={}):
        self.mpc = mpc
        super().__init__(F, u, bcs=bcs, J=J, form_compiler_options=form_compiler_options, jit_options=jit_options)

    def F(self, x: PETSc.Vec, F: PETSc.Vec):  # type: ignore
        with F.localForm() as F_local:
            F_local.set(0.0)
        dolfinx_mpc.assemble_vector(self._L, self.mpc, b=F)

        # Apply boundary condition
        dolfinx_mpc.apply_lifting(
            F,
            [self._a],
            bcs=[self.bcs],
            constraint=self.mpc,
            x0=[x],
            scale=dolfinx.default_scalar_type(-1.0),
        )
        F.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE)  # type: ignore
        dolfinx.fem.petsc.set_bc(F, self.bcs, x, -1.0)

    def J(self, x: PETSc.Vec, A: PETSc.Mat):  # type: ignore
        A.zeroEntries()
        dolfinx_mpc.assemble_matrix(self._a, self.mpc, bcs=self.bcs, A=A)
        A.assemble()


class NewtonSolverMPC(dolfinx.cpp.nls.petsc.NewtonSolver):
    def __init__(
        self,
        comm: MPI.Intracomm,
        problem: NonlinearMPCProblem,
        mpc: dolfinx_mpc.MultiPointConstraint,
    ):
        """A Newton solver for non-linear MPC problems."""
        super().__init__(comm)
        self.mpc = mpc
        self.u_mpc = dolfinx.fem.Function(mpc.function_space)

        # Create matrix and vector to be used for assembly of the non-linear
        # MPC problem
        self._A = dolfinx_mpc.cpp.mpc.create_matrix(problem.a._cpp_object, mpc._cpp_object)
        self._b = create_vector(mpc.function_space.dofmap.index_map, mpc.function_space.dofmap.index_map_bs)

        self.setF(problem.F, self._b)
        self.setJ(problem.J, self._A)
        self.set_form(problem.form)
        self.set_update(self.update)

    def update(self, solver: dolfinx.nls.petsc.NewtonSolver, dx: PETSc.Vec, x: PETSc.Vec):  # type: ignore
        # We need to use a vector created on the MPC's space to update ghosts
        self.u_mpc.x.petsc_vec.array = x.array_r
        self.u_mpc.x.petsc_vec.axpy(-1.0, dx)
        self.u_mpc.x.petsc_vec.ghostUpdate(
            addv=PETSc.InsertMode.INSERT,  # type: ignore
            mode=PETSc.ScatterMode.FORWARD,  # type: ignore
        )  # type: ignore
        self.mpc.homogenize(self.u_mpc)
        self.mpc.backsubstitution(self.u_mpc)
        x.array = self.u_mpc.x.petsc_vec.array_r
        x.ghostUpdate(
            addv=PETSc.InsertMode.INSERT,  # type: ignore
            mode=PETSc.ScatterMode.FORWARD,  # type: ignore
        )  # type: ignore

    def solve(self, u: dolfinx.fem.Function):
        """Solve non-linear problem into function u. Returns the number
        of iterations and if the solver converged."""
        n, converged = super().solve(u.x.petsc_vec)
        u.x.scatter_forward()
        return n, converged

    @property
    def A(self) -> PETSc.Mat:  # type: ignore
        """Jacobian matrix"""
        return self._A

    @property
    def b(self) -> PETSc.Vec:  # type: ignore
        """Residual vector"""
        return self._b


"""
Mesh
"""
length_plate = 1

# Create a simple 2D grid mesh (1x1) with 20 elements in each direction
domain = mesh.create_rectangle(
    comm=MPI.COMM_WORLD,
    points=((0, 0), (length_plate, length_plate)),
    n=(20, 20),
    cell_type=mesh.CellType.triangle
)
domain.topology.create_connectivity(domain.topology.dim - 1, domain.topology.dim)
dim = domain.topology.dim

"""
Function spaces
"""
degree = 1
shape = (dim,)
V = fem.functionspace(domain, ("P", degree, shape))

"""
Boundary conditions
"""
def bot_left_corner(x):
    return np.logical_and(np.isclose(x[1], 0.0), np.isclose(x[0], 0.0))

def right_boundary(x):
    return np.isclose(x[0], length_plate)

def top_boundary(x):
    return np.isclose(x[1], length_plate)

bot_left_dof = fem.locate_dofs_geometrical(
    V, bot_left_corner
)

top_facets = mesh.locate_entities_boundary(domain, dim-1, top_boundary)
top_dofs = fem.locate_dofs_topological(V.sub(1), dim-1, top_facets)
u_top = fem.Constant(domain, PETSc.ScalarType(1.))

# Fixed bottom left corner, prescribed value on all top dofs
bcs = [fem.dirichletbc(np.zeros((dim,)), bot_left_dof, V), fem.dirichletbc(u_top, top_dofs, V.sub(1))]

a = np.array([1, 0])

def periodic_relation_left_right(x):
    out_x = np.zeros(x.shape)
    out_x[0] = x[0] - 1.0
    out_x[1] = x[1]
    out_x[2] = x[2]
    return out_x

def periodic_relation_bottom_top(x):
    out_x = np.zeros(x.shape)
    out_x[0] = x[0]
    out_x[1] = x[1] - 1.0
    out_x[2] = x[2]
    return out_x

mpc = dolfinx_mpc.MultiPointConstraint(V)
mpc.create_periodic_constraint_geometrical(V, right_boundary, periodic_relation_left_right, bcs)
mpc.create_periodic_constraint_geometrical(V, top_boundary, periodic_relation_bottom_top, bcs)
mpc.finalize()

# Define variational problem
V_mpc = mpc.function_space

# unkown lives in MPC space
u = fem.Function(V_mpc, name="Displacement")
"""
Hyperelastic material model (compressible neo-Hookean model)
"""
# Identity tensor
Id = Identity(dim)

F = variable(Id + grad(u))  # Deformation gradient
C = F.T * F     # Right Cauchy-Green tensor
# Invariants of deformation tensors
I1 = tr(C)
J = det(F)

E = 1e3
nu = 0.3
mu = fem.Constant(domain, E / 2 / (1 + nu))
lmbda = fem.Constant(domain, E * nu / (1 - 2 * nu) / (1 + nu))

# Stored strain energy density
psi = mu / 2 * (I1 - 3 - 2 * ln(J)) + lmbda / 2 * (J - 1) ** 2

"""
Problem setup
"""
dx = ufl.Measure("dx", domain=domain, metadata={"quadrature_degree": 2})
E_pot = psi * dx

v = TestFunction(V)
du = TrialFunction(V)
Residual = derivative(
    E_pot, u, v
)  # This is equivalent to Residual = inner(P, grad(v))*dx
Jacobian = derivative(Residual, u, du)

problem = NonlinearMPCProblem(Residual, u, mpc, bcs=bcs, J=J)
solver = NewtonSolverMPC(mesh.comm, problem, mpc)
solver.atol = 1e-4
solver.rtol = 1e-4

"""
Solving of problem
"""

out_file = "compression_hypel_MWE.xdmf"
with io.XDMFFile(domain.comm, out_file, "w") as xdmf:
    xdmf.write_mesh(domain)

for n, disp in enumerate(np.linspace(0, -0.2, 11)[1:]):
    u_top.value = disp

    num_its, converged = solver.solve(u)
    assert converged

    u.x.scatter_forward()  # updates ghost values for parallel computations

    print(
        f"Time step {n}, Number of iterations {num_its}, Disp {disp:.3f}"
    )

    with io.XDMFFile(domain.comm, out_file, "a") as xdmf:
        xdmf.write_function(u, n + 1)


Snippets from the error:

in <module> problem = NonlinearMPCProblem(Residual, u, mpc, bcs=bcs, J=J)

super().__init__(F, u, bcs=bcs, J=J, form_compiler_options=form_compiler_options, jit_options=jit_options)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

self._a = _create_form(
         ~~~~~~~~~~~~^
        J, form_compiler_options=form_compiler_options, jit_options=jit_options
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )

NotImplementedError: Cannot take length of non-vector expression.

Perhaps related is the fact that I can’t import from dolfinx.la.petsc import create_vector, but instead use from dolfinx.fem.petsc import create_vector.

I appreciate any suggestions

First of all, you should change your constraint to:

def bot_left_corner(x):
    return np.logical_and(np.isclose(x[1], 0.0), np.isclose(x[0], 0.0))

def right_boundary(x):
    return np.isclose(x[0], length_plate)

def top_boundary(x):
    return np.isclose(x[1], length_plate) & np.invert(right_boundary(x))

to avoid constraining the same dof twice.

Secondly, you are passing in the wrong value for the jacobian:

This should be

problem = NonlinearMPCProblem(Residual, u, mpc, bcs=bcs, J=Jacobian)
solver = NewtonSolverMPC(domain.comm, problem, mpc)
solver.atol = 1e-4
solver.rtol = 1e-4

Additionally, your change of create_vector is not correct. What version of DOLFINx are you using?

1 Like

Thank you dokken! That fixed the error I had before - now I indeed get a mistake for the create_vector function.
I’m using dolfinx 0.9.0 and dolfinx_mpc 0.9.1.

If you are using v0.9.1, you need to look at the demo for the 0.9.1 version:dolfinx_mpc/python/tests/test_nonlinear_assembly.py at v0.9.1 · jorgensd/dolfinx_mpc · GitHub

1 Like

I see, thank you for that, the create_vector function changed. I’ve updated that part of the code.

Now I don’t get any direct errors, but the Newton solver still can not converge (for any step, even with lenient tolerance settings). If I set the loading to 0 then I do “converge” and the code runs.
Am I still making a mistake in my boundary conditions?

Here is an updated code with the previously discussed changes:

import numpy as np
import ufl

from mpi4py import MPI
from dolfinx import fem, io, mesh
import dolfinx_mpc
import dolfinx.la as _la
import dolfinx.fem.petsc
import dolfinx.nls.petsc
import petsc4py.PETSc as PETSc
from ufl import ( Identity, grad, ln, tr, det, variable, derivative, TestFunction, TrialFunction )

"""
Solving functions copied from dolfinx_mpc/python/tests/test_nonlinear_assembly.py
"""

class NonlinearMPCProblem(dolfinx.fem.petsc.NonlinearProblem):
    def __init__(self, F, u, mpc, bcs=[], J=None, form_compiler_options={}, jit_options={}):
        self.mpc = mpc
        super().__init__(F, u, bcs=bcs, J=J, form_compiler_options=form_compiler_options, jit_options=jit_options)

    def F(self, x: PETSc.Vec, F: PETSc.Vec):  # type: ignore
        with F.localForm() as F_local:
            F_local.set(0.0)
        dolfinx_mpc.assemble_vector(self._L, self.mpc, b=F)

        # Apply boundary condition
        dolfinx_mpc.apply_lifting(
            F,
            [self._a],
            bcs=[self.bcs],
            constraint=self.mpc,
            x0=[x],
            scale=dolfinx.default_scalar_type(-1.0),
        )
        F.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE)  # type: ignore
        dolfinx.fem.petsc.set_bc(F, self.bcs, x, -1.0)

    def J(self, x: PETSc.Vec, A: PETSc.Mat):  # type: ignore
        A.zeroEntries()
        dolfinx_mpc.assemble_matrix(self._a, self.mpc, bcs=self.bcs, A=A)
        A.assemble()


class NewtonSolverMPC(dolfinx.cpp.nls.petsc.NewtonSolver):
    def __init__(
        self,
        comm: MPI.Intracomm,
        problem: NonlinearMPCProblem,
        mpc: dolfinx_mpc.MultiPointConstraint,
    ):
        """A Newton solver for non-linear MPC problems."""
        super().__init__(comm)
        self.mpc = mpc
        self.u_mpc = dolfinx.fem.Function(mpc.function_space)

        # Create matrix and vector to be used for assembly of the non-linear
        # MPC problem
        self._A = dolfinx_mpc.cpp.mpc.create_matrix(problem.a._cpp_object, mpc._cpp_object)
        self._b = _la.create_petsc_vector(mpc.function_space.dofmap.index_map, mpc.function_space.dofmap.index_map_bs)

        self.setF(problem.F, self._b)
        self.setJ(problem.J, self._A)
        self.set_form(problem.form)
        self.set_update(self.update)

    def update(self, solver: dolfinx.nls.petsc.NewtonSolver, dx: PETSc.Vec, x: PETSc.Vec):  # type: ignore
        # We need to use a vector created on the MPC's space to update ghosts
        self.u_mpc.x.petsc_vec.array = x.array_r
        self.u_mpc.x.petsc_vec.axpy(-1.0, dx)
        self.u_mpc.x.petsc_vec.ghostUpdate(
            addv=PETSc.InsertMode.INSERT,  # type: ignore
            mode=PETSc.ScatterMode.FORWARD,  # type: ignore
        )  # type: ignore
        self.mpc.homogenize(self.u_mpc)
        self.mpc.backsubstitution(self.u_mpc)
        x.array = self.u_mpc.x.petsc_vec.array_r
        x.ghostUpdate(
            addv=PETSc.InsertMode.INSERT,  # type: ignore
            mode=PETSc.ScatterMode.FORWARD,  # type: ignore
        )  # type: ignore

    def solve(self, u: dolfinx.fem.Function):
        """Solve non-linear problem into function u. Returns the number
        of iterations and if the solver converged."""
        n, converged = super().solve(u.x.petsc_vec)
        u.x.scatter_forward()
        return n, converged

    @property
    def A(self) -> PETSc.Mat:  # type: ignore
        """Jacobian matrix"""
        return self._A

    @property
    def b(self) -> PETSc.Vec:  # type: ignore
        """Residual vector"""
        return self._b

"""
Mesh
"""
length_plate = 1

# Create a simple 2D grid mesh (1x1) with 20 elements in each direction
domain = mesh.create_rectangle(
    comm=MPI.COMM_WORLD,
    points=((0, 0), (length_plate, length_plate)),
    n=(20, 20),
    cell_type=mesh.CellType.triangle
)
domain.topology.create_connectivity(domain.topology.dim - 1, domain.topology.dim)
dim = domain.topology.dim

"""
Function spaces
"""
degree = 1
shape = (dim,)
V = fem.functionspace(domain, ("P", degree, shape))

"""
Boundary conditions
"""
def bot_left_corner(x):
    return np.logical_and(np.isclose(x[1], 0.0), np.isclose(x[0], 0.0))

def right_boundary(x):
    return np.isclose(x[0], length_plate)

def top_boundary(x):
    return np.isclose(x[1], length_plate) & np.invert(right_boundary(x))

bot_left_dof = fem.locate_dofs_geometrical(
    V, bot_left_corner
)

top_facets = mesh.locate_entities_boundary(domain, dim-1, top_boundary)
top_dofs = fem.locate_dofs_topological(V.sub(1), dim-1, top_facets)
u_top = fem.Constant(domain, PETSc.ScalarType(1.))

# Fixed bottom left corner, prescribed value on all top dofs
bcs = [fem.dirichletbc(np.zeros((dim,)), bot_left_dof, V), fem.dirichletbc(u_top, top_dofs, V.sub(1))]

a = np.array([1, 0])

def periodic_relation_left_right(x):
    out_x = np.zeros(x.shape)
    out_x[0] = x[0] - 1.0
    out_x[1] = x[1]
    out_x[2] = x[2]
    return out_x

def periodic_relation_bottom_top(x):
    out_x = np.zeros(x.shape)
    out_x[0] = x[0]
    out_x[1] = x[1] - 1.0
    out_x[2] = x[2]
    return out_x

mpc = dolfinx_mpc.MultiPointConstraint(V)
mpc.create_periodic_constraint_geometrical(V, right_boundary, periodic_relation_left_right, bcs)
mpc.create_periodic_constraint_geometrical(V, top_boundary, periodic_relation_bottom_top, bcs)
mpc.finalize()

# Define variational problem
V_mpc = mpc.function_space

# unkown lives in MPC space
u = fem.Function(V_mpc, name="Displacement")
"""
Hyperelastic material model (compressible neo-Hookean model)
"""
# Identity tensor
Id = Identity(dim)

F = variable(Id + grad(u))  # Deformation gradient
C = F.T * F     # Right Cauchy-Green tensor
# Invariants of deformation tensors
I1 = tr(C)
J = det(F)

E = 1e3
nu = 0.3
mu = fem.Constant(domain, E / 2 / (1 + nu))
lmbda = fem.Constant(domain, E * nu / (1 - 2 * nu) / (1 + nu))

# Stored strain energy density
psi = mu / 2 * (I1 - 3 - 2 * ln(J)) + lmbda / 2 * (J - 1) ** 2

"""
Problem setup
"""
dx = ufl.Measure("dx", domain=domain, metadata={"quadrature_degree": 2})
E_pot = psi * dx

v = TestFunction(V)
du = TrialFunction(V)
Residual = derivative(
    E_pot, u, v
)  # This is equivalent to Residual = inner(P, grad(v))*dx
Jacobian = derivative(Residual, u, du)

problem = NonlinearMPCProblem(Residual, u, mpc, bcs=bcs, J=Jacobian)
solver = NewtonSolverMPC(domain.comm, problem, mpc)
solver.atol = 1e-4
solver.rtol = 1e-4

"""
Solving of problem
"""

out_file = "compression_hypel_MWE.xdmf"
with io.XDMFFile(domain.comm, out_file, "w") as xdmf:
    xdmf.write_mesh(domain)

for n, disp in enumerate(np.linspace(0, -0.2, 11)[1:]):
    u_top.value = disp

    num_its, converged = solver.solve(u)
    assert converged

    u.x.scatter_forward()  # updates ghost values for parallel computations

    print(
        f"Time step {n}, Number of iterations {num_its}, Disp {disp:.3f}"
    )

    with io.XDMFFile(domain.comm, out_file, "a") as xdmf:
        xdmf.write_function(u, n + 1)

In the next release of DOLFINx (v0.10.0), a corresponding release of DOLFINx_MPC will include a better Non-linear interface, as illustrated in: Add SNES nonlinear problem as MPC class by jorgensd · Pull Request #167 · jorgensd/dolfinx_mpc · GitHub

1 Like

Hi,

I’ve returned to this problem now and have switched to dolfinx 0.10 and dolfinx_mpc 0.10.

I’ve changed the setup a bit, but something is still going wrong with the boundary conditions - the corners appear over-constrained. Or does the problem come from how I define the fluctuation from the deformation gradient?

Here is the full MWE code:

import numpy as np
import ufl
from mpi4py import MPI
from dolfinx import fem, io, mesh
import petsc4py.PETSc as PETSc
from ufl import (grad, ln, tr, det, variable, derivative, TestFunction, TrialFunction)
from dolfinx_mpc import NonlinearProblem, MultiPointConstraint

# ------------------------------------------------------------------------------
# Mesh Generation
# ------------------------------------------------------------------------------
comm = MPI.COMM_WORLD

# Generate simple rectangular mesh
length_plate = 1.0
n_elem = 30

domain = mesh.create_rectangle(
    comm=comm,
    points=((0.0, 0.0), (length_plate, length_plate)),
    n=(n_elem, n_elem),
    cell_type=mesh.CellType.triangle
)
dim = domain.topology.dim

# ------------------------------------------------------------------------------
# Boundary Conditions and Function Spaces
# ------------------------------------------------------------------------------
def bot_left_corner(x):
    return np.logical_and(np.isclose(x[0], 0.0), np.isclose(x[1], 0.0))

def bot_right_corner(x):
    return np.logical_and(np.isclose(x[0], length_plate), np.isclose(x[1], 0.0))

def top_left_corner(x):
    return np.logical_and(np.isclose(x[0], 0.0), np.isclose(x[1], length_plate))

def top_right_corner(x):
    return np.logical_and(np.isclose(x[0], length_plate), np.isclose(x[1], length_plate))

# Define boundaries for periodicity
def right_boundary(x):
    return np.isclose(x[0], length_plate) & ~bot_right_corner(x) & ~top_right_corner(x)

def top_boundary(x):
    return np.isclose(x[1], length_plate) & ~top_left_corner(x) & ~top_right_corner(x)

# Define periodic mappings
def periodic_map_left_right(x):
    out_x = np.zeros(x.shape)
    out_x[0] = x[0] - length_plate
    out_x[1] = x[1]
    out_x[2] = x[2]
    return out_x

def periodic_map_bottom_top(x):
    out_x = np.zeros(x.shape)
    out_x[0] = x[0]
    out_x[1] = x[1] - length_plate
    out_x[2] = x[2]
    return out_x

# Space for the periodic fluctuation field 'u'
V = fem.functionspace(domain, ("P", 1, (dim,)))

# Fix displacement at bottom-left corner (to remove rigid body motions)
pinned_dofs = fem.locate_dofs_geometrical(V, bot_left_corner)
u_zero = fem.Constant(domain, np.array([0.0, 0.0], dtype=PETSc.ScalarType))
bcs = [fem.dirichletbc(u_zero, pinned_dofs, V)]

# Initialize MultiPointConstraint
mpc = MultiPointConstraint(V)
mpc.create_periodic_constraint_geometrical(V, right_boundary, periodic_map_left_right, bcs)
mpc.create_periodic_constraint_geometrical(V, top_boundary, periodic_map_bottom_top, bcs)
mpc.finalize()

# ------------------------------------------------------------------------------
# Variational Formulation (Fluctuation Method)
# ------------------------------------------------------------------------------
V_mpc = mpc.function_space
u = fem.Function(V_mpc, name="Fluctuation")

# Macroscopic Deformation Gradient
F_macro_data = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=PETSc.ScalarType)
F_macro = fem.Constant(domain, F_macro_data)

# Kinematics: Total F = F_macro + grad(u_fluctuation)
F = variable(F_macro + grad(u))

# Material Properties
E_1 = 20.0
nu_1 = 0.3
mu_1_val = E_1 / (2 * (1 + nu_1))
lmbda_1_val = E_1 * nu_1 / ((1 + nu_1) * (1 - 2 * nu_1))

# Strain Energy Density Function (Neo-Hookean)
C = F.T * F
I1 = tr(C)
J = det(F)
psi = (mu_1_val / 2) * (I1 - 3 - 2 * ln(J)) + (lmbda_1_val / 2) * (J - 1) ** 2

# Total Potential Energy
dx = ufl.Measure("dx", domain=domain)
E_pot = psi * dx

# Derivatives
v = TestFunction(V)
du = TrialFunction(V)
Residual = derivative(E_pot, u, v)
Jacobian = derivative(Residual, u, du)

# Problem Setup
problem = NonlinearProblem(Residual, u, mpc, bcs=bcs, J=Jacobian)
solver = problem.solver
# specific solvers omitted from MWE - they appear to give the same issues as default

# ------------------------------------------------------------------------------
# Simulation Loop and Output
# ------------------------------------------------------------------------------
# Aux space for visualization of total displacement
W = fem.functionspace(domain, ("P", 1, (dim,)))
u_total_vis = fem.Function(W, name="Total_Displacement")
x_coords = fem.Function(W)
x_coords.interpolate(lambda x: x[:2])  # Capture reference coords

# Output file
out_file = "MWE_output.xdmf"
with io.XDMFFile(domain.comm, out_file, "w") as xdmf:
    xdmf.write_mesh(domain)

    for n, eps in enumerate(np.linspace(0, 0.4, 21)):
        if n == 0: continue
        # Update Macroscopic Gradient (Uniaxial Compression in Y)
        current_F_macro = np.array([[1.0, 0.0],
                                    [0.0, 1.0 - eps]], dtype=PETSc.ScalarType)
        F_macro.value[:] = current_F_macro

        print(f"Step {n}: eps = {eps:.4f}")

        # Solve the nonlinear problem
        u_mpc, converged, iters = problem.solve()
        if converged > 0:
            print(f"Converged reason: {converged} in {iters} iterations.")
        else:
            print(f"Failed to converge. Reason: {converged}, after {iters} iterations.")

        # Update ghost values
        u.x.scatter_forward()

        # Reconstruct Total Displacement: u_total = u_fluc + (F_macro - I) * X
        F_minus_I = current_F_macro - np.eye(2)
        x_array = x_coords.x.array.reshape((-1, 2))
        affine_disp = x_array @ F_minus_I.T
        u_total_vis.x.array[:] = u.x.array + affine_disp.flatten()

        # Save to file
        xdmf.write_function(u_total_vis, n)

Output:

Step 1: eps = 0.0200
Converged reason: 3 in 6 iterations.
Step 2: eps = 0.0400
Converged reason: 3 in 6 iterations.
Step 3: eps = 0.0600
Converged reason: 3 in 7 iterations.
Step 4: eps = 0.0800
Converged reason: 4 in 7 iterations.
Step 5: eps = 0.1000
Converged reason: 4 in 7 iterations.
Step 6: eps = 0.1200
Converged reason: 4 in 8 iterations.
Step 7: eps = 0.1400
Converged reason: 4 in 9 iterations.
Step 8: eps = 0.1600
Converged reason: 4 in 11 iterations.
Step 9: eps = 0.1800
Converged reason: 4 in 13 iterations.
Step 10: eps = 0.2000
Converged reason: 4 in 16 iterations.
Step 11: eps = 0.2200
Converged reason: 4 in 21 iterations.
Step 12: eps = 0.2400
Converged reason: 4 in 31 iterations.
Step 13: eps = 0.2600
Converged reason: 4 in 48 iterations.
Step 14: eps = 0.2800
Failed to converge. Reason: -5, after 50 iterations.
Step 15: eps = 0.3000
Failed to converge. Reason: -5, after 50 iterations.
Step 16: eps = 0.3200
Failed to converge. Reason: -5, after 50 iterations.
Step 17: eps = 0.3400
Converged reason: 4 in 42 iterations.
Step 18: eps = 0.3600
Converged reason: 4 in 30 iterations.
Step 19: eps = 0.3800
Converged reason: 4 in 24 iterations.
Step 20: eps = 0.4000
Converged reason: 4 in 22 iterations.

I’m been playing around with revising constraining across all axis (ref: Convergence difficulties with 3D periodic BCs - #2 by dokken)
which I’ve applied to your example:

import numpy as np
import ufl
from mpi4py import MPI
from dolfinx import fem, io, mesh
import petsc4py.PETSc as PETSc
from ufl import grad, ln, tr, det, variable, derivative, TestFunction, TrialFunction
from dolfinx_mpc import NonlinearProblem, MultiPointConstraint

# ------------------------------------------------------------------------------
# Mesh Generation
# ------------------------------------------------------------------------------
comm = MPI.COMM_WORLD

# Generate simple rectangular mesh
length_plate = 1.0
n_elem = 30

domain = mesh.create_rectangle(
    comm=comm,
    points=((0.0, 0.0), (length_plate, length_plate)),
    n=(n_elem, n_elem),
    cell_type=mesh.CellType.triangle,
)
dim = domain.topology.dim


# ------------------------------------------------------------------------------
# Boundary Conditions and Function Spaces
# ------------------------------------------------------------------------------
def bot_left_corner(x):
    return np.logical_and(np.isclose(x[0], 0.0), np.isclose(x[1], 0.0))


# Space for the periodic fluctuation field 'u'
V = fem.functionspace(domain, ("P", 1, (dim,)))

# Fix displacement at bottom-left corner (to remove rigid body motions)
pinned_dofs = fem.locate_dofs_geometrical(V, bot_left_corner)
u_zero = fem.Constant(domain, np.array([0.0, 0.0], dtype=PETSc.ScalarType))
bcs = []  # [fem.dirichletbc(u_zero, pinned_dofs, V)]


# Initialize MultiPointConstraint
tol = 1e-10


def PeriodicBoundary(axis):
    """
    Full surface minus dofs constrained by BCs
    """

    def complicated_mpc(x):
        condition = np.isclose(x[axis], length_plate, atol=tol)
        if axis == 0:
            return condition
        elif axis == 1:
            return np.logical_and(
                condition, np.logical_and(x[0] > tol, x[0] < length_plate - tol)
            )
        elif axis == 2:
            return np.logical_and(
                condition,
                np.logical_and(
                    np.logical_and(x[0] > tol, x[0] < length_plate - tol),
                    np.logical_and(x[1] > tol, x[1] < length_plate - tol),
                ),
            )
        else:
            raise RuntimeError("Axis should be 0, 1, or 2")

    return complicated_mpc


def create_periodic_map(axis):
    def periodic_relation(x):
        out_x = x.copy()
        out_x[axis] = 0
        return out_x

    return periodic_relation


mpc = MultiPointConstraint(V)
for axis in range(2):
    mpc.create_periodic_constraint_geometrical(
        V, PeriodicBoundary(axis), create_periodic_map(axis), bcs, tol=1e-10
    )
mpc.finalize()


# ------------------------------------------------------------------------------
# Variational Formulation (Fluctuation Method)
# ------------------------------------------------------------------------------
V_mpc = mpc.function_space
u = fem.Function(V_mpc, name="Fluctuation")

# Macroscopic Deformation Gradient
F_macro_data = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=PETSc.ScalarType)
F_macro = fem.Constant(domain, F_macro_data)

# Kinematics: Total F = F_macro + grad(u_fluctuation)
F = variable(F_macro + grad(u))

# Material Properties
E_1 = 20.0
nu_1 = 0.3
mu_1_val = E_1 / (2 * (1 + nu_1))
lmbda_1_val = E_1 * nu_1 / ((1 + nu_1) * (1 - 2 * nu_1))

# Strain Energy Density Function (Neo-Hookean)
C = F.T * F
I1 = tr(C)
J = det(F)
psi = (mu_1_val / 2) * (I1 - 3 - 2 * ln(J)) + (lmbda_1_val / 2) * (J - 1) ** 2

# Total Potential Energy
dx = ufl.Measure("dx", domain=domain)
E_pot = psi * dx

# Derivatives
v = TestFunction(V)
du = TrialFunction(V)
Residual = derivative(E_pot, u, v)
Jacobian = derivative(Residual, u, du)

# Problem Setup
problem = NonlinearProblem(
    Residual,
    u,
    mpc,
    bcs=bcs,
    J=Jacobian,
    petsc_options={
        "snes_monitor": None,
        "ksp_type": "preonly",
        "pc_type": "lu",
        "pc_factor_mat_solver_type": "mumps",
        "mat_mumps_icntl_24": 1,
        "mat_mumps_icntl_25": 0,
        "snes_type": "newtonls",
        "snes_linesearch_type": "none",
        "snes_monitor": None,
        "snes_rtol": 1e-8,
        "snes_atol": 1e-10,
        "snes_max_it": 50,
    },
)
solver = problem.solver
# specific solvers omitted from MWE - they appear to give the same issues as default

# ------------------------------------------------------------------------------
# Simulation Loop and Output
# ------------------------------------------------------------------------------
# Aux space for visualization of total displacement
W = fem.functionspace(domain, ("P", 1, (dim,)))
u_total_vis = fem.Function(W, name="Total_Displacement")
x_coords = fem.Function(W)
x_coords.interpolate(lambda x: x[:2])  # Capture reference coords

# Output file
out_file = "MWE_output.xdmf"
with io.XDMFFile(domain.comm, out_file, "w") as xdmf:
    xdmf.write_mesh(domain)

    for n, eps in enumerate(np.linspace(0, 0.4, 21)):
        if n == 0:
            continue
        # Update Macroscopic Gradient (Uniaxial Compression in Y)
        current_F_macro = np.array(
            [[1.0, 0.0], [0.0, 1.0 - eps]], dtype=PETSc.ScalarType
        )
        F_macro.value[:] = current_F_macro

        print(f"Step {n}: eps = {eps:.4f}")

        # Solve the nonlinear problem
        u_mpc, converged, iters = problem.solve()
        if converged > 0:
            print(f"Converged reason: {converged} in {iters} iterations.")
        else:
            print(f"Failed to converge. Reason: {converged}, after {iters} iterations.")

        # Update ghost values
        u.x.scatter_forward()

        # Reconstruct Total Displacement: u_total = u_fluc + (F_macro - I) * X
        F_minus_I = current_F_macro - np.eye(2)
        x_array = x_coords.x.array.reshape((-1, 2))
        affine_disp = x_array @ F_minus_I.T
        u_total_vis.x.array[:] = u.x.array + affine_disp.flatten()

        # Save to file
        xdmf.write_function(u_total_vis, n)

which yields a solution for every step and the final solution


which looks better, but something weird (although periodic) happens at the corners. I don’t have more time to look at this atm.

Thank you for the reply dokken.

Unfortunately, it is also not stable yet with the changes you’ve made - simply increasing the number of elements from 30x30 to 50x50 makes it fail. I am also surprised that you got rid of fixing the point at 0, 0, as I believe we now allow for rigid body movement.

One thing that I noticed with the help of your code is that specifying out_x = 0 gives different results compared to out_x = x[i]-length_plate. In this example, it can be the difference between the solver crashing or succeeding for certain setups (although always still giving wrong results).

The problem also occurs with a linear setup.

If anyone else has any suggestions for what the problem might be, please let me know.

This is because I let mumps handle the nullspace removal:

        "pc_factor_mat_solver_type": "mumps",
        "mat_mumps_icntl_24": 1,
        "mat_mumps_icntl_25": 0,

How does the solution differ for the two? These should give the same mapping?
If not, try adding a slightly higher tolerance to:

Ah I see, that is good to know.

Changing the MPC definition to handle all periodic nodes in a single call appears to have solved the issue:

# Link Left <-> Right and Bottom <-> Top
def periodic_relation(x):
    out_x = x[0]
    out_y = x[1]
    out_z = x[2]
    # Map right boundary (x=1) to left (x=0)
    out_x[np.isclose(x[0], 1.0)] = 0.0
    # Map top boundary (y=1) to bottom (y=0)
    out_y[np.isclose(x[1], 1.0)] = 0.0
    return np.array([out_x, out_y, out_z])

def boundary_locator(x):
    # Locate Right and Top boundaries to apply the constraint from
    return np.isclose(x[0], 1.0) | np.isclose(x[1], 1.0)

mpc = MultiPointConstraint(V)
mpc.create_periodic_constraint_geometrical(V, boundary_locator, periodic_relation, bcs)
mpc.finalize()

Hm, Since you have been able to pinpoint it I’ll try to have a closer look at the other one to figure out the error.

I have a follow-up question - I am unsure whether I should create a new thread for this. If I use the same script with a 2-phase material, convergence becomes very poor. Specifically, I wonder whether this formulation is correct:

def neo_hookean_psi(F, mu, lmbda):
    """Compute strain energy density for compressible Neo-Hookean material."""
    C = F.T * F
    I1 = tr(C)
    J = det(F)
    return (mu / 2) * (I1 - 3 - 2 * ln(J)) + (lmbda / 2) * (J - 1) ** 2

# Compute strain energy for each material phase
psi_matrix = neo_hookean_psi(F, mu_matrix, lmbda_matrix)
psi_inclusion = neo_hookean_psi(F, mu_inclusion, lmbda_inclusion)

# Select strain energy based on material tag
psi = ufl.conditional(ufl.eq(material_tag, matrix_tag), psi_matrix, psi_inclusion)

With mu_matrix = mu_inclusion and lmbda_matrix = lmbda_inclusion, it instantly converges. The more these parameters deviate, the worse the convergence gets. This makes me wonder whether the differentiation is not working well through this conditional?

(In practice, I want to use two different material model functions, so I can’t just create a piecewise field for mu and lmbda as done in this tutorial)

Here is the full code, adapted from your MWE discussed earlier:

import numpy as np
import ufl
from mpi4py import MPI
from dolfinx import fem, io, mesh
import petsc4py.PETSc as PETSc
from ufl import grad, ln, tr, det, variable, derivative, TestFunction, TrialFunction
from dolfinx_mpc import NonlinearProblem, MultiPointConstraint

# ------------------------------------------------------------------------------
# Mesh Generation
# ------------------------------------------------------------------------------
comm = MPI.COMM_WORLD

# Material tags
matrix_tag = 1
inclusion_tag = 2

# Generate simple rectangular mesh
length_plate = 1.0
n_elem = 30

domain = mesh.create_rectangle(
    comm=comm,
    points=((0.0, 0.0), (length_plate, length_plate)),
    n=(n_elem, n_elem),
    cell_type=mesh.CellType.triangle,
)
dim = domain.topology.dim

# Define geometric inclusion (Centered Circular Inclusion)
inclusion_center = np.array([length_plate / 2, length_plate / 2])
inclusion_radius = 0.25


def inclusion_marker(x):
    """Returns True if point is inside the circular inclusion."""
    dist_sq = (x[0] - inclusion_center[0]) ** 2 + (x[1] - inclusion_center[1]) ** 2
    return dist_sq <= inclusion_radius ** 2


# Locate cells belonging to the inclusion
inclusion_cells = mesh.locate_entities(domain, dim, inclusion_marker)


# ------------------------------------------------------------------------------
# Boundary Conditions and Function Spaces
# ------------------------------------------------------------------------------
def bot_left_corner(x):
    return np.logical_and(np.isclose(x[0], 0.0), np.isclose(x[1], 0.0))


# Space for the periodic fluctuation field 'u'
V = fem.functionspace(domain, ("P", 1, (dim,)))

# Fix displacement at bottom-left corner (to remove rigid body motions)
pinned_dofs = fem.locate_dofs_geometrical(V, bot_left_corner)
u_zero = fem.Constant(domain, np.array([0.0, 0.0], dtype=PETSc.ScalarType))
bcs = [fem.dirichletbc(u_zero, pinned_dofs, V)]

# Initialize MultiPointConstraint
# Link Left <-> Right and Bottom <-> Top
def periodic_relation(x):
    out_x = x[0]
    out_y = x[1]
    out_z = x[2]
    # Map right boundary (x=1) to left (x=0)
    out_x[np.isclose(x[0], 1.0)] = 0.0
    # Map top boundary (y=1) to bottom (y=0)
    out_y[np.isclose(x[1], 1.0)] = 0.0
    return np.array([out_x, out_y, out_z])

def boundary_locator(x):
    # Locate Right and Top boundaries to apply the constraint from
    return np.isclose(x[0], 1.0) | np.isclose(x[1], 1.0)

mpc = MultiPointConstraint(V)
mpc.create_periodic_constraint_geometrical(V, boundary_locator, periodic_relation, bcs)
mpc.finalize()

# ------------------------------------------------------------------------------
# Variational Formulation (Fluctuation Method)
# ------------------------------------------------------------------------------
V_mpc = mpc.function_space
u = fem.Function(V_mpc, name="Fluctuation")

# Macroscopic Deformation Gradient
F_macro_data = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=PETSc.ScalarType)
F_macro = fem.Constant(domain, F_macro_data)

# Kinematics: Total F = F_macro + grad(u_fluctuation)
F = variable(F_macro + grad(u))

# Material Properties - Matrix
E_matrix = 100.0
nu_matrix = 0.3
mu_matrix = E_matrix / (2 * (1 + nu_matrix))
lmbda_matrix = E_matrix * nu_matrix / ((1 + nu_matrix) * (1 - 2 * nu_matrix))

# Material Properties - Inclusion (stiffer)
E_inclusion = 1000.0
nu_inclusion = 0.3
mu_inclusion = E_inclusion / (2 * (1 + nu_inclusion))
lmbda_inclusion = E_inclusion * nu_inclusion / ((1 + nu_inclusion) * (1 - 2 * nu_inclusion))

# DG0 function space for material tagging
Q = fem.functionspace(domain, ("DG", 0))
material_tag = fem.Function(Q, name="Material_Tag")
material_tag.x.array[:] = matrix_tag
material_tag.x.array[inclusion_cells] = inclusion_tag


def neo_hookean_psi(F, mu, lmbda):
    """Compute strain energy density for compressible Neo-Hookean material."""
    C = F.T * F
    I1 = tr(C)
    J = det(F)
    return (mu / 2) * (I1 - 3 - 2 * ln(J)) + (lmbda / 2) * (J - 1) ** 2

# Compute strain energy for each material phase
psi_matrix = neo_hookean_psi(F, mu_matrix, lmbda_matrix)
psi_inclusion = neo_hookean_psi(F, mu_inclusion, lmbda_inclusion)

# Select strain energy based on material tag
psi = ufl.conditional(ufl.eq(material_tag, matrix_tag), psi_matrix, psi_inclusion)

# Total Potential Energy
dx = ufl.Measure("dx", domain=domain)
E_pot = psi * dx

# Derivatives
v = TestFunction(V)
du = TrialFunction(V)
Residual = derivative(E_pot, u, v)
Jacobian = derivative(Residual, u, du)

# Problem Setup
problem = NonlinearProblem(
    Residual,
    u,
    mpc,
    bcs=bcs,
    J=Jacobian,
    petsc_options={
        "ksp_type": "preonly",
        "pc_type": "lu",
        "pc_factor_mat_solver_type": "mumps",
        "snes_type": "newtonls",
        "snes_linesearch_type": "none",
        "snes_monitor": None,
        "snes_rtol": 1e-8,
        "snes_atol": 1e-10,
        "snes_max_it": 20,
    },
)

# ------------------------------------------------------------------------------
# Simulation Loop and Output
# ------------------------------------------------------------------------------
# Aux space for visualization of total displacement
W = fem.functionspace(domain, ("P", 1, (dim,)))
u_total_vis = fem.Function(W, name="Total_Displacement")
x_coords = fem.Function(W)
x_coords.interpolate(lambda x: x[:2])  # Capture reference coords

# Output file
out_file = "MWE_2-phase.xdmf"
with io.XDMFFile(domain.comm, out_file, "w") as xdmf:
    xdmf.write_mesh(domain)

    for n, eps in enumerate(np.linspace(0, 0.4, 21)):
        if n == 0:
            continue

        # Update macroscopic gradient (uniaxial compression in Y)
        current_F_macro = np.array([[1.0, 0.0], [0.0, 1.0 - eps]], dtype=PETSc.ScalarType)
        F_macro.value[:] = current_F_macro
        print(f"Step {n}: eps = {eps:.4f}")

        # Solve the nonlinear problem
        u, converged, iters = problem.solve()
        if converged > 0:
            print(f"Converged reason: {converged} in {iters} iterations.")
        else:
            print(f"Failed to converge. Reason: {converged}, after {iters} iterations.")

        # Update ghost values
        u.x.scatter_forward()

        # Reconstruct Total Displacement: u_total = u_fluc + (F_macro - I) * X
        F_minus_I = current_F_macro - np.eye(2)
        x_array = x_coords.x.array.reshape((-1, 2))
        affine_disp = x_array @ F_minus_I.T
        u_total_vis.x.array[:] = u.x.array + affine_disp.flatten()

        # Save to file
        xdmf.write_function(u_total_vis, n)

I would start by adding a smaller tolerance to your solvers, and check if the visualized solution looks sensible, i.e.

        "snes_rtol": 1e-4,
        "snes_atol": 1e-4,
        "snes_stol": 1e-4,
        "snes_max_it": 20,

To me it looks like there is an issue when the eps is around 0.2 (I tried changing the spacing of eps, and the solver always fails around that point).

1 Like

In comparing the results with an in-house FE solver, I get identical (homogenized) results, but the in-house solver has clear quadratic convergence with a SkylineLU solver, and converges for more timesteps.

I’ve tried various solver settings in this example, but I cannot get similar performance, so something in the setup here is still proving difficult. I will give an update if I can resolve this.

I have resolved the issue. It was related to the constraints on the boundary conditions.
To fix it, set Dirichlet bcs to ALL corners:

def bot_left(x):
    return np.isclose(x[0], 0.0) & np.isclose(x[1], 0.0)

def top_right(x):
    return np.isclose(x[0], 1.0) & np.isclose(x[1], 1.0)

def top_left(x):
    return np.isclose(x[0], 0.0) & np.isclose(x[1], 1.0)

def bot_right(x):
    return np.isclose(x[0], 1.0) & np.isclose(x[1], 0.0)

# Space for the periodic fluctuation field 'u'
V = fem.functionspace(domain, ("P", 1, (dim,)))

# Fix displacement at all corners
dofs_bl = fem.locate_dofs_geometrical(V, bot_left)
dofs_tr = fem.locate_dofs_geometrical(V, top_right)
dofs_tl = fem.locate_dofs_geometrical(V, top_left)
dofs_br = fem.locate_dofs_geometrical(V, bot_right)
u_zero = fem.Constant(domain, np.array([0.0, 0.0], dtype=PETSc.ScalarType))
bcs = [fem.dirichletbc(u_zero, dofs_bl, V),
       fem.dirichletbc(u_zero, dofs_tr, V),
       fem.dirichletbc(u_zero, dofs_tl, V),
       fem.dirichletbc(u_zero, dofs_br, V)]

And remove these corner points from the MPC definition:

def boundary_locator(x):
    """Identify right and top boundaries, excluding corners."""
    on_right = np.isclose(x[0], 1.0)
    on_top = np.isclose(x[1], 1.0)
    on_left = np.isclose(x[0], 0.0)
    on_bottom = np.isclose(x[1], 0.0)

    # Exclude corners: (1,0), (0,1), (1,1)
    right_inner_edge = on_right & ~on_top & ~on_bottom
    top_inner_edge = on_top & ~on_left & ~on_right

    return right_inner_edge | top_inner_edge

The resulting convergence behavior (similar for all timesteps)

Step 1: eps = 0.0200
  0 SNES Function norm 4.129362588121e+00
  1 SNES Function norm 2.888957382718e-02
  2 SNES Function norm 8.309586969502e-06
  3 SNES Function norm 5.437709616069e-08
  4 SNES Function norm 4.751528791612e-10
1 Like

Unfortunately, the above did not completely resolve the issue yet. When applying it to more complex problems that I am interested in, convergence is still very poor, and it frequently fails to converge for the first timestep already.
To get some indication for what is happening, I am plotting the residual error over the domain for a modified problem setup. On the left of the Figure is the material tag, on the right is the pattern of the residual. (loaded in vertical compression)

This specific problem does converge, but not quadratically:

Step 1: eps = 0.0100
  0 SNES Function norm 1.626539015647e+00
  1 SNES Function norm 5.721892928751e-03
  2 SNES Function norm 1.237730383369e-04
  3 SNES Function norm 4.454470903946e-06
  4 SNES Function norm 2.795399754864e-07
  5 SNES Function norm 2.356995808170e-08
  6 SNES Function norm 1.614224291657e-09

Running the problem without creating periodic constraints does lead to quadratic convergence.
@dokken I was wondering if you have some intuition for why this is happening, and what directions to look into to solve it?
Thank you!

MWE used for the figure:

import numpy as np
import ufl
from mpi4py import MPI
from dolfinx import fem, io, mesh
import petsc4py.PETSc as PETSc
from ufl import grad, ln, tr, det, variable, derivative, TestFunction, TrialFunction
from dolfinx_mpc import NonlinearProblem, MultiPointConstraint

# ------------------------------------------------------------------------------
# Mesh Generation
# ------------------------------------------------------------------------------
comm = MPI.COMM_WORLD

# Material tags
matrix_tag = 1
inclusion_tag = 2

# Generate simple rectangular mesh
length_plate = 1.0
n_elem = 50

domain = mesh.create_rectangle(
    comm=comm,
    points=((0.0, 0.0), (length_plate, length_plate)),
    n=(n_elem, n_elem),
    cell_type=mesh.CellType.triangle,
)
dim = domain.topology.dim

# Define geometric inclusion (Centered Circular Inclusion)
inclusion_center = np.array([length_plate / 2, length_plate / 2])
inclusion_radius = 0.25

def inclusion_marker(x):
    """Returns True if point is inside the circular inclusion."""
    dist_sq = (x[0] + 0.1 ) ** 2 + (x[1] - 0.5) ** 2
    dist_sq_2 = (x[0] - .9 ) ** 2 + (x[1] - 0.5) ** 2
    # Using two inclusions over boundary makes it converge a bit slower, but residuals stay low.
    return (dist_sq <= 0.25 ** 2) | (dist_sq_2 <= 0.25 ** 2)

# Locate cells belonging to the inclusion
inclusion_cells = mesh.locate_entities(domain, dim, inclusion_marker)


# ------------------------------------------------------------------------------
# Boundary Conditions and Function Spaces
# ------------------------------------------------------------------------------
def bot_left(x):
    return np.isclose(x[0], 0.0) & np.isclose(x[1], 0.0)

def top_right(x):
    return np.isclose(x[0], 1.0) & np.isclose(x[1], 1.0)

def top_left(x):
    return np.isclose(x[0], 0.0) & np.isclose(x[1], 1.0)

def bot_right(x):
    return np.isclose(x[0], 1.0) & np.isclose(x[1], 0.0)

# Space for the periodic fluctuation field 'u'
V = fem.functionspace(domain, ("P", 1, (dim,)))

# Fix displacement at all corners
dofs_bl = fem.locate_dofs_geometrical(V, bot_left)
dofs_tr = fem.locate_dofs_geometrical(V, top_right)
dofs_tl = fem.locate_dofs_geometrical(V, top_left)
dofs_br = fem.locate_dofs_geometrical(V, bot_right)
u_zero = fem.Constant(domain, np.array([0.0, 0.0], dtype=PETSc.ScalarType))
bcs = [fem.dirichletbc(u_zero, dofs_bl, V),
       fem.dirichletbc(u_zero, dofs_tr, V),
       fem.dirichletbc(u_zero, dofs_tl, V),
       fem.dirichletbc(u_zero, dofs_br, V)]
# bcs = [fem.dirichletbc(u_zero, dofs_bl, V),]

# Initialize MultiPointConstraint
# Link Left <-> Right and Bottom <-> Top
def periodic_relation(x):
    out_x = x[0]
    out_y = x[1]
    out_z = x[2]
    # Map right boundary (x=1) to left (x=0)
    out_x[np.isclose(x[0], 1.0)] = 0.0
    # Map top boundary (y=1) to bottom (y=0)
    out_y[np.isclose(x[1], 1.0)] = 0.0
    return np.array([out_x, out_y, out_z])


def boundary_locator(x):
    """Identify right and top boundaries, excluding corners."""
    on_right = np.isclose(x[0], 1.0)
    on_top = np.isclose(x[1], 1.0)
    on_left = np.isclose(x[0], 0.0)
    on_bottom = np.isclose(x[1], 0.0)

    # Exclude corners: (1,0), (0,1), (1,1)
    right_inner_edge = on_right & ~on_top & ~on_bottom
    top_inner_edge = on_top & ~on_left & ~on_right

    return right_inner_edge | top_inner_edge

mpc = MultiPointConstraint(V)
mpc.create_periodic_constraint_geometrical(V, boundary_locator, periodic_relation, bcs)
mpc.finalize()

# ------------------------------------------------------------------------------
# Variational Formulation (Fluctuation Method)
# ------------------------------------------------------------------------------
V_mpc = mpc.function_space
u = fem.Function(V_mpc, name="Fluctuation")

# Macroscopic Deformation Gradient
F_macro_data = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=PETSc.ScalarType)
F_macro = fem.Constant(domain, F_macro_data)

# Kinematics: Total F = F_macro + grad(u_fluctuation)
F = variable(F_macro + grad(u))

# Material Properties - Matrix
E_matrix = 100.0
nu_matrix = 0.3
mu_matrix = E_matrix / (2 * (1 + nu_matrix))
lmbda_matrix = E_matrix * nu_matrix / ((1 + nu_matrix) * (1 - 2 * nu_matrix))

# Material Properties - Inclusion (stiffer)
E_inclusion = 1000.0
nu_inclusion = 0.3
mu_inclusion = E_inclusion / (2 * (1 + nu_inclusion))
lmbda_inclusion = E_inclusion * nu_inclusion / ((1 + nu_inclusion) * (1 - 2 * nu_inclusion))

# DG0 function space for material tagging
Q = fem.functionspace(domain, ("DG", 0))
material_tag = fem.Function(Q, name="Material_Tag")
material_tag.x.array[:] = matrix_tag
material_tag.x.array[inclusion_cells] = inclusion_tag


def neo_hookean_psi(F, mu, lmbda):
    """Compute strain energy density for compressible Neo-Hookean material."""
    C = F.T * F
    I1 = tr(C)
    J = det(F)
    return (mu / 2) * (I1 - 3 - 2 * ln(J)) + (lmbda / 2) * (J - 1) ** 2

# Compute strain energy for each material phase
psi_matrix = neo_hookean_psi(F, mu_matrix, lmbda_matrix)
psi_inclusion = neo_hookean_psi(F, mu_inclusion, lmbda_inclusion)

# Select strain energy based on material tag
psi = ufl.conditional(ufl.eq(material_tag, matrix_tag), psi_matrix, psi_inclusion)

# Total Potential Energy
dx = ufl.Measure("dx", domain=domain)
E_pot = psi * dx

# Derivatives
v = TestFunction(V)
du = TrialFunction(V)
Residual = derivative(E_pot, u, v)
Jacobian = derivative(Residual, u, du)

# Problem Setup
problem = NonlinearProblem(
    Residual,
    u,
    mpc,
    bcs=bcs,
    J=Jacobian,
    petsc_options={
        "ksp_type": "preonly",
        "pc_type": "lu",
        "pc_factor_mat_solver_type": "mumps",
        "snes_type": "newtonls",
        "snes_linesearch_type": "basic",
        "snes_monitor": None,
        "snes_rtol": 1e-8,
        "snes_atol": 1e-10,
        "snes_max_it": 20,
    },
)

# ------------------------------------------------------------------------------
# Simulation Loop and Output
# ------------------------------------------------------------------------------
# Aux space for visualization of total displacement
W = fem.functionspace(domain, ("P", 1, (dim,)))
u_total_vis = fem.Function(W, name="Total_Displacement")
x_coords = fem.Function(W)
x_coords.interpolate(lambda x: x[:2])  # Capture reference coords

# Visualizing the Residual field
from dolfinx_mpc.assemble_vector import apply_lifting, assemble_vector
from dolfinx.la.petsc import create_vector
# Create a PETSc vector for the constrained residual
residual_field = fem.Function(V, name="Residual_Field")
residual_form = fem.form(Residual)
residual_petsc = create_vector([(mpc.function_space.dofmap.index_map,
                                 mpc.function_space.dofmap.index_map_bs)])

# Assemble the constrained residual (matching assemble_residual_mpc logic)
def compute_constrained_residual(u_func, residual_form, jacobian_form, bcs, mpc, residual_petsc):
    """Compute the residual with MPC and BCs applied."""
    # Homogenize and backsubstitute through MPC
    mpc.homogenize(u_func)
    mpc.backsubstitution(u_func)

    # Assemble the residual with MPC
    residual_petsc.zeroEntries()
    assemble_vector(residual_form, mpc, residual_petsc)

    # Apply lifting (accounts for inhomogeneous BCs in the Jacobian)
    apply_lifting(residual_petsc, [jacobian_form], bcs=[bcs], constraint=mpc)
    residual_petsc.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE)

    # Set BC values to zero in the residual
    fem.petsc.set_bc(residual_petsc, bcs)
    residual_petsc.ghostUpdate(addv=PETSc.InsertMode.INSERT, mode=PETSc.ScatterMode.FORWARD)

    return residual_petsc

# Output file
out_file = "MWE_2-phase_MPC.xdmf"
with io.XDMFFile(domain.comm, out_file, "w") as xdmf:
    xdmf.write_mesh(domain)

    for n, eps in enumerate(np.linspace(0, 0.2, 21)):
        if n == 0:
            continue
        xdmf.write_function(material_tag, n)

        # Update macroscopic gradient (uniaxial compression in Y)
        current_F_macro = np.array([[1.0, 0.0], [0.0, 1.0 - eps]], dtype=PETSc.ScalarType)
        F_macro.value[:] = current_F_macro
        print(f"Step {n}: eps = {eps:.4f}")

        # Solve the nonlinear problem
        u, converged, iters = problem.solve()
        if converged > 0:
            print(f"Converged reason: {converged} in {iters} iterations.")
        else:
            print(f"Failed to converge. Reason: {converged}, after {iters} iterations.")

        # Update ghost values
        u.x.scatter_forward()

        # Reconstruct Total Displacement: u_total = u_fluc + (F_macro - I) * X
        F_minus_I = current_F_macro - np.eye(2)
        x_array = x_coords.x.array.reshape((-1, 2))
        affine_disp = x_array @ F_minus_I.T
        u_total_vis.x.array[:] = u.x.array + affine_disp.flatten()

        # Save to file
        xdmf.write_function(u_total_vis, n)

        # Compute the residual
        jacobian_form = fem.form(Jacobian)
        constrained_res = compute_constrained_residual(u, residual_form, jacobian_form, bcs, mpc, residual_petsc)
        residual_field.x.array[:] = constrained_res.array
        xdmf.write_function(residual_field, n)

I believe there is an error in dolfinx_mpc that causes the Jacobian to be incorrect.
NonlinearProblem calls assemble_jacobian_mpc, which does not homogenize. Adding this middle section (like in assemble_residual_mpc) significantly improves convergence:

...
_fem.petsc.assign(x, u)

if isinstance(u, Sequence):
    assert isinstance(mpc, Sequence)
    for i in range(len(u)):
        mpc[i].homogenize(u[i])
        mpc[i].backsubstitution(u[i])
else:
    assert isinstance(u, _fem.Function)
    assert isinstance(mpc, MultiPointConstraint)
    mpc.homogenize(u)
    mpc.backsubstitution(u)

# Assemble Jacobian
J.zeroEntries()
...

Convergence on the latest MWE with this fix:

Step 1: eps = 0.0100
0 SNES Function norm 1.626539015647e+00
1 SNES Function norm 5.721892928751e-03
2 SNES Function norm 2.691866450251e-07
3 SNES Function norm 9.501986670691e-14

(With this, the previous fix of adding BCS at all corners becomes unnecessary.)
I’ve tried creating a pull request to the library, but lack permission.

——————————————————————————————————————————

For my specific problem of interest, there is still another issue troubling the solver at the interface between two materials (ellipsoids of much higher stiffness). Here is a residual plot:

And convergence is always something like:

  0 SNES Function norm 3.430633286766e+01
  1 SNES Function norm 3.194739429851e+01
  2 SNES Function norm 2.740305953784e+01
  3 SNES Function norm 2.240537741507e+01
  4 SNES Function norm 1.230618117556e+01
  5 SNES Function norm 2.525467305806e+00
  6 SNES Function norm 1.688819422942e+00
  7 SNES Function norm 4.336110972208e-02
  8 SNES Function norm 1.655273533811e-03
  9 SNES Function norm 4.485973939506e-08