Using CutFEMx to replicate a working FEniCSx code

I would like to use CutFEMx to replicate a result I get with dolfinx (the reason is that once this works, I will employ CutFEMx to perform things I cannot do in a straightforward manner with dolfinx).

My working fenicsx code is:

import numpy as np
from mpi4py import MPI
from petsc4py import PETSc
from dolfinx import fem, log
from dolfinx.fem import (Constant, Function, functionspace, assemble_scalar,
                         dirichletbc, form, locate_dofs_topological, Expression)
from dolfinx.fem.petsc import NonlinearProblem
from dolfinx.nls.petsc import NewtonSolver
from dolfinx.io import gmshio
from dolfinx import default_scalar_type
from ufl import (Measure, SpatialCoordinate, TestFunction, TrialFunction,
                 split, derivative, grad, dot, dx, dS, div, FacetNormal)
from basix.ufl import element, mixed_element

# --- 1. Simulation and Material Parameters ---
# Physical constants and simulation settings
T_cold = 300.0
ΔT = 100.0
κ = 1.8  # Thermal conductivity

# Finite element degree
deg = 4

# Seebeck tensor components for the anisotropic region
s_xx, s_yy = 1e-5, 1e-3

# --- 2. Mesh Loading and Definition of Measures ---
# Load the mesh and associated markers from the .msh file
# This replaces the TheMesh class
mesh, cell_markers, facet_markers = gmshio.read_from_msh("meshes/full_annulus.msh", MPI.COMM_WORLD, gdim=2)

# Define geometric and integration entities
x = SpatialCoordinate(mesh)
n = FacetNormal(mesh)
dx = Measure("dx", domain=mesh, subdomain_data=cell_markers)
ds = Measure("ds", domain=mesh, subdomain_data=facet_markers) # Redundant but kept for consistency
dS = Measure("dS", domain=mesh, subdomain_data=facet_markers)

# --- 3. Definition of Region-Dependent Material Properties ---
# The mesh has two material regions marked with IDs 20 and 21.
# We define material properties (Seebeck tensor, resistivity, conductivity)
# that have different values in each region.

# Create a discontinuous function space (DG0) for region-dependent properties
# Resistivity (rho) and electrical conductivity (sigma)
rho_space = functionspace(mesh, ("DG", 0))
rho = Function(rho_space)
sigma = Function(rho_space)

three_quarters_cells = cell_markers.find(20)
quarter_cells = cell_markers.find(21)

# Assign values for the "three-quarter" material region (ID 20)
rho.x.array[three_quarters_cells] = np.full_like(three_quarters_cells, 1e0, dtype=default_scalar_type)
sigma.x.array[three_quarters_cells] = np.full_like(three_quarters_cells, 1.0 / 1e0, dtype=default_scalar_type)

# Assign values for the "quarter" material region (ID 21)
rho.x.array[quarter_cells] = np.full_like(quarter_cells, 1.0, dtype=default_scalar_type)
sigma.x.array[quarter_cells] = np.full_like(quarter_cells, 1.0 / 1.0, dtype=default_scalar_type)

# Define the anisotropic Seebeck tensor using a DG0 tensor function space
tensor_el = element(family="DG", cell=str(mesh.ufl_cell()), degree=0, shape=(2, 2))
S_space = functionspace(mesh, tensor_el)
Seebeck_tensor = Function(S_space)

# Define the tensor values for each region
def seebeck_three_quarters(x):
    # Isotropic with zero Seebeck effect in this region
    tensor = np.array([[0, 0], [0, 0]])
    num_points = x.shape[1]
    return np.tile(tensor.flatten(), (num_points, 1)).T.reshape(4, num_points)

def seebeck_quarter(x):
    # Anisotropic Seebeck tensor in this region
    tensor = np.array([[s_xx, 0], [0, s_yy]])
    num_points = x.shape[1]
    return np.tile(tensor.flatten(), (num_points, 1)).T.reshape(4, num_points)

# Interpolate the functions to define the Seebeck tensor across the domain
Seebeck_tensor.interpolate(seebeck_three_quarters, cells0=three_quarters_cells)
Seebeck_tensor.interpolate(seebeck_quarter, cells0=quarter_cells)


# --- 4. Weak Formulation using Mixed Elements ---
# Define a mixed element function space for temperature (temp) and voltage (volt)
el = element("CG", str(mesh.ufl_cell()), deg)
mel = mixed_element([el, el])
ME = functionspace(mesh, mel)

# Define test functions and the function to hold the solution
u, v = TestFunction(ME)
TempVolt = Function(ME)
temp, volt = split(TempVolt)
dTV = TrialFunction(ME)

# Define the electric current density J
J_vector = -sigma * grad(volt) - sigma * Seebeck_tensor * grad(temp)

# Weak form for the heat equation (F_T)
F_T = dot(κ * grad(temp), grad(u)) * dx
rest_terms = dot(temp * Seebeck_tensor* J_vector, grad(u)) * dx + dot(volt * J_vector, grad(u)) * dx
F_T += rest_terms
F_V = -dot(grad(v), sigma * grad(volt))*dx -dot(grad(v), sigma * Seebeck_tensor * grad(temp))*dx

# Combine into the final weak form
weak_form = F_T + F_V

# --- 5. Boundary Conditions ---
# Boundary IDs from the gmsh file
# Physical Curve("r_inner_quarter", 24)
# Physical Curve("r_outer_quarter", 25)
# Physical Curve("r_inner_three_quarter", 26)
# Physical Curve("r_outer_three_quarter", 27)
T_cold_boundary_curve = 24
T_hot_boundary_curve = 25

# Locate degrees of freedom for temperature on the specified boundaries
facets_cold = facet_markers.find(T_cold_boundary_curve)
facets_hot = facet_markers.find(T_hot_boundary_curve)

dofs_cold = locate_dofs_topological(ME.sub(0), mesh.topology.dim - 1, facets_cold)
dofs_hot = locate_dofs_topological(ME.sub(0), mesh.topology.dim - 1, facets_hot)

# Define and apply Dirichlet (fixed value) boundary conditions for temperature
bc_cold = dirichletbc(PETSc.ScalarType(T_cold), dofs_cold, ME.sub(0))
bc_hot = dirichletbc(PETSc.ScalarType(T_cold + ΔT), dofs_hot, ME.sub(0))

bcs = [bc_cold, bc_hot]

# --- 6. Solver Configuration and Solution ---
# The problem is nonlinear, so we define the Jacobian and use a Newton solver
Jac = derivative(weak_form, TempVolt, dTV)

# Set up the nonlinear problem
problem = NonlinearProblem(weak_form, TempVolt, bcs=bcs, J=Jac)

# Configure the Newton solver
solver = NewtonSolver(MPI.COMM_WORLD, problem)
solver.convergence_criterion = "residual"
solver.rtol = 1e-14
solver.atol = 1e-14
solver.report = True
solver.max_it = 100

# Configure the linear solver (KSP) within the Newton solver
# Use a direct LU solver (MUMPS) for robustness
ksp = solver.krylov_solver
opts = PETSc.Options()
option_prefix = ksp.getOptionsPrefix()
opts[f"{option_prefix}ksp_type"] = "preonly"
opts[f"{option_prefix}pc_type"] = "lu"
opts[f"{option_prefix}pc_factor_mat_solver_type"] = "mumps"
ksp.setFromOptions()

log.set_log_level(log.LogLevel.WARNING)

# Solve the system
print("--- Starting nonlinear solver ---")
n_iter, converged = solver.solve(TempVolt)
if converged:
    print(f"Solver converged in {n_iter} iterations.")
else:
    print("Solver did not converge.")

# The solution is now in TempVolt. We can split it to get temp and volt.
temp_sol, volt_sol = TempVolt.sub(0).collapse(), TempVolt.sub(1).collapse()


# --- 7. Post-Processing (Optional) ---
# Example calculation: Check for current conservation across internal boundaries
# Physical Curve("theta_0", 22)
# Physical Curve("theta_pi_over_2", 23)
# These are internal lines separating the two material regions
J_integrand = (-sigma('+') * grad(volt)('+') - sigma('+') * Seebeck_tensor('+') * grad(temp)('+'))

