Shape optimization

I would appreciate some guidance.

I have a FeniCSx (dolfinx 0.9) code that performs some computations, it works fine for a given mesh. The mesh consists of 2 regions, one is a three quarters-annulus that is fixed, and the other part closes the 3-quarters, and is a shape I wish to optimize (as to maximize/minimize some value). I wish to implement some constraint(s), such as the area of the mesh being optimized be worth a constant or target value within some tolerance.

I have read about CutFEMx, which seems to be a method I could use. If I understand well, and please correct me if I am wrong, this tool would be appropriate to reach my goal. The main idea would be to create a single mesh, such as a big square, in which the 3-quarters annulus would be defined by some functions (2 functions? One for the inner and for the outer radii?), and the shape to optimize by another 2 functions.

Then I would have to use an optimizer such as Optuna to vary the 2 curves defining the region whose shape is to be optimized. I could specify to the optimizer whether the constraint is being violated, in which case to prune the result before the FEM computations are even done, saving time.

In the end I would be left with the curve corresponding to the shape of the best solution found by the optimizer. But exactly how would the optimizer vary the function(s)? What exactly would be left to be done for me?

I realize my question could be asked in a CutFEMx forum rather than FEniCSx, if only it existed…

Any idea, help, comment or guidance is appreciated.

There are multiple libraries and approaches in FEniCSx that you could use. The simplest approach would be the SIMP method detailed here SIMP or Fenitop. If you need a high accuracy at the boundary or if you have explicit parametric descriptions of your domain, CutFEMx in combination with OptiCut might be indeed the most interesting for you.

2 Likes

Hello Susanne,
Thank you for your reply, I did not expect a reply from you!
The thing is that I have 2 regions (two different materials) sharing 2 interfaces, but these 2 interfaces should be fixed (not submitted to any shape optimization). I don’t know if this complicates things…

I do not have figured out how to deal with the mesh generation, other than using randomness in picking x_i and y_i s coordinates for a Bezier curves, yet. It works well in that gmsh does not crash nor core dumps nor hangs, and produces a mesh within a few seconds, but this way of creating meshes is not good enough because it isn’t contemplating all possible shapes. It only considers a limited subset possible, so even if I improved on this method, for example by figuring out a way to produce meshes of a given surface area, it would still be not that good at all, I suspect all meshes would look similar to each other.

All I would need is to link 2 pairs of points together, I do not particularly care about the details as long as I cover a large range of possible ways/shapes to link those points. Here is an example of what my code generates:


The 2 bottom points should always be linked by a straight line (this is the interface 1 with the other material), and the 2 top right points should also be linked to each other by a straight line, as they are in the picture. It is the other 2 curves that need to be adapted, changed, as to optimize a computed quantity.

Therefore, I do not have, yet at least, a parametric description of my domain. I could obtain one, I guess, at least a basis from which the optimizer would have to take over. For example I could start with a circular shape, and let the optimizer add a polynomial to the curves, adjusting the coefficients.

I would have a few questions regarding CutFEMx. In your Github example in the demo here, you use “jump” operations (I guess that’s the Nitsche’s method?), would have I to do the same? I currently do not do anything of the sort. I also use a residual F =0 instead of having a bilinear form with a and L. I really wonder if my code would be reasonably easy to modify to make use of CutFEMx.

For example, right now, I don’t really know how to reproduce this code:

a_cut = cut_form(a)
L_cut = cut_form(L)

b = assemble_vector(L_cut)
A = assemble_matrix(a_cut)
deactivate(A,"phi>0",level_set,[V])
A_py = A.to_scipy()

In my code the weak form looks like:

    # Weak form.
    J_vector = -sigma * grad(volt) - sigma * Seebeck_tensor * grad(temp)
    Fourier_term = dot(-Îş * grad(temp), grad(u)) * dx
    F_T = Fourier_term
    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 #- v * J_out * ds(out_current_curve) + v * J_inj * ds(inj_current_curve) 

    weak_form = F_T + F_V

I am not sure which part I should deactivate… I guess not “weak_form”?

Hi raboynics,

I think I understand your problem a bit better now but a lot of the details are not clear. Do you have a minimal working example you could post?

I will try to explain in a bit more detail what the current CutFEMx library does (see also the documentation of OptiCut and the CutFEM paper).

I have one level set (typically a signed distance function to a boundary) whose zero contour line describes my geometry. I then cut along a piecewise linear approximation of that zero contour line and I adjust my integration rules for the elements that are cut as well as the unfitted cut interface.

As the zero level set cuts arbitrarly through background elements, very small integration regions can appear and these need a form of stabilisation. This is where the jump operators come in. They are continuous interior penalty terms in intersected cells in order to extend the solution in an accurate way in the interface region. Have a look at the CutFEM paper for a detailed description of those (called ghost-penalty).

The deactivation that you see is a trick to set all the unknowns that are fully outside of your domain to zero. If you skip this step you have zero rows in your matrices which will give you nan’s in a direct solver.

If you post a minimal example I can help you better.

1 Like

Hello Susanne,
I had glanced at your paper, very quickly (most of it is way above my head, I am not a specialist in the domain), and today I glanced at OptiCut’s documentation, which is similar in some ways to the information found in the paper. I think I have understood the gist of it, but not the details.
For example, in my case I use polynomials of degree 4 (2 is minimal for me not to get a vanishing quantity that depends on the 2nd order spatial derivatives of “temp” and “volt”, the 2 unknowns of the 2 coupled PDEs in my problem), and from what I understand, the solution is computed not only at the mesh vertices but also in some interior points of the triangles making up the mesh. So I don’t exactly know how CutFEMx is able to retrieve the position of those points, checks whether they are inside or outside the set_level that the user has defined, and so on. But of course, I am confident that it is able to deal with this case without problem.

Unfortunately I do not have a MWE to share. A MWE with all the features that my full code possesses, such as mixed element space, anisotropy in a physical property and a mesh with 2 regions. My code is “big”, spread over several files and uses an object oriented approach, i.e. I created some classes and helper functions to handle my problem. I don’t have much time and I took advantage of LLM AI to create a MWE. I reviewed the code, modified it a bit, executed it and it looks OK to me. It did modify the weak form (you can see this yourself, I gave my real weak form in my previous post), so the equations it solves might not make entirely physical sense. But the code does work and is very similar to my real code, so if we can take it from there, I think I could apply any suggestion to my real code.

The code includes some post processing that can safely be removed, but it’s just some extra lines making sure everything is correct.
Here’s the Python code:

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 - dot(rho * J_vector, J_vector) * u * dx

F_V = dot(J_vector, grad(v)) * 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}")

Its execution yields:

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.841666e-03 A
Current through interface at theta=pi/2 (ID 23): 8.043990e-03 A
Volume integral of div(J): -3.967311e-12

The mesh I am considering comes from the following 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};

which can generate the file.msh either from Gmsh GUI or from the command gmsh -2 full_annulus.geo.

Let me know if that’s too much, too big, i.e. if you would prefer something trimmed. I just don’t know if I would be able to apply the suggestions to my real code if I trim it significantly from the above example.

Thank you very much for your time and your help. :slight_smile: