Problems with Periodic BCs in 3D

For interested users:
After some considerations of @conpierce8, the problem was related to the mesh as there were no periodicity restrictions during the mesh generation. Even though dolfinx_mpc is able to handle periodic conditions on non-periodic meshes, you need to be careful that no slave node is used for a mapping as a master node (especially in the environment of edges).

Here is the example with fully periodic boundaries on a periodic mesh:

import gmsh
import dolfinx
import dolfinx_mpc
from dolfinx.fem import FunctionSpace, Function, Constant
from dolfinx.io import XDMFFile
from dolfinx.mesh import locate_entities_boundary, meshtags
from dolfinx.io.gmshio import model_to_mesh

from ufl import grad, dot, lhs, rhs, Measure, TrialFunction, TestFunction, FiniteElement
import ufl
from petsc4py import PETSc
import numpy as np
from mpi4py import MPI

# MPI initialization
comm = MPI.COMM_WORLD
rank = MPI.COMM_WORLD.rank

##################################################
## Mesh generation Start
##################################################
gmsh.initialize()
r = 0.1     # radius of sphere
L = 1.0     # length of box

gdim = 3         # Geometric dimension of the mesh
fdim = gdim - 1  # facet dimension

if rank == 0:
    # Define geometry for RVE with periodicity of the surfaces
    sphere = gmsh.model.occ.add_sphere(L / 2, L / 2, L / 2, r)
    box = gmsh.model.occ.add_box(0.0, 0.0, 0.0, L, L, L)
    whole_domain = gmsh.model.occ.fragment([(3, box)], [(3, sphere)])
    gmsh.model.occ.synchronize()

    # Ask OpenCASCADE to compute more accurate bounding boxes of entities using
    # the STL mesh:
    gmsh.option.setNumber("Geometry.OCCBoundsUseStl", 1)

    # The periodicity transform is provided as a 4x4 affine transformation matrix given by row.
    translation_x = [1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
    translation_y = [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1]
    translation_z = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1]

    eps = 1e-3
    # We get all surfaces on the left:
    sxmin = gmsh.model.getEntitiesInBoundingBox(- eps, -eps, -eps, eps, L + eps, L + eps, 2)
    # We get all surfaces on the bottom:
    symin = gmsh.model.getEntitiesInBoundingBox(- eps, -eps, -eps, L + eps, eps, L + eps, 2)
    # We get all surfaces on the front:
    szmin = gmsh.model.getEntitiesInBoundingBox(- eps, -eps, -eps, L + eps, L + eps, eps, 2)

    for i_x in sxmin:
        # Then we get the bounding box of each left surface
        xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(i_x[0], i_x[1])
        # We translate the bounding box to the right and look for surfaces inside it:
        sxmax = gmsh.model.getEntitiesInBoundingBox(xmin - eps + L, ymin - eps,
                                                    zmin - eps, xmax + eps + L,
                                                    ymax + eps, zmax + eps, 2)
        # For all the matches, we compare the corresponding bounding boxes...
        for j_x in sxmax:
            xmin2, ymin2, zmin2, xmax2, ymax2, zmax2 = gmsh.model.getBoundingBox(j_x[0], j_x[1])
            xmin2 -= 1
            xmax2 -= 1
            # ...and if they match, we apply the periodicity constraint
            if (abs(xmin2 - xmin) < eps and abs(xmax2 - xmax) < eps
                    and abs(ymin2 - ymin) < eps and abs(ymax2 - ymax) < eps
                    and abs(zmin2 - zmin) < eps and abs(zmax2 - zmax) < eps):
                gmsh.model.mesh.setPeriodic(2, [j_x[1]], [i_x[1]], translation_x)

    for i_y in symin:
        # Then we get the bounding box of each left surface
        xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(i_y[0], i_y[1])
        # We translate the bounding box to the right and look for surfaces inside it:
        symax = gmsh.model.getEntitiesInBoundingBox(xmin - eps, ymin - eps + L,
                                                    zmin - eps, xmax + eps,
                                                    ymax + eps + L, zmax + eps, 2)
        # For all the matches, we compare the corresponding bounding boxes...
        for j_y in symax:
            xmin2, ymin2, zmin2, xmax2, ymax2, zmax2 = gmsh.model.getBoundingBox(j_y[0], j_y[1])
            xmin2 -= 1
            xmax2 -= 1
            # ...and if they match, we apply the periodicity constraint
            if (abs(xmin2 - xmin) < eps and abs(xmax2 - xmax) < eps
                    and abs(ymin2 - ymin) < eps and abs(ymax2 - ymax) < eps
                    and abs(zmin2 - zmin) < eps and abs(zmax2 - zmax) < eps):
                gmsh.model.mesh.setPeriodic(2, [j_y[1]], [i_y[1]], translation_y)


    for i_z in szmin:
        # Then we get the bounding box of each left surface
        xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(i_z[0], i_z[1])
        # We translate the bounding box to the right and look for surfaces inside it:
        szmax = gmsh.model.getEntitiesInBoundingBox(xmin - eps, ymin - eps,
                                                    zmin - eps + L, xmax + eps,
                                                    ymax + eps, zmax + eps + L, 2)
        # For all the matches, we compare the corresponding bounding boxes...
        for j_z in szmax:
            xmin2, ymin2, zmin2, xmax2, ymax2, zmax2 = gmsh.model.getBoundingBox(j_z[0], j_z[1])
            xmin2 -= 1
            xmax2 -= 1
            # ...and if they match, we apply the periodicity constraint
            if (abs(xmin2 - xmin) < eps and abs(xmax2 - xmax) < eps
                    and abs(ymin2 - ymin) < eps and abs(ymax2 - ymax) < eps
                    and abs(zmin2 - zmin) < eps and abs(zmax2 - zmax) < eps):
                gmsh.model.mesh.setPeriodic(2, [j_z[1]], [i_z[1]], translation_z)


    matrix_physical = gmsh.model.addPhysicalGroup(gdim, [box], tag=1)         # tag for matrix
    inclusion_physical = gmsh.model.addPhysicalGroup(gdim, [sphere], tag=2)      # tag for inclusion

    # Get the interface elements between rectangle and circle and tag
    interface_elements = gmsh.model.getBoundary([(gdim, inclusion_physical)])
    interface_physical = gmsh.model.addPhysicalGroup(fdim, (1, 1), tag = 9)

    background_surfaces = []
    inclusion_surfaces = []
    for domain in whole_domain[0]:
        com = gmsh.model.occ.getCenterOfMass(domain[0], domain[1])
        mass = gmsh.model.occ.getMass(domain[0], domain[1])
        if np.isclose(mass, 4/3 * np.pi * (r ** 3)):  # identify inclusion by its mass
            inclusion_surfaces.append(domain)
        else:
            background_surfaces.append(domain)

    gmsh.model.mesh.field.add("Distance", 1)
    edges = gmsh.model.getBoundary(inclusion_surfaces, oriented=False)
    gmsh.model.mesh.field.setNumbers(1, "CurvesList", [e[0] for e in edges])
    gmsh.model.mesh.field.setNumber(1, "Sampling", 300)

    gmsh.model.mesh.field.add("Threshold", 2)
    gmsh.model.mesh.field.setNumber(2, "IField", 1)
    gmsh.model.mesh.field.setNumber(2, "LcMin", 0.010)
    gmsh.model.mesh.field.setNumber(2, "LcMax", 0.030)
    gmsh.model.mesh.field.setNumber(2, "DistMin", 0.00)
    gmsh.model.mesh.field.setNumber(2, "DistMax", 0.075)
    gmsh.model.mesh.field.setAsBackgroundMesh(2)

    gmsh.option.setNumber("Mesh.Algorithm", 6)
    gmsh.model.mesh.generate(gdim)

domain, ct, _ = model_to_mesh(gmsh.model, comm, rank, gdim=gdim)
gmsh.finalize()

xdmf = dolfinx.io.XDMFFile(domain.comm, "results_3D_MWE_v4/mesh.xdmf", "w")
xdmf.write_mesh(domain)
xdmf.close()
##################################################
## Mesh generation finished
##################################################

dx = Measure('dx', domain=domain, subdomain_data=ct)
x = ufl.SpatialCoordinate(domain)

# elements and funcionspace
###########################

P1 = FiniteElement("CG", domain.ufl_cell(), 1)
V = FunctionSpace(domain, P1)

# define function, trial function and test function
T_fluc = TrialFunction(V)
dT_fluc = TestFunction(V)

# define material parameter
S = FunctionSpace(domain, ("DG", 0))
kappa = Function(S)
matrix = ct.find(1)
kappa.x.array[matrix] = np.full_like(matrix, 5, dtype=PETSc.ScalarType)
inclusion = ct.find(2)
kappa.x.array[inclusion]  = np.full_like(inclusion, 1, dtype=PETSc.ScalarType)
kappa.x.scatter_forward()

# boundary conditions
#####################
bcs = []
## periodic boundary conditions
pbc_directions = []
pbc_slave_tags = []
pbc_is_slave = []
pbc_is_master = []
pbc_meshtags = []
pbc_slave_to_master_maps = []

mpc = dolfinx_mpc.MultiPointConstraint(V)

def generate_pbc_slave_to_master_map(i):
    def pbc_slave_to_master_map(x):
        out_x = x.copy()
        out_x[i] = x[i] - L
        return out_x

    return pbc_slave_to_master_map

def generate_pbc_is_slave(i):
    return lambda x: np.isclose(x[i], L)

def generate_pbc_is_master(i):
    return lambda x: np.isclose(x[i], 0.0)