# Calculate the total current flowing through each interface
I_theta0 = assemble_scalar(form(dot(J_integrand, n('+')) * dS(22)))
I_thetapi_over_2 = assemble_scalar(form(dot(J_integrand, n('+')) * dS(23)))

print("\n--- Post-processing Results ---")
print(f"Current through interface at theta=0 (ID 22): {I_theta0:.6e} A")
print(f"Current through interface at theta=pi/2 (ID 23): {I_thetapi_over_2:.6e} A")
# For a closed system with no current sources/sinks, these should sum to zero.

# Check that the divergence of J is close to zero over the whole domain
div_J_integral = assemble_scalar(form(div(J_vector) * dx))
print(f"Volume integral of div(J): {div_J_integral:.6e}")

yielding the output:

Info    : Reading 'meshes/full_annulus.msh'...
Info    : 13 entities
Info    : 6205 nodes
Info    : 12444 elements
Info    : Done reading 'meshes/full_annulus.msh'
--- Starting nonlinear solver ---
Solver converged in 2 iterations.

--- Post-processing Results ---
Current through interface at theta=0 (ID 22): 7.841707e-03 A
Current through interface at theta=pi/2 (ID 23): 8.043999e-03 A
Volume integral of div(J): -7.452434e-12

the mesh being an annulus generated with gmsh from this file.geo:

// Gmsh project created on Sat Mar  8 22:37:56 2025
SetFactory("OpenCASCADE");
//+
Mesh.CharacteristicLengthMax = 0.0009;
Mesh.CharacteristicLengthMin = 0.0001;

Disk(1) = {0, 0, 0, 0.05, 0.05};
//+
Disk(2) = {0, 0, 0, 0.035, 0.035};


BooleanDifference{ Surface{1}; Delete;}{ Surface{2}; Delete;}
// Surface 3 is the full annulus. I need to split it into 2.

Point(4) = {0, 0.05, 0};
//+
Point(5) = {0, 0.035, 0};
//+
// Origin.
Point(6) = {0, 0, 0};


Line(4) = {2,3};
Line(5) = {4,5};

Circle(6) = {5, 6, 2};

Circle(7) = {4, 6, 3};
Curve Loop(8) = {5,6,4,7};




Plane Surface(9) = {8};

// 3-quarter surface
BooleanDifference{ Surface{1}; Delete;}{ Surface{9};}

BooleanFragments{ Surface{1}; Surface{9}; Delete; }{ }

Physical Surface("three_quarter_material", 20) = {1};
Physical Surface("quarter_material", 21) = {9};
Physical Curve("theta_0", 22) = {4};
Physical Curve("theta_pi_over_2", 23) = {5};
Physical Curve("r_inner_quarter", 24) = {6};
Physical Curve("r_outer_quarter", 25) = {7};
Physical Curve("r_inner_three_quarter", 26) = {8};
Physical Curve("r_outer_three_quarter", 27) = {9};

My weak form involves solving 2 PDEs: \nabla \cdot (-\kappa \nabla T) +\nabla \cdot (T S\vec J) +\nabla \cdot ( V\vec J)=0 (heat equation) and \nabla \cdot \vec J =0 where \vec J =-\sigma \nabla V-\sigma S \nabla T where S is a 2x2 matrix (it’s a tensor) that depends on the region, the other quantites are either scalar or scalar fields. The 2 unknowns are T and V.

Regarding CutFEMx, things are more complicated. My weak form has to account for correction terms, both ghost penalties and Nitsche’s method term, this is to ensure my Dirichlet b.c.s are correctly applied. In my case I apply a constant temperature (so T, or temp following my code’s naming) on some curves of the annulus. I think things could potentially be very complicated, indeed, my heat equation contains several terms involving 2nd order spatial derivatives, it’s not just the usual diffusive term with kappa (because \vec J contains \nabla T and \nabla V), nevertheless, if I am to trust LLM AI, the usual correction terms should be enough.

I used LLM AI to help me write the following code, where I asked it to make a mesh visualizer to make sure everything is set correctly, i.e. S should be 0 in the 3 quarters annulus and different from 0 only in the 1st quadrant (quarter annulus). I do not get this entirely, so I don’t know whether something is wrong or whether it’s just the visualizer that’s bugged.

In any case, the code fails with an error near the end, and I don’t think the problem being solved is the correct one (Newton method converges in 0 step).
Here’s the full code:

import numpy as np
from mpi4py import MPI
from petsc4py import PETSc
import pyvista
import ufl
from ufl import avg

from dolfinx import fem, mesh, plot
from dolfinx import default_scalar_type

from cutfemx.level_set import (
    locate_entities,
    cut_entities,
    ghost_penalty_facets,
    facet_topology,
    compute_normal,
)
from cutfemx.mesh import create_cut_mesh
from cutfemx.quadrature import runtime_quadrature, physical_points
from cutfemx.petsc import assemble_vector, assemble_matrix
from cutfemx.fem import cut_form, deactivate, cut_function
from basix.ufl import element, mixed_element

# =============================================================================
# --- CONTROL FLAG ---
# Set to True to see the setup, False to run the full simulation.
# =============================================================================
RUN_VISUALIZATION_ONLY = False

# =============================================================================
# --- 1. Common Setup: Parameters and Geometry ---
# =============================================================================
T_cold = 300.0
T_hot = 400.0
kappa = 1.8
s_xx, s_yy = 1e-5, 1e-3
deg = 2
gamma_g = 1e-1
gamma_N = 100 * deg**2
R_inner = 0.035
R_outer = 0.05
N = 40

msh = mesh.create_rectangle(
    comm=MPI.COMM_WORLD,
    points=((-0.15, -0.15), (0.15, 0.15)),
    n=(N, N),
    cell_type=mesh.CellType.triangle,
)
tdim = msh.topology.dim
gdim = msh.geometry.dim

V_ls = fem.functionspace(msh, ("Lagrange", deg))
def annulus_level_set_func(x):
    r = np.sqrt(x[0] ** 2 + x[1] ** 2)
    return np.maximum(r - R_outer, R_inner - r)
level_set = fem.Function(V_ls)
level_set.interpolate(annulus_level_set_func)

# =============================================================================
# --- 2. Common Setup: Function Spaces and Materials ---
# =============================================================================
el = element("CG", str(msh.ufl_cell()), deg)
mel = mixed_element([el, el])
ME = fem.functionspace(msh, mel)

Q = fem.functionspace(msh, ("DG", 0))
sigma = fem.Function(Q)
sigma.x.array[:] = 1.0

region_marker = fem.Function(Q)
def first_quadrant_marker_predicate(x):
    return np.logical_and(x[0] > 0, x[1] > 0)
region_marker.interpolate(lambda x: first_quadrant_marker_predicate(x).astype(default_scalar_type))

S_aniso = ufl.as_matrix([[s_xx, 0], [0, s_yy]])
Seebeck_tensor = region_marker * S_aniso


