Challenges in reproducing phase-field fracture benchmark: Unphysical crack trajectory and mesh refinement behavior

Hi, every FEniCSx uesrs. I’m implementing an adaptive phase-field fracture model in FEniCSx (dolfinx), closely following the approach in Giri et al. (2025, Finite Elements in Analysis and Design). The benchmark is a 1×1 mm edge-cracked domain under uniaxial tension (Mode I). Despite matching the paper’s AMR criterion (φ ≥ 0.25, h ≥ l0/2), I observe two persistent issues:

1. Non-straight crack path — the crack deviates from the expected horizontal trajectory, producing a visibly jagged path with local wiggles.

2. Over-refinement — the refinement band is much wider than the crack itself, and the final mesh (~32,000 elements) is larger than the paper’s (~28,000), even though the initial mesh (3,700 triangles, lc = 0.025) matches.

What I’ve ruled out so far:

  • Mesh anisotropy from gmsh Delaunay (tried Frontal-Delaunay with Mesh.Algorithm = 6, identical results)
  • Convergence tolerance (tol = 1e-5, standard)
  • History variable projection after refinement (using interpolate_nonmatching)

Related details:

# Material & model
E = 210.0, nu = 0.3, Gc = 2.7e-3, l0 = 0.01  # kN/mm², mm
# AMR criterion (from the paper)
if max(phi_per_cell) >= 0.25 and h_cell > l0 / 2.0:
    refine cell
# BCs: bottom fully fixed (u_x = u_y = 0), top prescribed u_y
# Staggered scheme, P1 elements, ATS sub-stepping

About Load-displacement :
Peak load ~720 N at u ≈ 0.0061 mm, roughly consistent with the paper, but the post-peak softening is noisy with frequent ATS rejections.

Suspicions:

  • Bottom BC: fully fixed (u_x = 0) prevents Poisson contraction. Could this distort the stress field near the crack tip?
  • Edge-bisection refinement creating anisotropic refinement patterns?
  • Something else in the staggered scheme or history variable update?

Has anyone encountered similar issues with jagged crack paths in dolfinx phase-field simulations? Any advice on debugging or fixing this would be greatly appreciated. I’m happy to share the full code.

The current problem is that the program always yields the crack propagation path of type i and type ii fracture composites. I don’t know where the issue lies.
What’s more annoying is that new users are unable to upload pictures, so I can’t show you the problem I encountered.

I would suggest that you add the code so people can reproduce the issue.

This is due to some historical issues we have had with bots posting pornography and other unsuitable content on the forum.

Sorry, there is a problem with the code that I just uploaded. Please take a look at the following one.