for i in range(gdim):

    pbc_directions.append(i)
    pbc_slave_tags.append(i + 3)
    pbc_is_slave.append(generate_pbc_is_slave(i))
    pbc_is_master.append(generate_pbc_is_master(i))
    pbc_slave_to_master_maps.append(generate_pbc_slave_to_master_map(i))

    facets = locate_entities_boundary(domain, fdim, pbc_is_slave[-1])
    arg_sort = np.argsort(facets)
    pbc_meshtags.append(meshtags(domain,
                                 fdim,
                                 facets[arg_sort],
                                 np.full(len(facets), pbc_slave_tags[-1], dtype=np.int32)))

N_pbc = len(pbc_directions)
for i in range(N_pbc):

    # slave/master mapping of opposite surfaces (without slave-slave[-slave] intersections)
    def pbc_slave_to_master_map(x):
        out_x = pbc_slave_to_master_maps[i](x)
        # remove edges that are connected to another slave surface
        idx = np.logical_or(pbc_is_slave[(i + 1) % N_pbc](x),pbc_is_slave[(i + 2) % N_pbc](x))
        out_x[i][idx] = np.nan
        print("Number of Nodes for Surface mapping that are excluded: ", len(out_x[i][idx]))
        print("Number of Nodes for Surface mapping: ", len(out_x[i][~idx]))
        return out_x

    mpc.create_periodic_constraint_topological(V, pbc_meshtags[i],
                                                   pbc_slave_tags[i],
                                                   pbc_slave_to_master_map,
                                                   bcs)

if len(pbc_directions) > 1:
    def generate_pbc_slave_to_master_map_edges(dir_i, dir_j):
        def pbc_slave_to_master_map_edges(x):
            '''
            Maps the slave edge dofs [intersection of slave_i, slave_j] (i=1,j=1) to
            master corner dof [master_i, master_j] (i=0,j=0)
            (1) map slave_x, slave_y to master_x, master_y: i=0, j=1
            (2) map slave_x, slave_z to master_x, master_z: i=0, j=2
            (3) map slave_y, slave_z to master_y, master_z: i=1, j=2
            '''
            out_x = x.copy()
            out_x[dir_i] = x[dir_i] - L
            out_x[dir_j] = x[dir_j] - L
            idx = np.logical_and(pbc_is_slave[dir_i](x), pbc_is_slave[dir_j](x))
            out_x[dir_i][~idx] = np.nan
            out_x[dir_j][~idx] = np.nan
            # remove corner DOFs at point (1,1,1)
            idx_corner = np.logical_and(pbc_is_slave[0](x), np.logical_and(pbc_is_slave[1](x), pbc_is_slave[2](x)))
            out_x[dir_i][idx_corner] = np.nan
            out_x[dir_j][idx_corner] = np.nan
            print("Number of Nodes for edge mapping: ", len(out_x[dir_i][idx]), "Minus", len(out_x[dir_i][idx_corner]), "=", len(out_x[dir_i][idx])-len(out_x[dir_i][idx_corner]))
            return out_x
        return pbc_slave_to_master_map_edges

    def pbc_slave_to_master_map_corner(x):
        '''
        Maps the slave corner dof [intersection of slave_x, slave_y, slave_z] (1,1,1) to
        master corner dof [master_x, master_y, master_z] (0,0,0)
        '''
        out_x = x.copy()
        out_x[0] = x[0] - L
        out_x[1] = x[1] - L
        out_x[2] = x[2] - L
        idx_corner = np.logical_and(pbc_is_slave[0](x), np.logical_and(pbc_is_slave[1](x), pbc_is_slave[2](x)))
        out_x[0][~idx_corner] = np.nan
        out_x[1][~idx_corner] = np.nan
        out_x[2][~idx_corner] = np.nan
        print("Number of Nodes of corner mapping:" , len(out_x[0][idx_corner]))
        return out_x

    mapping_slave_intersections = [(0,1),(0,2),(1,2)] # pairs of slave intersections

    # mapping slave intersection node (corner) to master intersection node (opposite corner)
    mpc.create_periodic_constraint_topological(V, pbc_meshtags[0],
                                               pbc_slave_tags[0],
                                               pbc_slave_to_master_map_corner,
                                               bcs)

    for inters in mapping_slave_intersections:
        # mapping slave intersections to opposite master intersections
        mpc.create_periodic_constraint_topological(V, pbc_meshtags[inters[0]],
                                                   pbc_slave_tags[inters[0]],
                                                   generate_pbc_slave_to_master_map_edges(inters[0], inters[1]),
                                                   bcs)

mpc.finalize()

# Variational problem
#####################
T_gradient = Constant(domain, PETSc.ScalarType((1,0,0)))

## weak formulation
eqn = - kappa * dot(grad(dot(T_gradient , x) + T_fluc), grad(dT_fluc)) * dx
a_form = lhs(eqn)
L_form = rhs(eqn)

## solving
func_sol = Function(V)
problem = dolfinx_mpc.LinearProblem(a_form, L_form, mpc, bcs=bcs)
func_sol = problem.solve()

# write results to file
file_results1 = XDMFFile(domain.comm, "results_3D_MWE/T_fluc.xdmf", "w")
file_results1.write_mesh(domain)
file_results1.write_function(func_sol)
file_results1.close()