if RUN_VISUALIZATION_ONLY:
    # =============================================================================
    # --- 3A. Pre-Solver Visualization ---
    # =============================================================================
    print("--- Running Pre-Solver Visualization ---")
    
    inside_entities = locate_entities(level_set, tdim, "phi<0")
    intersected_cells = locate_entities(level_set, tdim, "phi=0")
    
    el_vis = element("CG", str(msh.ufl_cell()), deg)
    V_vis = fem.functionspace(msh, el_vis)
    dof_coords_vis = V_vis.tabulate_dof_coordinates()
    cut_cells_vis = cut_entities(level_set, dof_coords_vis, intersected_cells, tdim, "phi<0")
    cut_mesh_vis = create_cut_mesh(msh.comm, cut_cells_vis, msh, inside_entities)

    domain_quad_rule = runtime_quadrature(level_set, "phi<0", 2 * deg)
    nitsche_quad_rule = runtime_quadrature(level_set, "phi=0", 2 * deg)
    domain_quad_points = physical_points(domain_quad_rule, msh)
    nitsche_quad_points = physical_points(nitsche_quad_rule, msh)

    plotter = pyvista.Plotter(shape=(2, 2), window_size=[1600, 1600])
    V_cg1 = fem.functionspace(msh, ("CG", 1))
    grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(V_cg1))
    vis_grid_coords = V_cg1.tabulate_dof_coordinates()

    plotter.subplot(0, 0)
    plotter.add_mesh(grid, style="wireframe", color="grey", opacity=0.5, line_width=0.5)
    cut_grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(cut_mesh_vis._mesh))
    plotter.add_mesh(cut_grid, show_edges=True, color="lightblue")
    plotter.add_points(domain_quad_points, render_points_as_spheres=True, color="black", point_size=5)
    plotter.add_points(nitsche_quad_points, render_points_as_spheres=True, color="red", point_size=8)
    plotter.add_title("Geometry and Quadrature Points")
    plotter.view_xy()

    plotter.subplot(0, 1)
    marker_vals = first_quadrant_marker_predicate(vis_grid_coords.T).astype(float)
    grid.point_data["Region Marker"] = marker_vals
    plotter.add_mesh(grid, scalars="Region Marker", show_edges=True)
    plotter.add_title("Region Marker (1.0 in 1st Quad)")
    plotter.view_xy()

    plotter.subplot(1, 0)
    s_yy_vals = marker_vals * s_yy
    print(f"\n--- Sanity Check for S_yy ---")
    print(f"Shape of s_yy_vals array: {s_yy_vals.shape}")
    print(f"Min value: {np.min(s_yy_vals):.2e}, Max value: {np.max(s_yy_vals):.2e}")
    print(f"Number of non-zero values: {np.count_nonzero(s_yy_vals)}\n")
    grid.point_data["S_yy"] = s_yy_vals
    plotter.add_mesh(grid, scalars="S_yy", show_edges=True, scalar_bar_args={'title': 'S_yy Value'})
    plotter.add_title(f"Seebeck Tensor Component (S_yy = {s_yy:.1e})")
    plotter.view_xy()

    plotter.subplot(1, 1)
    level_set_cg1 = fem.Function(V_cg1)
    level_set_cg1.interpolate(level_set)
    grid.point_data["Level Set"] = level_set_cg1.x.array
    plotter.add_mesh(grid, scalars="Level Set", show_edges=True)
    # CORRECTED: Use the .contour() filter method
    contour = grid.contour([0.0], scalars="Level Set")
    plotter.add_mesh(contour, color="red", line_width=5)
    plotter.add_title("Level Set Function (<0 is inside)")
    plotter.view_xy()
    
    plotter.link_views()
    plotter.show()

else:
    # =============================================================================
    # --- 3B. Main Solver ---
    # =============================================================================
    print("--- Running Main Solver ---")
    
    dTV = ufl.TrialFunction(ME)
    (u_T, u_V) = ufl.TestFunctions(ME)
    TempVolt = fem.Function(ME)
    (temp, volt) = ufl.split(TempVolt)

    h = ufl.CellDiameter(msh)
    n = ufl.FacetNormal(msh)

    dx_phys = ufl.Measure("dx", subdomain_data=runtime_quadrature(level_set, "phi<0", 2 * deg), domain=msh)
    gp_ids = ghost_penalty_facets(level_set, "phi<0")
    gp_topo = facet_topology(msh, gp_ids)
    dS_ghost = ufl.Measure("dS", subdomain_data=[(1, gp_topo)], domain=msh)
    d_Gamma = ufl.Measure("dC", subdomain_data=runtime_quadrature(level_set, "phi=0", 2 * deg), domain=msh)

    V_n = fem.functionspace(msh, ("DG", 1, (gdim,)))
    n_level_set = fem.Function(V_n)
    intersected_cells = locate_entities(level_set, tdim, "phi=0")
    compute_normal(n_level_set, level_set, intersected_cells)

    J_vector = -sigma * ufl.grad(volt) - sigma * Seebeck_tensor * ufl.grad(temp)
    F_T = ufl.dot(kappa * ufl.grad(temp), ufl.grad(u_T)) * dx_phys
    rest_terms = (ufl.dot(temp * Seebeck_tensor * J_vector, ufl.grad(u_T))
                  + ufl.dot(volt * J_vector, ufl.grad(u_T))) * dx_phys
    F_T += rest_terms
    F_V = -ufl.dot(ufl.grad(u_V), J_vector) * dx_phys
    F_ghost = (
        avg(gamma_g) * avg(h) * ufl.dot(ufl.jump(ufl.grad(temp), n), ufl.jump(ufl.grad(u_T), n)) * dS_ghost(1)
        + avg(gamma_g) * avg(h) * ufl.dot(ufl.jump(ufl.grad(volt), n), ufl.jump(ufl.grad(u_V), n)) * dS_ghost(1)
    )

    x = ufl.SpatialCoordinate(msh)
    r = ufl.sqrt(x[0]**2 + x[1]**2)
    on_inner = ufl.conditional(ufl.lt(r, (R_inner + R_outer) / 2), 1, 0)
    on_outer = ufl.conditional(ufl.ge(r, (R_inner + R_outer) / 2), 1, 0)
    g_T = on_inner * T_cold + on_outer * T_hot
    integrand_nitsche = (
        -ufl.dot(kappa * ufl.grad(temp), n_level_set) * u_T
        - ufl.dot(kappa * ufl.grad(u_T), n_level_set) * (temp - g_T)
        + (gamma_N * kappa / h) * (temp - g_T) * u_T
    )
    F_nitsche = (region_marker * integrand_nitsche) * d_Gamma
    F = F_T + F_V + F_ghost + F_nitsche

    J = ufl.derivative(F, TempVolt, dTV)
    F_cut = cut_form(F)
    J_cut = cut_form(J)

    A = assemble_matrix(J_cut)
    A.assemble()
    b = assemble_vector(F_cut)

    solver = PETSc.KSP().create(msh.comm)
    solver.setOperators(A)
    solver.setType(PETSc.KSP.Type.PREONLY)
    solver.getPC().setType(PETSc.PC.Type.LU)
    solver.getPC().setFactorSolverType("mumps")

    print("--- Starting Newton Solver ---")
    delta_TempVolt = fem.Function(ME)

    def find_outside_dofs(subspace):
        V_collapsed, dof_map = subspace.collapse()
        ls_in_V = fem.Function(V_collapsed)
        ls_in_V.interpolate(level_set)
        local_indices_outside = np.where(ls_in_V.x.array > 0)[0]
        global_dofs = np.array([dof_map[i] for i in local_indices_outside], dtype=np.int32)
        return global_dofs

    outside_dofs_T = find_outside_dofs(ME.sub(0))
    outside_dofs_V = find_outside_dofs(ME.sub(1))
    outside_dofs = np.union1d(outside_dofs_T, outside_dofs_V).astype(np.int32)

    i = 0
    max_it = 30
    tol = 1e-8

    # CORRECTED: Assemble the vector after setting values
    b.setValues(outside_dofs, np.zeros_like(outside_dofs, dtype=default_scalar_type))
    b.assemble()
    norm_r = b.norm()
    print(f"Iteration {i}: residual = {norm_r:.2e}")

    while norm_r > tol and i < max_it:
        A.zeroEntries()
        assemble_matrix(J_cut, A=A)
        A.assemble()
        deactivate(A, "phi>0", level_set, [ME])
        solver.setOperators(A)

        assemble_vector(F_cut, b=b)
        # CORRECTED: Assemble the vector after setting values
        b.setValues(outside_dofs, np.zeros_like(outside_dofs, dtype=default_scalar_type))
        b.assemble()

        solver.solve(-b, delta_TempVolt.vector)
        delta_TempVolt.x.scatter_forward()

        TempVolt.x.array[:] += delta_TempVolt.x.array
        TempVolt.x.scatter_forward()
        i += 1

        assemble_vector(F_cut, b=b)
        b.setValues(outside_dofs, np.zeros_like(outside_dofs, dtype=default_scalar_type))
        b.assemble()
        norm_r = b.norm()
        print(f"Iteration {i}: residual = {norm_r:.2e}")

    if norm_r <= tol:
        print(f"Converged in {i} iterations.")
    else:
        print("Newton solver did not converge.")

    # --- Post-Processing ---
    print("--- Post-Processing ---")
    inside_entities = locate_entities(level_set, tdim, "phi<0")
    
    # CORRECTED: Use the robust .collapse() method to get DoF coordinates
    V_temp_collapsed, _ = ME.sub(0).collapse()
    dof_coords_vis = V_temp_collapsed.tabulate_dof_coordinates()
    
    cut_cells = cut_entities(level_set, dof_coords_vis, intersected_cells, tdim, "phi<0")
    cut_mesh = create_cut_mesh(msh.comm, cut_cells, msh, inside_entities)

    temp_sol = cut_function(TempVolt.sub(0), cut_mesh)
    volt_sol = cut_function(TempVolt.sub(1), cut_mesh)

    plotter = pyvista.Plotter(shape=(1, 2), window_size=[1600, 800])
    plotter.subplot(0, 0)
    temp_grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(temp_sol.function_space))
    temp_grid.point_data["Temperature"] = temp_sol.x.array
    plotter.add_mesh(temp_grid, scalars="Temperature", show_edges=True)
    plotter.add_title("Temperature (T)")
    plotter.view_xy()

    plotter.subplot(0, 1)
    volt_grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(volt_sol.function_space))
    volt_grid.point_data["Voltage"] = volt_sol.x.array
    plotter.add_mesh(volt_grid, scalars="Voltage", show_edges=True)
    plotter.add_title("Voltage (V)")
    plotter.view_xy()

    plotter.link_views()
    plotter.show()