def generate_initial_mesh(L=1.0, W=1.0, a=0.5, lc=lc_initial):
    """
    Symmetric mesh via OCC copy+mirror+fragment.
    Bottom half mirrored about y=W/2, then fragmented for shared interface.
    Guaranteed node symmetry above/below y=0.5.
    """
    import gmsh
    from dolfinx.io.gmsh import model_to_mesh

    gmsh.initialize()
    gmsh.option.setNumber("General.Terminal", 0)
    gmsh.model.add("symmetric_mirror")

    eps = 1e-6
    y_mid = W / 2.0

    # 1. Bottom half rectangle [0,L] x [0, y_mid]
    s_bottom = gmsh.model.occ.addRectangle(0, 0, 0, L, y_mid)
    gmsh.model.occ.synchronize()

    # 2. Copy + mirror about y=y_mid
    copied = gmsh.model.occ.copy([(2, s_bottom)])
    gmsh.model.occ.mirror(copied, 0, 1, 0, -y_mid)
    gmsh.model.occ.synchronize()

    # 3. Fragment to merge overlapping curves on y=y_mid
    s_top = copied[0][1]
    gmsh.model.occ.fragment([(2, s_bottom)], [(2, s_top)])
    gmsh.model.occ.synchronize()

    # 4. Identify boundaries via bounding box
    all_surfaces = gmsh.model.getEntities(2)

    bottom_curves = gmsh.model.getEntitiesInBoundingBox(
        -eps, -eps, -eps, L+eps, eps, eps, 1)
    right_curves = gmsh.model.getEntitiesInBoundingBox(
        L-eps, -eps, -eps, L+eps, W+eps, eps, 1)
    top_curves = gmsh.model.getEntitiesInBoundingBox(
        -eps, W-eps, -eps, L+eps, W+eps, eps, 1)
    left_all = gmsh.model.getEntitiesInBoundingBox(
        -eps, -eps, -eps, eps, W+eps, eps, 1)
    # Crack: all curves on y=y_mid (interface), used for physical group
    crack_curves = gmsh.model.getEntitiesInBoundingBox(
        -eps, y_mid-eps, -eps, L+eps, y_mid+eps, eps, 1)

    # Filter left curves: exclude interface curves
    left_curves = []
    for dim, tag in left_all:
        bbox = gmsh.model.getBoundingBox(dim, tag)
        my = (bbox[1] + bbox[4]) / 2
        if abs(my - y_mid) > eps * 10:
            left_curves.append((dim, tag))

    # 5. Physical groups
    gmsh.model.addPhysicalGroup(
        2, [s[1] for s in all_surfaces], TAG_DOMAIN, name="Domain")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in bottom_curves], TAG_BOTTOM, name="Bottom")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in right_curves], TAG_RIGHT, name="Right")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in top_curves], TAG_TOP, name="Top")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in left_curves], TAG_LEFT, name="Left")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in crack_curves], TAG_CRACK, name="Crack")

    # 6. Uniform mesh size
    gmsh.option.setNumber("Mesh.MeshSizeMin", lc)
    gmsh.option.setNumber("Mesh.MeshSizeMax", lc)
    gmsh.model.mesh.generate(2)

    mesh_data = model_to_mesh(gmsh.model, MPI.COMM_WORLD, 0, gdim=2)
    domain, cell_tags, facet_tags = mesh_data.mesh, mesh_data.cell_tags, mesh_data.facet_tags
    gmsh.finalize()
    return domain, facet_tags

def setup_problem(domain, facet_tags):
    V_u = fem.functionspace(domain, ("Lagrange", 1, (domain.geometry.dim,)))
    V_phi = fem.functionspace(domain, ("Lagrange", 1))

    u = fem.Function(V_u, name="Displacement")
    phi = fem.Function(V_phi, name="PhaseField")
    phi_old = fem.Function(V_phi)
    H = fem.Function(V_phi, name="History")

    E = fem.Constant(domain, default_scalar_type(E_val))
    nu = fem.Constant(domain, default_scalar_type(nu_val))
    gc = fem.Constant(domain, default_scalar_type(gc_val))
    l0 = fem.Constant(domain, default_scalar_type(l0_val))
    k_res = fem.Constant(domain, default_scalar_type(1e-6))

    lmbda = E * nu / ((1.0 + nu) * (1.0 - 2.0 * nu))
    mu = E / (2.0 * (1.0 + nu))

    def eps(u_): return ufl.sym(ufl.grad(u_))

    def psi_plus(u_):
        e = eps(u_)
        tr_e = ufl.tr(e)
        det_e = ufl.det(e)
        gap = ufl.sqrt(ufl.max_value((tr_e / 2.0)**2 - det_e, 0.0))
        eps1 = tr_e / 2.0 + gap
        eps2 = tr_e / 2.0 - gap
        def mac_plus(x): return ufl.max_value(x, 0)
        return 0.5 * lmbda * mac_plus(tr_e)**2 + mu * (mac_plus(eps1)**2 + mac_plus(eps2)**2)

    def sigma(u_): return lmbda * ufl.tr(eps(u_)) * ufl.Identity(domain.geometry.dim) + 2.0 * mu * eps(u_)
    def g(phi_): return (1.0 - phi_)**2 + k_res

    u_trial, v = ufl.TrialFunction(V_u), ufl.TestFunction(V_u)
    phi_trial, q = ufl.TrialFunction(V_phi), ufl.TestFunction(V_phi)

    a_u = ufl.inner(g(phi) * sigma(u_trial), eps(v)) * ufl.dx
    L_u = ufl.inner(fem.Constant(domain, default_scalar_type((0.0, 0.0))), v) * ufl.dx

    a_phi = (gc * l0 * ufl.inner(ufl.grad(phi_trial), ufl.grad(q)) + (gc / l0 + 2.0 * H) * phi_trial * q) * ufl.dx
    L_phi = (2.0 * H * q) * ufl.dx

    t_disp = fem.Constant(domain, default_scalar_type(0.0))

    # BC: bottom fixed, top uy = t_disp (same as PMB2_1/paper)
    domain.topology.create_connectivity(domain.topology.dim - 1, domain.topology.dim)

    bottom_facets = facet_tags.find(TAG_BOTTOM)
    bottom_dofs = fem.locate_dofs_topological(V_u, domain.topology.dim - 1, bottom_facets)
    bc_bottom = fem.dirichletbc(np.array([0.0, 0.0], dtype=default_scalar_type), bottom_dofs, V_u)

    top_facets = facet_tags.find(TAG_TOP)
    top_dofs_y = fem.locate_dofs_topological(V_u.sub(1), domain.topology.dim - 1, top_facets)
    bc_top = fem.dirichletbc(t_disp, top_dofs_y, V_u.sub(1))


    # top_dofs_x = fem.locate_dofs_topological(V_u.sub(0), domain.topology.dim - 1, top_facets)
    # bc_top_x = fem.dirichletbc(default_scalar_type(0.0), top_dofs_x, V_u.sub(0))

    bcs_u = [bc_bottom, bc_top]
    # bcs_u = [bc_bottom, bc_top, bc_top_x]

    problem_u = LinearProblem(a_u, L_u, bcs=bcs_u,
        petsc_options={"ksp_type": "cg", "pc_type": "gamg", "ksp_rtol": 1e-8}, petsc_options_prefix='p_u')

    problem_phi = LinearProblem(a_phi, L_phi, bcs=[],
        petsc_options={"ksp_type": "cg", "pc_type": "jacobi", "ksp_rtol": 1e-8}, petsc_options_prefix='p_phi')

    return {
        "mesh": domain, "facet_tags": facet_tags,
        "V_u": V_u, "V_phi": V_phi,
        "u": u, "phi": phi, "phi_old": phi_old, "H": H,
        "E": E, "nu": nu, "gc": gc, "l0": l0, "k_res": k_res,
        "lmbda": lmbda, "mu": mu,
        "eps": eps, "psi_plus": psi_plus, "g": g, "sigma": sigma,
        "t_disp": t_disp,
        "problem_u": problem_u, "problem_phi": problem_phi,
        "ds": ufl.Measure("ds", domain=domain, subdomain_data=facet_tags),
    }

# ==========================================
# 4. Main simulation (no AMR, no ATS)
# ==========================================
domain, facet_tags = generate_initial_mesh()
sys = setup_problem(domain, facet_tags)

# Initial crack: phi=1 on y=0.5, x<=0.5 (geometric, no TAG_CRACK needed)
def crack_marker(x):
    return np.logical_and(x[0] <= 0.5 + 1e-10, np.isclose(x[1], 0.5, atol=1e-10))
crack_dofs = fem.locate_dofs_geometrical(sys["V_phi"], crack_marker)
sys["phi"].x.array[crack_dofs] = 1.0
sys["phi_old"].x.array[:] = sys["phi"].x.array[:]
sys["H"].x.array[crack_dofs] = 1e10

# Reaction force form
load_form = fem.form(sys["g"](sys["phi"]) * (
    sys["lmbda"] * ufl.tr(sys["eps"](sys["u"])) * ufl.Identity(2)
    + 2 * sys["mu"] * sys["eps"](sys["u"]))[1, 1] * sys["ds"](TAG_TOP))

results_disp = []
results_load = []