The traceback:

--- Running Main Solver ---
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
--- Starting Newton Solver ---
Iteration 0: residual = 0.00e+00
Converged in 0 iterations.
--- Post-Processing ---
Traceback (most recent call last):
  File "/home/isaac/Documents/fenicsx/Bridgman/2d_bridgman_textbook_example/debug_CutFEMx_annulus/annulus_gemini_visualizer_27_06_2025.py", line 280, in <module>
    temp_grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(temp_sol.function_space))
                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/isaac/.conda/envs/cutfemx/lib/python3.12/functools.py", line 909, in wrapper
    return dispatch(args[0].__class__)(*args, **kw)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/isaac/.conda/envs/cutfemx/lib/python3.12/site-packages/dolfinx/plot.py", line 49, in vtk_mesh
    dim = msh.topology.dim
          ^^^^^^^^^^^^
AttributeError: 'dolfinx.cpp.fem.FunctionSpace_float64' object has no attribute 'topology'

and here’s what the visualizer produces:


It shows all regions marked as 0 (it shouldn’t. It should mark the annulus as 1). It shows the S tensor as 0 in the whole annulus and worth 1e-3 all around it, while it should be 0 everywhere except in the 1st quadrant of the annulus region. I guess the remaining 2 figures (top left and bottom right) are correct.

Any help/guidance in debugging the code and making it work is appreciated. :slight_smile: feel free to ask any question.

My latest code has some of these visuals fixed, but the solution computed for both unknowns is 0 in the whole annulus (domain). After debugging, it turns out that my “b” has a norm of 0 and I don’t know why.
Here’s the output picture of the marked domains:


I am not even sure it is correct. It lacks a symmetry, because it is supposedly the quarter-annulus of the 1st quadrant.

--- Running Main Solver ---
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
--- Starting Newton Solver ---
Iteration 0: residual = 0.00e+00
Converged in 0 iterations.
--- Post-Processing ---

And the sparsity patterns of A:


No idea either if they look correct…

Full code:

import numpy as np
from mpi4py import MPI
from petsc4py import PETSc
import pyvista
import ufl
from ufl import avg
import matplotlib.pyplot as plt
from scipy.sparse import csr_matrix

from dolfinx import fem, mesh, plot
from dolfinx import default_scalar_type

from cutfemx.level_set import (
    locate_entities as cutfemx_locate_entities,
    cut_entities,
    ghost_penalty_facets,
    facet_topology,
    compute_normal,
)
from cutfemx.mesh import create_cut_mesh
from cutfemx.quadrature import runtime_quadrature
from cutfemx.petsc import assemble_vector, assemble_matrix
from cutfemx.fem import cut_form, deactivate
from basix.ufl import element, mixed_element

# =============================================================================
# --- CONTROL FLAG ---
# =============================================================================
RUN_VISUALIZATION_ONLY = False

# =============================================================================
# --- 1. Common Setup: Parameters and Geometry ---
# =============================================================================
T_cold = 300.0
T_hot = 400.0
kappa = 1.8
s_xx, s_yy = 1e-5, 1e-3
deg = 2
gamma_g = 1e-1
gamma_N = 100 * deg**2
R_inner = 0.035
R_outer = 0.05
N = 40

msh = mesh.create_rectangle(
    comm=MPI.COMM_WORLD,
    points=((-0.15, -0.15), (0.15, 0.15)),
    n=(N, N),
    cell_type=mesh.CellType.triangle,
)
tdim = msh.topology.dim
gdim = msh.geometry.dim

V_ls = fem.functionspace(msh, ("Lagrange", deg))
def annulus_level_set_func(x):
    r_val = np.sqrt(x[0]**2 + x[1]**2)
    return np.maximum(r_val - R_outer, R_inner - r_val)
level_set = fem.Function(V_ls)
level_set.interpolate(annulus_level_set_func)

# =============================================================================
# --- 2. Common Setup: Function Spaces and Materials ---
# =============================================================================
el = element("CG", str(msh.ufl_cell()), deg)
mel = mixed_element([el, el])
ME = fem.functionspace(msh, mel)

sigma = fem.Constant(msh, default_scalar_type(1.0))

# DEFINITIVE REGION DEFINITION USING AN EXPLICIT DG0 FUNCTION
Q = fem.functionspace(msh, ("DG", 0))
region_marker = fem.Function(Q)
def marker_logic(x):
    r_val = np.sqrt(x[0]**2 + x[1]**2)
    in_annulus = np.logical_and(r_val >= R_inner, r_val <= R_outer)
    in_1st_quad = np.logical_and(x[0] >= 0, x[1] >= 0)
    return np.logical_and(in_annulus, in_1st_quad)

#marker_expr = fem.Expression(marker_logic, Q.element.interpolation_points(), comm=MPI.COMM_WORLD)
#region_marker.interpolate(marker_expr)
def marker_logic_int(x):
    r = np.sqrt(x[0]**2 + x[1]**2)
    in_annulus  = np.logical_and(r >= R_inner, r <= R_outer)
    in_1st_quad = np.logical_and(x[0] >= 0, x[1] >= 0)
    return in_annulus & in_1st_quad
region_marker.interpolate(lambda x: marker_logic(x).astype(np.int32))

S_aniso = ufl.as_matrix([[s_xx, 0], [0, s_yy]])
Seebeck_tensor = region_marker * S_aniso


if RUN_VISUALIZATION_ONLY:
    # =============================================================================
    # --- 3A. Pre-Solver Visualization ---
    # =============================================================================
    print("--- Running Pre-Solver and System Visualization ---")
    
    # --- Visualize the Quarter-Annulus Region ---
    V_cg1 = fem.functionspace(msh, ("CG", 1))
    region_plot_func = fem.Function(V_cg1)
    region_plot_func.interpolate(region_marker)
    
    plotter = pyvista.Plotter()
    grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(V_cg1))
    grid.point_data["Quarter Annulus Marker"] = region_plot_func.x.array
    
    plotter.add_mesh(grid, scalars="Quarter Annulus Marker", show_edges=True)
    plotter.add_title("Region Marker (1.0 in the Quarter-Annulus)")
    plotter.view_xy()
    plotter.show()

    # --- Visualize the Matrix and Vector ---
    print("\n--- Assembling system to visualize Matrix and Vector ---")
    dTV = ufl.TrialFunction(ME)
    (u_T, u_V) = ufl.TestFunctions(ME)
    TempVolt = fem.Function(ME)
    (temp, volt) = ufl.split(TempVolt)
    h = ufl.CellDiameter(msh)
    n = ufl.FacetNormal(msh)
    x_coord = ufl.SpatialCoordinate(msh)
    r = ufl.sqrt(x_coord[0]**2 + x_coord[1]**2)
    dx_phys = ufl.Measure("dx", subdomain_data=runtime_quadrature(level_set, "phi<0", 2 * deg), domain=msh)
    d_Gamma = ufl.Measure("dC", subdomain_data=runtime_quadrature(level_set, "phi=0", 2 * deg), domain=msh)
    V_n = fem.functionspace(msh, ("DG", 1, (gdim,)))
    n_level_set = fem.Function(V_n)
    intersected_cells = cutfemx_locate_entities(level_set, tdim, "phi=0")
    compute_normal(n_level_set, level_set, intersected_cells)
    on_inner_bc = ufl.conditional(ufl.lt(r, (R_inner + R_outer) / 2), 1, 0)
    on_outer_bc = ufl.conditional(ufl.ge(r, (R_inner + R_outer) / 2), 1, 0)
    g_T = on_inner_bc * T_cold + on_outer_bc * T_hot
    integrand_nitsche = (-ufl.dot(kappa * ufl.grad(temp), n_level_set) * u_T - ufl.dot(kappa * ufl.grad(u_T), n_level_set) * (temp - g_T) + (gamma_N * kappa / h) * (temp - g_T) * u_T)
    F_nitsche = (region_marker * integrand_nitsche) * d_Gamma
    L = F_nitsche # Only need the RHS for vector visualization

    # 1) build quadrature‐rule objects
    q_vol  = runtime_quadrature(level_set, "phi<0", 2*deg)
    q_surf = runtime_quadrature(level_set, "phi=0", 2*deg)

    # 2) tell FormCompiler which rules go with which measures
    fopts = {"quadrature": {"dx":   q_vol,
                            "dC":   q_surf}}

    # 3) assemble
    L_cut = cut_form(L, 
                    form_compiler_options=fopts,
                    entity_maps={msh: intersected_cells})
    
    b = assemble_vector(L_cut)
    b.assemble()

    fig, ax = plt.subplots(1, 1, figsize=(6, 6))
    ax.plot(b.array)
    ax.set_title("Entries of Residual Vector b (from Nitsche BC)")
    
    print(f"\nSanity Check of assembled system:")
    print(f"Norm of initial residual vector b: {b.norm():.2e}")
    #assert b.norm() > 1e-10, "FATAL: Initial residual is zero. Boundary conditions are not being applied."
    plt.show()


else:
    # =============================================================================
    # --- 3B. Main Solver ---
    # =============================================================================
    print("--- Running Main Solver ---")
    
    dTV = ufl.TrialFunction(ME)
    (u_T, u_V) = ufl.TestFunctions(ME)
    TempVolt = fem.Function(ME)
    (temp, volt) = ufl.split(TempVolt)
    h = ufl.CellDiameter(msh)
    n = ufl.FacetNormal(msh)
    x_coord = ufl.SpatialCoordinate(msh)
    r = ufl.sqrt(x_coord[0]**2 + x_coord[1]**2)
    dx_phys = ufl.Measure("dx", subdomain_data=runtime_quadrature(level_set, "phi<0", 2 * deg), domain=msh)
    gp_ids = ghost_penalty_facets(level_set, "phi<0")
    gp_topo = facet_topology(msh, gp_ids)
    dS_ghost = ufl.Measure("dS", subdomain_data=[(1, gp_topo)], domain=msh)
    d_Gamma = ufl.Measure("dC", subdomain_data=runtime_quadrature(level_set, "phi=0", 2 * deg), domain=msh)
    V_n = fem.functionspace(msh, ("DG", 1, (gdim,)))
    n_level_set = fem.Function(V_n)
    intersected_cells = cutfemx_locate_entities(level_set, tdim, "phi=0")
    compute_normal(n_level_set, level_set, intersected_cells)
    J_vector = -sigma * ufl.grad(volt) - sigma * Seebeck_tensor * ufl.grad(temp)
    F_T = ufl.dot(kappa * ufl.grad(temp), ufl.grad(u_T)) * dx_phys
    rest_terms = (ufl.dot(temp * Seebeck_tensor * J_vector, ufl.grad(u_T)) + ufl.dot(volt * J_vector, ufl.grad(u_T))) * dx_phys
    F_T += rest_terms
    F_V = -ufl.dot(ufl.grad(u_V), J_vector) * dx_phys
    F_ghost = (avg(gamma_g) * avg(h) * ufl.dot(ufl.jump(ufl.grad(temp), n), ufl.jump(ufl.grad(u_T), n)) * dS_ghost(1) + avg(gamma_g) * avg(h) * ufl.dot(ufl.jump(ufl.grad(volt), n), ufl.jump(ufl.grad(u_V), n)) * dS_ghost(1))
    on_inner = ufl.conditional(ufl.lt(r, (R_inner + R_outer) / 2), 1, 0)
    on_outer = ufl.conditional(ufl.ge(r, (R_inner + R_outer) / 2), 1, 0)
    g_T = on_inner * T_cold + on_outer * T_hot
    integrand_nitsche = (-ufl.dot(kappa * ufl.grad(temp), n_level_set) * u_T - ufl.dot(kappa * ufl.grad(u_T), n_level_set) * (temp - g_T) + (gamma_N * kappa / h) * (temp - g_T) * u_T)
    F_nitsche = (region_marker * integrand_nitsche) * d_Gamma
    F = F_T + F_V + F_ghost + F_nitsche

    J = ufl.derivative(F, TempVolt, dTV)
    F_cut = cut_form(F)
    J_cut = cut_form(J)
    
    A = assemble_matrix(J_cut)
    A.assemble()
    b = assemble_vector(F_cut)

    fig, ax = plt.subplots(1, 1, figsize=(6, 6))
    aij = A.getValuesCSR()
    A_scipy = csr_matrix((aij[2], aij[1], aij[0]))


    ax.spy(A_scipy, markersize=1)
    ax.set_title("Sparsity Pattern of A")
    plt.show()

    solver = PETSc.KSP().create(msh.comm)
    solver.setOperators(A)
    solver.setType(PETSc.KSP.Type.PREONLY)
    solver.getPC().setType(PETSc.PC.Type.LU)
    solver.getPC().setFactorSolverType("mumps")
    print("--- Starting Newton Solver ---")
    delta_TempVolt = fem.Function(ME)

    def find_outside_dofs(subspace):
        V_collapsed, dof_map = subspace.collapse()
        ls_in_V = fem.Function(V_collapsed)
        ls_in_V.interpolate(level_set)
        local_indices_outside = np.where(ls_in_V.x.array > 0)[0]
        global_dofs = np.array([dof_map[i] for i in local_indices_outside], dtype=np.int32)
        return global_dofs

    outside_dofs_T = find_outside_dofs(ME.sub(0))
    outside_dofs_V = find_outside_dofs(ME.sub(1))
    outside_dofs = np.union1d(outside_dofs_T, outside_dofs_V).astype(np.int32)
    i = 0
    max_it = 30
    tol = 1e-8
    b.setValues(outside_dofs, np.zeros_like(outside_dofs, dtype=default_scalar_type))
    b.assemble()
    norm_r = b.norm()
    print(f"Iteration {i}: residual = {norm_r:.2e}")
    while norm_r > tol and i < max_it:
        A.zeroEntries()
        assemble_matrix(J_cut, A=A)
        A.assemble()
        deactivate(A, "phi>0", level_set, [ME])
        solver.setOperators(A)
        assemble_vector(F_cut, b=b)
        b.setValues(outside_dofs, np.zeros_like(outside_dofs, dtype=default_scalar_type))
        b.assemble()
        solver.solve(-b, delta_TempVolt.vector)
        delta_TempVolt.x.scatter_forward()
        TempVolt.x.array[:] += delta_TempVolt.x.array
        TempVolt.x.scatter_forward()
        i += 1
        assemble_vector(F_cut, b=b)
        b.setValues(outside_dofs, np.zeros_like(outside_dofs, dtype=default_scalar_type))
        b.assemble()
        norm_r = b.norm()
        print(f"Iteration {i}: residual = {norm_r:.2e}")
    if norm_r <= tol:
        print(f"Converged in {i} iterations.")
    else:
        print("Newton solver did not converge.")

    # =============================================================================
    # --- 4. Post-Processing using NaN for visualization ---
    # =============================================================================
    print("--- Post-Processing ---")
    
    V_cg1 = fem.functionspace(msh, ("CG", 1))
    
    temp_plot = fem.Function(V_cg1)
    temp_plot.interpolate(TempVolt.sub(0))
    
    volt_plot = fem.Function(V_cg1)
    volt_plot.interpolate(TempVolt.sub(1))
    
    ls_plot = fem.Function(V_cg1)
    ls_plot.interpolate(level_set)
    # Correctly find dofs outside the annulus for NaN masking
    outside_dofs_plot = np.where(ls_plot.x.array > 1e-12)[0]
    
    temp_plot.x.array[outside_dofs_plot] = np.nan
    volt_plot.x.array[outside_dofs_plot] = np.nan
    
    plot_grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(V_cg1))
    plot_grid.point_data["Temperature"] = temp_plot.x.array
    plot_grid.point_data["Voltage"] = volt_plot.x.array
    
    plotter = pyvista.Plotter(shape=(1, 2), window_size=[1600, 800])
    plotter.subplot(0, 0)
    plotter.add_mesh(plot_grid, scalars="Temperature", show_edges=True, nan_opacity=0.0)
    plotter.add_title("Temperature (T)")
    plotter.view_xy()
    plotter.subplot(0, 1)
    plotter.add_mesh(plot_grid, scalars="Voltage", show_edges=True, nan_opacity=0.0)
    plotter.add_title("Voltage (V)")
    plotter.view_xy()
    plotter.link_views()
    plotter.show()

I am still stuck at the same point. My b vector has only vanishing elements, its norm is 0 and I do not know why yet.

A MWE with this problem:

import numpy as np
from mpi4py import MPI
from petsc4py import PETSc
import matplotlib.pyplot as plt
import ufl

from dolfinx import fem, mesh
from dolfinx import default_scalar_type

from cutfemx.level_set import (
    locate_entities as cutfemx_locate_entities,
    compute_normal,
)
from cutfemx.quadrature import runtime_quadrature
from cutfemx.petsc import assemble_vector
from cutfemx.fem import cut_form
from basix.ufl import element

T_hot = 400.0
kappa = 1.8
deg = 2
gamma_N = 100 * deg**2
R_inner = 0.035
R_outer = 0.05
N = 40

msh = mesh.create_rectangle(
    comm=MPI.COMM_WORLD,
    points=((-0.15, -0.15), (0.15, 0.15)),
    n=(N, N),
    cell_type=mesh.CellType.triangle,
)
tdim = msh.topology.dim
gdim = msh.geometry.dim

V_ls = fem.functionspace(msh, ("Lagrange", deg))
def annulus_level_set_func(x):
    r_val = np.sqrt(x[0]**2 + x[1]**2)
    return np.maximum(r_val - R_outer, R_inner - r_val)

level_set = fem.Function(V_ls)
level_set.interpolate(annulus_level_set_func)

V = fem.functionspace(msh, ("Lagrange", deg))
v = ufl.TestFunction(V)
h = ufl.CellDiameter(msh)
g = fem.Constant(msh, default_scalar_type(T_hot))

q_surf = runtime_quadrature(level_set, "phi=0", 2*deg)
d_Gamma = ufl.Measure("dC", subdomain_data=q_surf, domain=msh)

V_n = fem.functionspace(msh, ("DG", 1, (gdim,)))
n_level_set = fem.Function(V_n)
intersected_cells = cutfemx_locate_entities(level_set, tdim, "phi=0")
print(f"Number of intersected cells found: {len(intersected_cells)}")
compute_normal(n_level_set, level_set, intersected_cells)

L = v * d_Gamma
fopts = {"quadrature": {"dC": q_surf}}
entity_maps = {msh: intersected_cells}
L_cut = cut_form(L, form_compiler_options=fopts, entity_maps=entity_maps)

b = assemble_vector(L_cut)
b.assemble()

norm_b = b.norm()
print(f"Norm of vector b: {norm_b}")

fig, ax = plt.subplots()
ax.plot(b.array, marker='.', linestyle='None')
ax.set_title(f"Entries of Vector b")
plt.show()

Edit: I could make some progress. I’ll post tomorrow or in a few days. I haven’t fully solved the problem yet but I am not stuck anymore. Great…!

Unfortunately I have been stuck afterwards even though I could get the norm of b different from 0, but not in my mixed element problem…

My latest script (mostly done by AI but I have been double checking it and tried to improve on it/fix it) is:

import numpy as np
from mpi4py import MPI
from petsc4py import PETSc
import ufl
from ufl import (
    FacetNormal, CellDiameter, grad, inner, dot, jump, avg,
    split, derivative, TrialFunction, TestFunctions, conditional, And, sqrt,
    as_matrix, SpatialCoordinate
)
import dolfinx
from dolfinx import fem, mesh, log
from dolfinx.fem import (
    functionspace, Function, dirichletbc
)
import dolfinx.fem
import dolfinx.fem.petsc
from dolfinx.mesh import GhostMode
from basix.ufl import element, mixed_element

# Corrected imports
from cutfemx.level_set import ghost_penalty_facets
from cutfemx.quadrature import runtime_quadrature
from cutfemx.petsc import assemble_vector, assemble_matrix, locate_dofs
from cutfemx.fem import cut_form

# --- 1. Simulation and Material Parameters ---
# Physical constants
T_cold = 300.0
T_hot = 400.0
kappa = 1.8

# Finite element degree
deg = 2

# Seebeck tensor components
s_xx, s_yy = 1e-5, 1e-3

# Nitsche and Ghost penalty parameters
gamma_N = 10 * deg**2
gamma_g = 1e-3

# Geometry and Mesh
R_inner = 0.035
R_outer = 0.05
points = ((-0.15, -0.15), (0.15, 0.15))
N = 50

# Newton solver parameters
max_iter = 25
rtol = 1e-9

# --- 2. Background Mesh and Level Set Definition ---
msh = mesh.create_rectangle(
    comm=MPI.COMM_WORLD,
    points=points,
    n=(N, N),
    cell_type=mesh.CellType.triangle,
    ghost_mode=GhostMode.shared_facet,
)

V_level_set = functionspace(msh, ("Lagrange", deg))
def annulus_level_set_func(x):
    r_val = np.sqrt(x[0]**2 + x[1]**2)
    return np.maximum(r_val - R_outer, R_inner - r_val)

level_set = Function(V_level_set)
level_set.interpolate(annulus_level_set_func)

# --- 3. Definition of Measures and Function Spaces ---
order = 2 * deg
q_inside = runtime_quadrature(level_set=level_set, ls_part="phi<0", order=order)
q_interface = runtime_quadrature(level_set=level_set, ls_part="phi=0", order=order)

dx_c = ufl.Measure("dC", subdomain_data=[(0, q_inside)], domain=msh)
dC = ufl.Measure("dC", subdomain_data=[(1, q_interface)], domain=msh)

gp_facets = ghost_penalty_facets(level_set, "phi<0")
dS_g = ufl.Measure("dS", subdomain_data=[(0, gp_facets)], domain=msh)

el0 = element("CG", msh.ufl_cell().cellname(), deg)
ME = functionspace(msh, mixed_element([el0, el0]))

# --- 4. Stabilizing Dirichlet BC for Exterior Domain ---
# Locate dofs in the region phi > 0
exterior_dofs = locate_dofs("phi>0", level_set, [ME])

# Follow the cutfemx's petsc example's. 
bc_exterior_T = dirichletbc(value=PETSc.ScalarType(0), dofs=exterior_dofs, V=ME.sub(0))
bc_exterior_V = dirichletbc(value=PETSc.ScalarType(0), dofs=exterior_dofs, V=ME.sub(1))

bcs = [bc_exterior_T, bc_exterior_V]

# --- 5. Weak Formulation using CutFEM ---
TempVolt = Function(ME)
(temp, volt) = split(TempVolt)
(v_T, v_V) = TestFunctions(ME)
dTV = TrialFunction(ME)

x = SpatialCoordinate(msh)
r = sqrt(x[0]**2 + x[1]**2)
h = CellDiameter(msh)

in_q1 = And(x[0] >= 0, x[1] >= 0)
marker_q1 = conditional(in_q1, 1.0, 0.0)

rho = marker_q1 * 1.0 + (1 - marker_q1) * 1e0
sigma = 1.0 / rho
Smat = as_matrix([[s_xx, 0], [0, s_yy]])
Seebeck_tensor = marker_q1 * Smat

J_vector = -sigma * grad(volt) - sigma * Seebeck_tensor * grad(temp)