u_total = 0.0
num_cells_global = domain.topology.index_map(domain.topology.dim).size_global

print(f"Start PFM simulation (uniform mesh, no AMR, no ATS)")
print(f"Mesh: {num_cells_global} cells, lc={lc_initial}")
print(f"Loading: du1={delta_u_stage1:.0e} up to u={u_stage_switch}, du2={delta_u_stage2:.0e} to u={u_max}")
print()

while u_total < u_max:
    # Two-stage fixed step size (paper PFM_C)
    if u_total < u_stage_switch:
        delta_u = delta_u_stage1
    else:
        delta_u = delta_u_stage2

    delta_u = min(delta_u, u_max - u_total)
    u_trial = u_total + delta_u
    sys["t_disp"].value = default_scalar_type(u_trial)

    # Staggered iteration
    converged = False
    for iter_count in range(iter_max):
        # Solve displacement
        u_res = sys["problem_u"].solve()
        sys["u"].x.array[:] = u_res.x.array[:]

        # Update history field
        psi_expr = fem.Expression(sys["psi_plus"](sys["u"]), sys["V_phi"].element.interpolation_points)
        psi_vals = fem.Function(sys["V_phi"])
        psi_vals.interpolate(psi_expr)
        sys["H"].x.array[:] = np.maximum(psi_vals.x.array, sys["H"].x.array)

        # Solve phase field
        phi_res = sys["problem_phi"].solve()
        sys["phi"].x.array[:] = phi_res.x.array[:]
        sys["phi"].x.array[:] = np.clip(sys["phi"].x.array, 0.0, 1.0)

        # Convergence check
        error_local = fem.assemble_scalar(fem.form((sys["phi"] - sys["phi_old"])**2 * ufl.dx))
        error_global = sys["mesh"].comm.allreduce(error_local, op=MPI.SUM)
        norm_local = fem.assemble_scalar(fem.form(sys["phi"]**2 * ufl.dx))
        norm_global = sys["mesh"].comm.allreduce(norm_local, op=MPI.SUM)
        rel_error = np.sqrt(error_global) / (np.sqrt(norm_global) + 1e-12)

        if rel_error < tol_staggered:
            converged = True
            break
        sys["phi_old"].x.array[:] = sys["phi"].x.array[:]

    N_iter = iter_count + 1

    if not converged:
        print(f"  Warning: step at u={u_trial:.6f} did not converge ({N_iter} iters, err={rel_error:.2e})")

    u_total = u_trial
    sys["phi_old"].x.array[:] = sys["phi"].x.array[:]

    # Reaction force
    local_load = fem.assemble_scalar(load_form)
    global_load = sys["mesh"].comm.allreduce(local_load, op=MPI.SUM)
    results_load.append(global_load)
    results_disp.append(u_total)

    print(f"Step {len(results_disp):4d} | u={u_total:.6f} | iter={N_iter} | "
          f"max_phi={np.max(sys["phi"].x.array):.3f} | F={global_load*1000:.2f} N")

print()
print(f"Simulation finished. Steps={len(results_disp)}, cells={num_cells_global}")

Now I can upload pictures!!! I drew two diagrams. The first one is a displacement field cloud map, and the second one is a phase field cloud map. As you can see, the crack path did not expand in a horizontal direction. Instead, it tended towards a composite crack of type i (stretching) and type ii (shearing).

I tried many approaches, including modifying the feature length l0, the grid size lc, the boundary conditions, the iteration method, and the grid generation, etc. However, without exception, none of them could solve the problem.

But perhaps it’s a problem with my own abilities. So I really hope that some experienced person can solve my issue.


Explanation of the generate_initial_mesh function: To construct a structured grid, I adopted the approach of dividing the domain at y = 0.5 into two rectangular sections, with the fracture segment (x ≤ a) not sharing nodes and the ligament segment (x > a) sharing nodes. Thus, Gmsh independently performs Delaunay triangulation on the two subdomains, each naturally symmetrically, and the result of the run shows that I have not solved the problem; there are still composite fractures.

The mesh generated by the “generate_initial_mesh” function looks like this

Below is my complete code.

"""
Uses a globally uniform fine mesh and
two-stage fixed displacement increments (paper PFM_C approach).
"""

import numpy as np
import ufl
from mpi4py import MPI
from dolfinx import fem, mesh, io, plot, default_scalar_type
from dolfinx.fem.petsc import LinearProblem

# ==========================================
# 1. Physical tags and parameters
# ==========================================
TAG_DOMAIN = 1
TAG_BOTTOM = 2
TAG_RIGHT = 3
TAG_TOP = 4
TAG_LEFT = 5
TAG_CRACK = 6

# Material parameters (mm, kN)
E_val = 210.0          # kN/mm^2
nu_val = 0.3
gc_val = 2.7e-3        # kN/mm (2.7 N/mm)
l0_val = 0.015          # mm

# Mesh: uniform fine mesh, no AMR
lc_initial = 0.005     # = l0/2, matches paper PFM_C (~92k cells)
# To reduce runtime, try lc_initial = 0.01 (~23k cells)

# Fixed two-stage displacement increments (paper PFM_C)
delta_u_stage1 = 1.0e-5    # mm, up to u = 5e-3
delta_u_stage2 = 1.0e-6    # mm, after u = 5e-3
u_stage_switch = 5.0e-3
u_max = 0.007

# Staggered solver
tol_staggered = 1.0e-6
iter_max = 15


# ==========================================
# 2. Mesh generation
# ==========================================
def generate_initial_mesh(L=1.0, W=1.0, a=0.5, lc=lc_initial):
    """
    Symmetric mesh via OCC copy+mirror+fragment.
    Bottom half mirrored about y=W/2, then fragmented for shared interface.
    Guaranteed node symmetry above/below y=0.5.
    """
    import gmsh
    from dolfinx.io.gmsh import model_to_mesh

    gmsh.initialize()
    gmsh.option.setNumber("General.Terminal", 0)
    gmsh.model.add("symmetric_mirror")

    eps = 1e-6
    y_mid = W / 2.0

    # 1. Bottom half rectangle [0,L] x [0, y_mid]
    s_bottom = gmsh.model.occ.addRectangle(0, 0, 0, L, y_mid)
    gmsh.model.occ.synchronize()

    # 2. Copy + mirror about y=y_mid
    copied = gmsh.model.occ.copy([(2, s_bottom)])
    gmsh.model.occ.mirror(copied, 0, 1, 0, -y_mid)
    gmsh.model.occ.synchronize()

    # 3. Fragment to merge overlapping curves on y=y_mid
    s_top = copied[0][1]
    gmsh.model.occ.fragment([(2, s_bottom)], [(2, s_top)])
    gmsh.model.occ.synchronize()

    # 4. Identify boundaries via bounding box
    all_surfaces = gmsh.model.getEntities(2)

    bottom_curves = gmsh.model.getEntitiesInBoundingBox(
        -eps, -eps, -eps, L+eps, eps, eps, 1)
    right_curves = gmsh.model.getEntitiesInBoundingBox(
        L-eps, -eps, -eps, L+eps, W+eps, eps, 1)
    top_curves = gmsh.model.getEntitiesInBoundingBox(
        -eps, W-eps, -eps, L+eps, W+eps, eps, 1)
    left_all = gmsh.model.getEntitiesInBoundingBox(
        -eps, -eps, -eps, eps, W+eps, eps, 1)
    # Crack: all curves on y=y_mid (interface), used for physical group
    crack_curves = gmsh.model.getEntitiesInBoundingBox(
        -eps, y_mid-eps, -eps, L+eps, y_mid+eps, eps, 1)

    # Filter left curves: exclude interface curves
    left_curves = []
    for dim, tag in left_all:
        bbox = gmsh.model.getBoundingBox(dim, tag)
        my = (bbox[1] + bbox[4]) / 2
        if abs(my - y_mid) > eps * 10:
            left_curves.append((dim, tag))

    # 5. Physical groups
    gmsh.model.addPhysicalGroup(
        2, [s[1] for s in all_surfaces], TAG_DOMAIN, name="Domain")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in bottom_curves], TAG_BOTTOM, name="Bottom")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in right_curves], TAG_RIGHT, name="Right")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in top_curves], TAG_TOP, name="Top")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in left_curves], TAG_LEFT, name="Left")
    gmsh.model.addPhysicalGroup(
        1, [c[1] for c in crack_curves], TAG_CRACK, name="Crack")

    # 6. Uniform mesh size
    gmsh.option.setNumber("Mesh.MeshSizeMin", lc)
    gmsh.option.setNumber("Mesh.MeshSizeMax", lc)
    gmsh.model.mesh.generate(2)

    mesh_data = model_to_mesh(gmsh.model, MPI.COMM_WORLD, 0, gdim=2)
    domain, cell_tags, facet_tags = mesh_data.mesh, mesh_data.cell_tags, mesh_data.facet_tags
    gmsh.finalize()
    return domain, facet_tags

def setup_problem(domain, facet_tags):
    V_u = fem.functionspace(domain, ("Lagrange", 1, (domain.geometry.dim,)))
    V_phi = fem.functionspace(domain, ("Lagrange", 1))

    u = fem.Function(V_u, name="Displacement")
    phi = fem.Function(V_phi, name="PhaseField")
    phi_old = fem.Function(V_phi)
    H = fem.Function(V_phi, name="History")

    E = fem.Constant(domain, default_scalar_type(E_val))
    nu = fem.Constant(domain, default_scalar_type(nu_val))
    gc = fem.Constant(domain, default_scalar_type(gc_val))
    l0 = fem.Constant(domain, default_scalar_type(l0_val))
    k_res = fem.Constant(domain, default_scalar_type(1e-6))

    lmbda = E * nu / ((1.0 + nu) * (1.0 - 2.0 * nu))
    mu = E / (2.0 * (1.0 + nu))

    def eps(u_): return ufl.sym(ufl.grad(u_))

    def psi_plus(u_):
        e = eps(u_)
        tr_e = ufl.tr(e)
        det_e = ufl.det(e)
        gap = ufl.sqrt(ufl.max_value((tr_e / 2.0)**2 - det_e, 0.0))
        eps1 = tr_e / 2.0 + gap
        eps2 = tr_e / 2.0 - gap
        def mac_plus(x): return ufl.max_value(x, 0)
        return 0.5 * lmbda * mac_plus(tr_e)**2 + mu * (mac_plus(eps1)**2 + mac_plus(eps2)**2)

    def sigma(u_): return lmbda * ufl.tr(eps(u_)) * ufl.Identity(domain.geometry.dim) + 2.0 * mu * eps(u_)
    def g(phi_): return (1.0 - phi_)**2 + k_res

    u_trial, v = ufl.TrialFunction(V_u), ufl.TestFunction(V_u)
    phi_trial, q = ufl.TrialFunction(V_phi), ufl.TestFunction(V_phi)

    a_u = ufl.inner(g(phi) * sigma(u_trial), eps(v)) * ufl.dx
    L_u = ufl.inner(fem.Constant(domain, default_scalar_type((0.0, 0.0))), v) * ufl.dx

    a_phi = (gc * l0 * ufl.inner(ufl.grad(phi_trial), ufl.grad(q)) + (gc / l0 + 2.0 * H) * phi_trial * q) * ufl.dx
    L_phi = (2.0 * H * q) * ufl.dx

    t_disp = fem.Constant(domain, default_scalar_type(0.0))

    # BC: bottom fixed, top uy = t_disp (same as PMB2_1/paper)
    domain.topology.create_connectivity(domain.topology.dim - 1, domain.topology.dim)

    bottom_facets = facet_tags.find(TAG_BOTTOM)
    bottom_dofs = fem.locate_dofs_topological(V_u, domain.topology.dim - 1, bottom_facets)
    bc_bottom = fem.dirichletbc(np.array([0.0, 0.0], dtype=default_scalar_type), bottom_dofs, V_u)

    top_facets = facet_tags.find(TAG_TOP)
    top_dofs_y = fem.locate_dofs_topological(V_u.sub(1), domain.topology.dim - 1, top_facets)
    bc_top = fem.dirichletbc(t_disp, top_dofs_y, V_u.sub(1))


    # top_dofs_x = fem.locate_dofs_topological(V_u.sub(0), domain.topology.dim - 1, top_facets)
    # bc_top_x = fem.dirichletbc(default_scalar_type(0.0), top_dofs_x, V_u.sub(0))

    bcs_u = [bc_bottom, bc_top]
    # bcs_u = [bc_bottom, bc_top, bc_top_x]

    problem_u = LinearProblem(a_u, L_u, bcs=bcs_u,
        petsc_options={"ksp_type": "cg", "pc_type": "gamg", "ksp_rtol": 1e-8}, petsc_options_prefix='p_u')

    problem_phi = LinearProblem(a_phi, L_phi, bcs=[],
        petsc_options={"ksp_type": "cg", "pc_type": "jacobi", "ksp_rtol": 1e-8}, petsc_options_prefix='p_phi')

    return {
        "mesh": domain, "facet_tags": facet_tags,
        "V_u": V_u, "V_phi": V_phi,
        "u": u, "phi": phi, "phi_old": phi_old, "H": H,
        "E": E, "nu": nu, "gc": gc, "l0": l0, "k_res": k_res,
        "lmbda": lmbda, "mu": mu,
        "eps": eps, "psi_plus": psi_plus, "g": g, "sigma": sigma,
        "t_disp": t_disp,
        "problem_u": problem_u, "problem_phi": problem_phi,
        "ds": ufl.Measure("ds", domain=domain, subdomain_data=facet_tags),
    }

# ==========================================
# 4. Main simulation (no AMR, no ATS)
# ==========================================
domain, facet_tags = generate_initial_mesh()
sys = setup_problem(domain, facet_tags)

# Initial crack: phi=1 on y=0.5, x<=0.5 (geometric, no TAG_CRACK needed)
def crack_marker(x):
    return np.logical_and(x[0] <= 0.5 + 1e-10, np.isclose(x[1], 0.5, atol=1e-10))
crack_dofs = fem.locate_dofs_geometrical(sys["V_phi"], crack_marker)
sys["phi"].x.array[crack_dofs] = 1.0
sys["phi_old"].x.array[:] = sys["phi"].x.array[:]
sys["H"].x.array[crack_dofs] = 1e10

# Reaction force form
load_form = fem.form(sys["g"](sys["phi"]) * (
    sys["lmbda"] * ufl.tr(sys["eps"](sys["u"])) * ufl.Identity(2)
    + 2 * sys["mu"] * sys["eps"](sys["u"]))[1, 1] * sys["ds"](TAG_TOP))

results_disp = []
results_load = []

u_total = 0.0
num_cells_global = domain.topology.index_map(domain.topology.dim).size_global

print(f"Start PFM simulation (uniform mesh, no AMR, no ATS)")
print(f"Mesh: {num_cells_global} cells, lc={lc_initial}")
print(f"Loading: du1={delta_u_stage1:.0e} up to u={u_stage_switch}, du2={delta_u_stage2:.0e} to u={u_max}")
print()

while u_total < u_max:
    # Two-stage fixed step size (paper PFM_C)
    if u_total < u_stage_switch:
        delta_u = delta_u_stage1
    else:
        delta_u = delta_u_stage2

    delta_u = min(delta_u, u_max - u_total)
    u_trial = u_total + delta_u
    sys["t_disp"].value = default_scalar_type(u_trial)

    # Staggered iteration
    converged = False
    for iter_count in range(iter_max):
        # Solve displacement
        u_res = sys["problem_u"].solve()
        sys["u"].x.array[:] = u_res.x.array[:]

        # Update history field
        psi_expr = fem.Expression(sys["psi_plus"](sys["u"]), sys["V_phi"].element.interpolation_points)
        psi_vals = fem.Function(sys["V_phi"])
        psi_vals.interpolate(psi_expr)
        sys["H"].x.array[:] = np.maximum(psi_vals.x.array, sys["H"].x.array)

        # Solve phase field
        phi_res = sys["problem_phi"].solve()
        sys["phi"].x.array[:] = phi_res.x.array[:]
        sys["phi"].x.array[:] = np.clip(sys["phi"].x.array, 0.0, 1.0)

        # Convergence check
        error_local = fem.assemble_scalar(fem.form((sys["phi"] - sys["phi_old"])**2 * ufl.dx))
        error_global = sys["mesh"].comm.allreduce(error_local, op=MPI.SUM)
        norm_local = fem.assemble_scalar(fem.form(sys["phi"]**2 * ufl.dx))
        norm_global = sys["mesh"].comm.allreduce(norm_local, op=MPI.SUM)
        rel_error = np.sqrt(error_global) / (np.sqrt(norm_global) + 1e-12)

        if rel_error < tol_staggered:
            converged = True
            break
        sys["phi_old"].x.array[:] = sys["phi"].x.array[:]

    N_iter = iter_count + 1

    if not converged:
        print(f"  Warning: step at u={u_trial:.6f} did not converge ({N_iter} iters, err={rel_error:.2e})")

    u_total = u_trial
    sys["phi_old"].x.array[:] = sys["phi"].x.array[:]

    # Reaction force
    local_load = fem.assemble_scalar(load_form)
    global_load = sys["mesh"].comm.allreduce(local_load, op=MPI.SUM)
    results_load.append(global_load)
    results_disp.append(u_total)

    print(f"Step {len(results_disp):4d} | u={u_total:.6f} | iter={N_iter} | "
          f"max_phi={np.max(sys["phi"].x.array):.3f} | F={global_load*1000:.2f} N")

print()
print(f"Simulation finished. Steps={len(results_disp)}, cells={num_cells_global}")

# ==========================================
# ==========================================
# 5. Visualization
# ==========================================
# Phase field with mesh edges
import pyvista

topology_phi, cell_types_phi, geometry_phi = plot.vtk_mesh(sys["V_phi"])
grid_phi = pyvista.UnstructuredGrid(topology_phi, cell_types_phi, geometry_phi)
grid_phi.point_data["phi"] = sys["phi"].x.array.real
grid_phi.set_active_scalars("phi")

plotter = pyvista.Plotter(window_size=(900, 800))
plotter.set_background("white")
plotter.add_mesh(grid_phi, show_edges=False,
                cmap="coolwarm", clim=[0, 1])
plotter.add_text(f"Phase Field (u={u_total:.4f} mm, {num_cells_global} cells)",
                 font_size=12, color="black")
plotter.view_xy()
plotter.show()

# Displacement magnitude
u_mag = np.sqrt(sys["u"].x.array[0::2]**2 + sys["u"].x.array[1::2]**2)

topology_u, cell_types_u, geometry_u = plot.vtk_mesh(sys["V_u"])
grid_u = pyvista.UnstructuredGrid(topology_u, cell_types_u, geometry_u)
grid_u.point_data["|u|"] = u_mag.real
grid_u.set_active_scalars("|u|")

plotter2 = pyvista.Plotter(window_size=(900, 800))
plotter2.set_background("white")
plotter2.add_mesh(grid_u, show_edges=False, 
                cmap="viridis")
plotter2.add_text(f"Displacement Magnitude (u={u_total:.4f} mm)",
                   font_size=12, color="black")
plotter2.view_xy()
plotter2.show()

At first, I intended to replicate the ATS + AMR form used in the first numerical experiment of the paper, but I failed to do so consistently. Therefore, I gave up and simply went ahead with the global fixed-grid version.

The title of the paper is
Accelerated adaptive phase-field fracture model with an efficient sub-stepping scheme