F_T = dot(kappa * grad(temp), grad(v_T)) * dx_c
F_T += dot(temp * Seebeck_tensor * J_vector, grad(v_T)) * dx_c
F_T += dot(volt * J_vector, grad(v_T)) * dx_c
F_V = -dot(grad(v_V), sigma * grad(volt)) * dx_c
F_V -= dot(grad(v_V), sigma * Seebeck_tensor * grad(temp)) * dx_c

n_ls = grad(level_set) / sqrt(dot(grad(level_set), grad(level_set)) + 1e-8)
T_g = conditional(r < (R_inner + R_outer) / 2, T_cold, T_hot)
marker_bc_q1 = conditional(in_q1, 1.0, 0.0)
nitsche_term = - dot(kappa * grad(temp), n_ls) * v_T
nitsche_term += - dot(kappa * grad(v_T), n_ls) * (temp - T_g)
nitsche_term += (gamma_N / h) * kappa * (temp - T_g) * v_T
F_T += marker_bc_q1 * nitsche_term * dC

n = FacetNormal(msh)
ghost_penalty = avg(gamma_g) * avg(h) * dot(jump(grad(temp), n), jump(grad(v_T), n)) * dS_g(0)
ghost_penalty += avg(gamma_g) * avg(h) * dot(jump(grad(volt), n), jump(grad(v_V), n)) * dS_g(0)

weak_form_ufl = F_T + F_V + ghost_penalty
Jac_ufl = derivative(weak_form_ufl, TempVolt, dTV)

# --- 6. Create cut_forms ---


F_cut = cut_form(weak_form_ufl)
J_cut = cut_form(Jac_ufl)

# --- 7. Solver Configuration ---
opts = PETSc.Options()
opts["ksp_type"] = "preonly"
opts["pc_type"] = "lu"
opts["pc_factor_mat_solver_type"] = "mumps"

solver = PETSc.KSP().create(msh.comm)
solver.setFromOptions()

dTV_sol = Function(ME)
TempVolt.x.array[:] = 0.0

# --- 8. Manual Newton Solver ---
log.set_log_level(log.LogLevel.INFO)
print("--- Starting nonlinear solver ---")


A = assemble_matrix(J_cut, bcs=bcs)
b = assemble_vector(F_cut)

A.assemble()
# --- 7. Solver Configuration ---
opts = PETSc.Options()
opts["ksp_type"] = "preonly"
opts["pc_type"]   = "lu"
opts["pc_factor_mat_solver_type"] = "mumps"

solver = PETSc.KSP().create(msh.comm)
solver.setFromOptions()

# zero‐initial guess
TempVolt.x.array[:] = 0.0
TempVolt.x.scatter_forward()

# --- 8. Clean Newton Loop ---
log.set_log_level(log.LogLevel.INFO)
print("--- Starting nonlinear solver ---")

for k in range(max_iter):
    # 1) Build a brand‐new Jacobian; apply BCs immediately
    A = assemble_matrix(J_cut, bcs=bcs)
    A.assemble()

    # 2) Build the current residual
    b = assemble_vector(F_cut)
    b.ghostUpdate(addv=PETSc.InsertMode.ADD,
                  mode=PETSc.ScatterMode.REVERSE)
    fem.petsc.set_bc(b, bcs)           # zero out residual rows, etc.
    b.scale(-1.0)

    # 3) Check convergence
    res = b.norm()
    if k == 0:
        res0 = res
    print(f" Iter {k:2d}: ‖res‖ = {res:.5e}")

    if res < rtol*res0:
        print(" ⇒ converged.")
        break

    # 4) Solve for the Newton‐step, update solution
    solver.setOperators(A)
    dTV_sol.x.petsc_vec.set(0.0)
    solver.solve(b, dTV_sol.x.petsc_vec)
    dTV_sol.x.scatter_forward()

    TempVolt.x.array[:] += dTV_sol.x.array
    TempVolt.x.scatter_forward()

    if k == max_iter - 1:
        print("Solver did not converge.")

# --- 9. Post-Processing and Visualization ---
from dolfinx.io import VTXWriter
temp_sol, volt_sol = TempVolt.sub(0).collapse(), TempVolt.sub(1).collapse()
temp_sol.name = "Temperature"
volt_sol.name = "Voltage"

with VTXWriter(msh.comm, "result.bp", [temp_sol, volt_sol]) as vtx:
    vtx.write(0.0)

It yields the following traceback:

Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
--- Starting nonlinear solver ---
[2025-07-04 23:06:32.341] [info] Requesting connectivity (1, 0) - (2, 0)
[2025-07-04 23:06:32.344] [info] Column ghost size increased from 0 to 0
[2025-07-04 23:06:32.345] [info] Compute edge permutations
--- Starting nonlinear solver ---
[2025-07-04 23:06:32.350] [info] Requesting connectivity (1, 0) - (2, 0)
[2025-07-04 23:06:32.353] [info] Column ghost size increased from 0 to 0
 Iter  0: ‖res‖ = nan
[2025-07-04 23:06:32.393] [info] Requesting connectivity (1, 0) - (2, 0)
Traceback (most recent call last):
  File "/home/isaac/Documents/fenicsx/Bridgman/2d_bridgman_textbook_example/debug_CutFEMx_annulus/annulus_gemini_cutfemx_04_07_2025.py", line 184, in <module>
    A = assemble_matrix(J_cut, bcs=bcs)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/isaac/.conda/envs/cutfemx/lib/python3.12/functools.py", line 909, in wrapper
    return dispatch(args[0].__class__)(*args, **kw)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/isaac/.conda/envs/cutfemx/lib/python3.12/site-packages/cutfemx/petsc.py", line 57, in assemble_matrix
    A = _cpp.fem.petsc.create_matrix(a._cpp_object)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
RuntimeError: Cannot insert rows that do not exist in the IndexMap.

I feel like I’m almost there, but some things/details are off. Maybe @dokken or @sclaus, would you have an idea?

Note that the code is able to complete a Newton’s iteration but the residual is nan, and it is in the 2nd iteration that this traceback occurs.

Also, right after the line where I define b, VSCodium’s debug mode tells me that its norm is nan. I guess something is wrong…

I could fix the error, but the norm of b is still nan. When I inspect b, I see that its attributes “array”, “array_w” and “array_r” are all 0’s except at some indices where they are indeed worth nan.

Full latest code…:

import numpy as np
from mpi4py import MPI
from petsc4py import PETSc
import ufl
from ufl import (
    FacetNormal, CellDiameter, grad, inner, dot, jump, avg,
    split, derivative, TrialFunction, TestFunctions, conditional, And, sqrt,
    as_matrix, SpatialCoordinate
)

from dolfinx import fem, mesh, log
from dolfinx.fem import (
    functionspace, Function, dirichletbc
)
# FINAL FIX: Explicitly import the petsc submodule to access its functions
import dolfinx.fem.petsc
from dolfinx.mesh import GhostMode
from basix.ufl import element, mixed_element

from cutfemx.level_set import ghost_penalty_facets
from cutfemx.quadrature import runtime_quadrature
from cutfemx.petsc import assemble_vector, assemble_matrix, locate_dofs
from cutfemx.fem import cut_form


# --- 1. Simulation and Material Parameters ---
T_cold = 300.0
T_hot = 400.0
kappa = 1.8
deg = 2
s_xx, s_yy = 1e-5, 1e-3
gamma_N = 10 * deg**2
gamma_g = 1e-3
R_inner = 0.035
R_outer = 0.05
points = ((-0.15, -0.15), (0.15, 0.15))
N = 50
max_iter = 25
rtol = 1e-9

# --- 2. Background Mesh and Level Set Definition ---
msh = mesh.create_rectangle(
    comm=MPI.COMM_WORLD,
    points=points,
    n=(N, N),
    cell_type=mesh.CellType.triangle,
    ghost_mode=GhostMode.shared_facet,
)

V_level_set = functionspace(msh, ("Lagrange", deg))
def annulus_level_set_func(x):
    r_val = np.sqrt(x[0]**2 + x[1]**2)
    return np.maximum(r_val - R_outer, R_inner - r_val)

level_set = Function(V_level_set)
level_set.interpolate(annulus_level_set_func)

# --- 3. Definition of Measures and Function Spaces ---
order = 2 * deg
q_inside = runtime_quadrature(level_set=level_set, ls_part="phi<0", order=order)
q_interface = runtime_quadrature(level_set=level_set, ls_part="phi=0", order=order)

dx_c = ufl.Measure("dC", subdomain_data=[(0, q_inside)], domain=msh)
dC = ufl.Measure("dC", subdomain_data=[(1, q_interface)], domain=msh)

gp_facets = ghost_penalty_facets(level_set, "phi<0")
dS_g = ufl.Measure("dS", subdomain_data=[(0, gp_facets)], domain=msh)

el0 = element("CG", msh.ufl_cell().cellname(), deg)
ME = functionspace(msh, mixed_element([el0, el0]))

# --- 4. Stabilizing Dirichlet BC for Exterior Domain ---
V0, _ = ME.sub(0).collapse()
V1, _ = ME.sub(1).collapse()
exterior_dofs_T = locate_dofs("phi>0", level_set, [V0])
exterior_dofs_V = locate_dofs("phi>0", level_set, [V1])

bc_exterior_T = dirichletbc(PETSc.ScalarType(0), exterior_dofs_T, V0)
bc_exterior_V = dirichletbc(PETSc.ScalarType(0), exterior_dofs_V, V1)
bcs = [bc_exterior_T, bc_exterior_V]

# --- 5. Weak Formulation using CutFEM ---
TempVolt = Function(ME)
(temp, volt) = split(TempVolt)
(v_T, v_V) = TestFunctions(ME)
dTV = TrialFunction(ME)

x = SpatialCoordinate(msh)
r = sqrt(x[0]**2 + x[1]**2)
h = CellDiameter(msh)

in_q1 = And(x[0] >= 0, x[1] >= 0)
marker_q1 = conditional(in_q1, 1.0, 0.0)

rho = marker_q1 * 1.0 + (1 - marker_q1) * 1e0
sigma = 1.0 / rho
Smat = as_matrix([[s_xx, 0], [0, s_yy]])
Seebeck_tensor = marker_q1 * Smat

J_vector = -sigma * grad(volt) - sigma * Seebeck_tensor * grad(temp)

F_T = dot(kappa * grad(temp), grad(v_T)) * dx_c
F_T += dot(temp * Seebeck_tensor * J_vector, grad(v_T)) * dx_c
F_T += dot(volt * J_vector, grad(v_T)) * dx_c
F_V = -dot(grad(v_V), sigma * grad(volt)) * dx_c
F_V -= dot(grad(v_V), sigma * Seebeck_tensor * grad(temp)) * dx_c

n_ls = grad(level_set) / sqrt(dot(grad(level_set), grad(level_set)) + 1e-8)
T_g = conditional(r < (R_inner + R_outer) / 2, T_cold, T_hot)
marker_bc_q1 = conditional(in_q1, 1.0, 0.0)
nitsche_term = - dot(kappa * grad(temp), n_ls) * v_T
nitsche_term += - dot(kappa * grad(v_T), n_ls) * (temp - T_g)
nitsche_term += (gamma_N / h) * kappa * (temp - T_g) * v_T
F_T += marker_bc_q1 * nitsche_term * dC

n = FacetNormal(msh)
ghost_penalty = avg(gamma_g) * avg(h) * dot(jump(grad(temp), n), jump(grad(v_T), n)) * dS_g(0)
ghost_penalty += avg(gamma_g) * avg(h) * dot(jump(grad(volt), n), jump(grad(v_V), n)) * dS_g(0)

weak_form_ufl = F_T + F_V + ghost_penalty
Jac_ufl = derivative(weak_form_ufl, TempVolt, dTV)

# --- 6. Create cut_forms ---
F_cut = cut_form(weak_form_ufl)
J_cut = cut_form(Jac_ufl)

# --- 7. Solver Configuration ---
opts = PETSc.Options()
opts["ksp_type"] = "preonly"
opts["pc_type"] = "lu"
opts["pc_factor_mat_solver_type"] = "mumps"

solver = PETSc.KSP().create(msh.comm)
solver.setFromOptions()

dTV_sol = Function(ME)
TempVolt.x.array[:] = 0.0

# --- 8. Manual Newton Solver ---
log.set_log_level(log.LogLevel.INFO)
print("--- Starting nonlinear solver ---")

for k in range(max_iter):
    A = assemble_matrix(J_cut, bcs=bcs)
    A.assemble()

    b = assemble_vector(F_cut)
    b.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE)
    
    # Apply BCs to residual vector using the fully qualified name
    dolfinx.fem.petsc.set_bc(b, bcs)
    b.ghostUpdate(addv=PETSc.InsertMode.INSERT, mode=PETSc.ScatterMode.FORWARD)

    residual_norm = b.norm()
    if k == 0:
        initial_residual_norm = b.norm()
        if np.isnan(initial_residual_norm):
            print("Solver failed on first iteration: residual is nan.")
            break
    
    print(f"Iteration {k:2d}: Residual norm = {residual_norm:.3e}")
    if residual_norm < rtol * initial_residual_norm:
        print("Solver converged.")
        break

    solver.setOperators(A)
    solver.solve(-b, dTV_sol.vector)
    
    dTV_sol.x.scatter_forward()
    TempVolt.x.array[:] += dTV_sol.x.array
    TempVolt.x.scatter_forward()

if k == max_iter - 1:
    print("Solver did not converge.")

# --- 9. Post-Processing and Visualization ---
from dolfinx.io import VTXWriter
temp_sol, volt_sol = TempVolt.sub(0).collapse(), TempVolt.sub(1).collapse()
temp_sol.name = "Temperature"
volt_sol.name = "Voltage"

with VTXWriter(msh.comm, "annulus_thermoelectric_final.bp", [temp_sol, volt_sol]) as vtx:
    vtx.write(0.0)

print("\nSolution saved to annulus_thermoelectric_final.bp")

The traceback is now:

Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
Warning implementation still to be tested for mixed element
--- Starting nonlinear solver ---
[2025-07-05 16:12:35.182] [info] Requesting connectivity (1, 0) - (2, 0)
[2025-07-05 16:12:35.185] [info] Column ghost size increased from 0 to 0
[2025-07-05 16:12:35.186] [info] Compute edge permutations
Solver failed on first iteration: residual is nan.

Solution saved to annulus_thermoelectric_final.bp

I will wait for some help, I’m getting lost…

I am almost completely stuck @sclaus @dokken.
If you have any idea on how to debug my problem(s) described in my previous post, feel free to share. I have VScodium and its debugger, I can examine any object at any point in the code, but even browsing through all of these objects, I find it hard to know if they’re correct and where things go wrong.

From what I could debug today, it looks like I might have several issues. First, the nans seems to be produced due to my ghost term, something is faulty with it, and I do not know how to properly fix this.
The other problem I face is that the solutions I get for volt and temp are identically 0, which they shouldn’t. I get such solutions when I remove the ghost penalty though, which of course I shouldn’t be doing but still, I would have expected to converge to a wrong solution that differs from 0. All norms arising from the terms in my weak form assemble(cut_form(term)) are 0 in that case, so this points out that something is off with my quadrature or dC or dx_c definitions…

So far I weakly apply a boundary condition on the inner and outer boundaries of the annulus or on the quarter-annulus (it doesn’t really matter between these 2 cases, I will have to apply it to other curves anyway).

I think the level set is “correct” although I do have a doubt, when I use a higher mesh resolution (N=300 instead of 50), I get an error:

Traceback (most recent call last):
  File "/home/isaac/Documents/fenicsx/Bridgman/2d_bridgman_textbook_example/debug_CutFEMx_annulus/annulus_attempt_12_07_2025.py", line 63, in <module>
    q_inside = runtime_quadrature(level_set=level_set, ls_part="phi<0", order=order)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/isaac/.conda/envs/cutfemx/lib/python3.12/site-packages/cutfemx/quadrature.py", line 26, in runtime_quadrature
    return _cpp.quadrature.runtime_quadrature(level_set._cpp_object,ls_part,order)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: triangle is not intersected and therefore cannot be cut

which is counter-intuitive to me. The error disappears when N=50 as in my last example here.