2D Topology Optimization Example

Classic 2D Structured Meshes

In this example we will show how you can use pyFANTOM to perform minimum compliance TO on structured meshes in 2D. For this example we will perform TO on the bridge problem.

Below we detail how you can do this in a few lines of code!

Importing The Necessary Tools

First we import the tools from pyFANTOM we will be using in this example.

from pyFANTOM.CPU import(
    StructuredMesh2D, # Mesh Object for 2D Structured Meshes
    StructuredFilter2D, # Filter kernel for 2D Structured Meshes
    StructuredStiffnessKernel, # Stiffness Kernel for 2D Structured Meshes
    FiniteElement, # Finite Element Object to Setup Boundary Conditions and RHS
    CHOLMOD, CG, GMRES, MultiGrid, SPLU, SPSOLVE, # Sparse Solvers
    MinimumCompliance, # TO Problem Definition
    PGD, MMA, OC # Nonlinear Optimizers
)

from pyFANTOM.Physics import LinearElasticity # Physics Model For FEA

# Other Imports
import numpy as np
import matplotlib.pyplot as plt
from tqdm import trange

Setting Up Physics and Geometry

The first step in using pyFANTOM is defining the domain of the problem and setting up the physics for FEA.

length = 1.0
height = 0.25

# Create a physics model
physics = LinearElasticity(E=1.0, nu=1/3, thickness=1.0, type='PlaneStress')

# Create a mesh
mesh = StructuredMesh2D(nx=256, ny=64, lx=length, ly=height, physics=physics)

Setting Up The Finite Element Solver

The next step in using pyFANTOM is setting up a stiffness kernel and finite-element boundary conditions.

# Create a stiffness kernel
stiffness_kernel = StructuredStiffnessKernel(mesh=mesh)

# Create a solver
solver = CHOLMOD(kernel=stiffness_kernel) # This can be any of the sparse solvers in pyFANTOM

# Create a finite-element object
fe = FiniteElement(mesh=mesh, kernel=stiffness_kernel, solver=solver)

# Visualize the problem
plt.figure(figsize=(10, 5))
fe.visualize_problem()
plt.tight_layout()
plt.axis('off')
Initial problem visualization

As seen above we do not have loads or boundary conditions. So now we will set this up for the bridge problem:

left_edge_nodes = np.where(mesh.nodes[:, 0] < 1e-12)[0]
right_edge_nodes = np.where(mesh.nodes[:, 0] > length - 1e-12)[0]

bottom_edge_nodes = np.where(mesh.nodes[:, 1] < 1e-12)[0]

fe.reset_dirichlet_boundary_conditions()
fe.reset_forces()
# Apply force to the bottom edge
fe.add_point_forces(
    node_ids=bottom_edge_nodes,
    positions=None,
    forces=np.array([[0.0, -1.0/len(bottom_edge_nodes)]])
)

# Apply zero displacement to the left edge and the right edge
fe.add_dirichlet_boundary_condition(
    node_ids=np.concatenate([left_edge_nodes, right_edge_nodes]), # Node IDs to apply the boundary condition to
    positions=None, # Alternative way to specify the boundary condition is to pass positions in space
    dofs=np.array([[1, 1]]), # Degrees of freedom to apply the boundary condition to
    rhs=0.0 # Value to apply the boundary condition to
)

# Visualize the problem
plt.figure(figsize=(10, 5))
fe.visualize_problem()
plt.tight_layout()
# plt.autoscale(enable=True)
plt.margins(0.12)
plt.axis('off')
Problem with boundary conditions

Setting Up TO Problem And Optimizer

Once the finite element is setup we can define the problem and optimizer to perform TO.

# Define the filter kernel for TO
filter_kernel = StructuredFilter2D(mesh=mesh, r_min=1.5)

# Define the TO problem
to_problem = MinimumCompliance(
    FE=fe,
    filter=filter_kernel,
    E_mul=[1.0], # You can pass a list of values to perform multi-material TO
    volume_fraction=[0.2], # You can pass a list of values to perform volume fraction control for each material
    void=1e-9, # You can pass a value to set void modulus
    penalty=3, # You can pass a value to set penalty factor
    #penalty_schedule = lambda p, i: (p-1)*np.round(4 * min(100, i) / 100)/4 + 1, # You can pass a function to set penalty schedule here
    heavyside= True, # You can pass a boolean to use heavyside or not
    eta=0.5, # You can pass a value to set eta - only used for heavyside
    beta=2.0, # You can pass a value to set beta, or a function of iteration to set beta schedule - only used for heavyside
)

# Define the optimizer
optimizer = PGD( # You can use any of the optimizers in pyFANTOM: OC, MMA, PGD
    problem=to_problem,
    change_tol=np.inf, # No change tolerance
    fun_tol=1e-4, # Function tolerance (convergence criterion for the optimizer)
)

Running The Optimization Loop

Now we can run the optimization loop and perform TO.

maximum_iterations = 300

Progressbar = trange(maximum_iterations, desc='Optimizer Iterations', leave=True)
objective_history = []
for i in Progressbar:
    optimizer.iter()

    Progressbar.set_postfix(
        optimizer.logs()
    )

    objective_history.append(optimizer.logs()['objective'])

    if optimizer.converged():
        print(f'Converged in {i} iterations')
        break

The optimization converged in 56 iterations with the following output:

Optimizer Iterations:  19%|█▊        | 56/300 [01:04<04:40,  1.15s/it, objective=11.6, variable change=0.936, function change=5.92e-5, iteration=58, residual=2.56e-12] Converged in 56 iterations

Now we can visualize the resulting topology:

plt.figure(figsize=(10, 5))
to_problem.visualize_solution()
plt.tight_layout()
# plt.autoscale(enable=True)
plt.margins(0.1)
plt.axis('off')
Optimized topology solution

Running on GPU

The exact same problem can be run on GPU by just switching the backend.

A few notes on CUDA back-ends: - In general the only exact solver available for CUDA is SPSOLVE, but in general for structured meshes the Multi-Grid solver with our custom CUDA kernels is the fastest solver on GPU. - The CUDA backend uses cupy, however, the inputs to the FE class can be numpy arrays.

from pyFANTOM.CUDA import(
    StructuredMesh2D, # Mesh Object for 2D Structured Meshes
    StructuredFilter2D, # Filter kernel for 2D Structured Meshes
    StructuredStiffnessKernel, # Stiffness Kernel for 2D Structured Meshes
    FiniteElement, # Finite Element Object to Setup Boundary Conditions and RHS
    CG, GMRES, MultiGrid, SPSOLVE, # Sparse Solvers
    MinimumCompliance, # TO Problem Definition
    PGD, MMA, OC # Nonlinear Optimizers
)

from pyFANTOM.Physics import LinearElasticity # Physics Model For FEA

# Other Imports
import numpy as np
import matplotlib.pyplot as plt
from tqdm import trange


length = 1.0
height = 0.25

# Create a physics model
physics = LinearElasticity(E=1.0, nu=1/3, thickness=1.0, type='PlaneStress')

# Create a mesh
mesh = StructuredMesh2D(nx=256, ny=64, lx=length, ly=height, physics=physics)

# Create a stiffness kernel
stiffness_kernel = StructuredStiffnessKernel(mesh = mesh)

# Create a solver
solver = MultiGrid(
    mesh=mesh,
    kernel=stiffness_kernel,
    maxiter=200,
    tol=1e-4,
    n_level=3,
    cycle='W',
    w_level=1
)

# Create a finite-element object
fe = FiniteElement(mesh=mesh, kernel=stiffness_kernel, solver=solver)

left_edge_nodes = np.where(mesh.nodes[:, 0] < 1e-12)[0]
right_edge_nodes = np.where(mesh.nodes[:, 0] > length - 1e-12)[0]

bottom_edge_nodes = np.where(mesh.nodes[:, 1] < 1e-12)[0]

fe.reset_dirichlet_boundary_conditions()
fe.reset_forces()
# Apply force to the bottom edge
fe.add_point_forces(
    node_ids=bottom_edge_nodes,
    positions=None,
    forces=np.array([[0.0, -1.0/len(bottom_edge_nodes)]])
)

# Apply zero displacement to the left edge and the right edge
fe.add_dirichlet_boundary_condition(
    node_ids=np.concatenate([left_edge_nodes, right_edge_nodes]), # Node IDs to apply the boundary condition to
    positions=None, # Alternative way to specify the boundary condition is to pass positions in space
    dofs=np.array([[1, 1]]), # Degrees of freedom to apply the boundary condition to
    rhs=0.0 # Value to apply the boundary condition to
)

# Define the filter kernel for TO
filter_kernel = StructuredFilter2D(mesh=mesh, r_min=1.5)

# Define the TO problem
to_problem = MinimumCompliance(
    FE=fe,
    filter=filter_kernel,
    E_mul=[1.0], # You can pass a list of values to perform multi-material TO
    volume_fraction=[0.2], # You can pass a list of values to perform volume fraction control for each material
    void=1e-9, # You can pass a value to set void modulus
    penalty=3, # You can pass a value to set penalty factor
    #penalty_schedule = lambda p, i: (p-1)*np.round(4 * min(100, i) / 100)/4 + 1, # You can pass a function to set penalty schedule here
    heavyside= True, # You can pass a boolean to use heavyside or not
    eta=0.5, # You can pass a value to set eta - only used for heavyside
    beta=2.0, # You can pass a value to set beta, or a function of iteration to set beta schedule - only used for heavyside
)

# Define the optimizer
optimizer = PGD( # You can use any of the optimizers in pyFANTOM: OC, MMA, PGD
    problem=to_problem,
    change_tol=np.inf, # No change tolerance
    fun_tol=1e-4, # Function tolerance (convergence criterion for the optimizer)
)

# Visualize the problem
plt.figure(figsize=(10, 5))
fe.visualize_problem()
plt.tight_layout()
# plt.autoscale(enable=True)
plt.margins(0.12)
plt.axis('off')
GPU problem visualization
maximum_iterations = 300

Progressbar = trange(maximum_iterations, desc='Optimizer Iterations', leave=True)
objective_history = []
for i in Progressbar:
    optimizer.iter()

    Progressbar.set_postfix(
        optimizer.logs()
    )

    objective_history.append(optimizer.logs()['objective'])

    if optimizer.converged():
        print(f'Converged in {i} iterations')
        break

The GPU optimization converged in 43 iterations:

Optimizer Iterations:  14%|█▍        | 43/300 [00:09<00:55,  4.63it/s, objective=11.7, variable change=2, function change=2.32e-5, iteration=45, residual=8.85e-5]     Converged in 43 iterations
plt.figure(figsize=(10, 5))
to_problem.visualize_solution()
plt.tight_layout()
# plt.autoscale(enable=True)
plt.margins(0.1)
plt.axis('off')
GPU optimized topology solution

Unstructured Meshes

pyFANTOM also supports unstructured meshes for topology optimization. Below we will use pygmesh to mesh a 2D model and perform TO on it.

NOTE: - This is a toy problem to represent the capabilities of the package not a real-world problem. - The Multi-Grid solver which is the most efficient way to solve the FEA problem is only available for structured meshes. So you may want to consider voxelizing geometry in some cases.

from pyFANTOM.CPU import(
    GeneralMesh, # Mesh Object for Unstructured Meshes
    GeneralFilter, # Filter kernel for Unstructured Meshes
    GeneralStiffnessKernel, UniformStiffnessKernel, # Stiffness Kernel for Unstructured Meshes
    FiniteElement, # Finite Element Object to Setup Boundary Conditions and RHS
    CHOLMOD, CG, GMRES, MultiGrid, SPLU, SPSOLVE, # Sparse Solvers
    MinimumCompliance, # TO Problem Definition
    PGD, MMA, OC # Nonlinear Optimizers
)

from pyFANTOM.Physics import LinearElasticity # Physics Model For FEA

import pygmsh

# Other Imports
import numpy as np
import matplotlib.pyplot as plt
from tqdm import trange

Create A Pac-Man Mesh

Below we will make a toy problem mesh using pygmesh.

# Unstructured Uniform Mesh generate on a random domain
with pygmsh.geo.Geometry(['-setnumber', 'Mesh.Algorithm', '6',
                          '-setnumber', 'Mesh.RecombinationAlgorithm', '3',
                          '-setnumber', 'Mesh.RecombineAll', '1',
                          '-setnumber', 'Mesh.SaveWithoutOrphans', '1']) as geom:
    # Pacman parameters
    body_center = [0.5, 0.5, 0.0]
    body_radius = 0.5
    mouth_angle = 45  # Half-angle for the mouth (in degrees)
    eye_center = [0.5, 0.75, 0.0]
    eye_radius = 0.05

    # Define the mouth as a wedge
    p1 = geom.add_point(
        [
            body_center[0] + body_radius * np.cos(-np.pi/4),
            body_center[1] + body_radius * np.sin(-np.pi/4),
            0.0,
        ],
        mesh_size = 0.005
    )
    p2 = geom.add_point(
        [
            body_center[0] + body_radius * np.cos(np.pi/4),
            body_center[1] + body_radius * np.sin(np.pi/4),
            0.0,
        ],
        mesh_size = 0.005
    )
    p3 = geom.add_point(
        [
            0.0,
            0.5,
            0.0,
        ],
        mesh_size = 0.005
    )
    center_point = geom.add_point(body_center, mesh_size = 0.005)

    mouth_line1 = geom.add_line(center_point, p2)
    mouth_line2 = geom.add_line(p1, center_point)
    arc1 = geom.add_circle_arc(p2, center_point, p3)
    arc2 = geom.add_circle_arc(p3, center_point, p1)

    loop = geom.add_curve_loop([mouth_line1, arc1, arc2, mouth_line2])

    eye_center_point = geom.add_point(eye_center,
        mesh_size = 0.005)
    eye_right_point = geom.add_point([eye_center[0] + eye_radius, eye_center[1], 0.0],
        mesh_size = 0.005)
    eye_left_point = geom.add_point([eye_center[0] - eye_radius, eye_center[1], 0.0],
        mesh_size = 0.005)
    eye_arc1 = geom.add_circle_arc(eye_right_point, eye_center_point, eye_left_point,)
    eye_arc2 = geom.add_circle_arc(eye_left_point, eye_center_point, eye_right_point)

    eye_loop = geom.add_curve_loop([eye_arc1, eye_arc2])

    surface = geom.add_plane_surface(loop, holes=[eye_loop])

    # Generate the mesh
    mesh_uniform = geom.generate_mesh()

    mesh_uniform = [mesh_uniform.points[:,0:2], mesh_uniform.cells[1].data.astype(int).tolist()]
# Create a physics model
physics = LinearElasticity(E=1.0, nu=1/3, thickness=1.0, type='PlaneStress')
mesh = GeneralMesh(np.array(mesh_uniform[0]), np.array(mesh_uniform[1]), physics=physics)

# Create a stiffness kernel
if mesh.is_uniform:
    stiffness_kernel = UniformStiffnessKernel(mesh=mesh)
else:
    stiffness_kernel = GeneralStiffnessKernel(mesh=mesh)

# Create a solver
solver = CHOLMOD(kernel=stiffness_kernel)

# Create a finite-element object
fe = FiniteElement(mesh=mesh, kernel=stiffness_kernel, solver=solver)

# Visualize the mesh
plt.figure(figsize=(10, 10))
fe.visualize_problem()
plt.tight_layout()
plt.axis('off')
Unstructured mesh visualization

Now we will setup boundary conditions for this toy problem:

fe.reset_dirichlet_boundary_conditions()
fe.reset_forces()

# find nodes on the boundary of the packman circle
bc_nodes = np.isclose(np.linalg.norm(mesh.nodes[:,0:2] - np.array([0.5,0.5]), axis=1),0.5)
bc_nodes = np.logical_and(bc_nodes, mesh.nodes[:,0] < 0.25)
bc_nodes = np.where(bc_nodes)[0]

fe.add_dirichlet_boundary_condition(
    node_ids=bc_nodes,
    positions=None,
    dofs=np.array([[1, 1]]),
    rhs=0.0
)

# apply load at the mouth of the packman
upper_mouth = np.logical_and(np.isclose(np.abs(np.dot(mesh.nodes,np.array([-1,1]))),0), mesh.nodes[:,0] > 0.5)
force_node = np.where(upper_mouth)[0]
force_nodes = force_node[np.isin(force_node, bc_nodes, invert=True)]
force = np.array([[1,-1]])/force_nodes.shape[0]/4 # Broadcastable, If needed you can provide one for each point

fe.add_point_forces(
    node_ids=force_nodes,
    positions=None,
    forces=force
)

lower_mouth = np.logical_and(np.isclose(np.abs(np.dot(mesh.nodes,np.array([1,1]))),1.0), mesh.nodes[:,0] > 0.5)
force_node = np.where(lower_mouth)[0]
force_nodes = force_node[np.isin(force_node, bc_nodes, invert=True)]
force = np.array([[1,1]])/force_nodes.shape[0]/4 # Broadcastable, If needed you can provide one for each point

fe.add_point_forces(
    node_ids=force_nodes,
    positions=None,
    forces=force
)

# Visualize the problem
plt.figure(figsize=(10, 10))
fe.visualize_problem()
plt.tight_layout()
plt.axis('off')
Unstructured mesh with boundary conditions

Now we can just setup the problem and optimizer like before:

# Define the filter kernel for TO
filter_kernel = GeneralFilter(mesh=mesh, r_min=2.0 * 0.005) # In general cases r_min is mesh space, not scaled by mesh size be cassreful!

# Define the TO problem
to_problem = MinimumCompliance(
    FE=fe,
    filter=filter_kernel,
    E_mul=[1.0], # You can pass a list of values to perform multi-material TO
    volume_fraction=[0.25], # You can pass a list of values to perform volume fraction control for each material
    void=1e-9, # You can pass a value to set void modulus
    penalty=3, # You can pass a value to set penalty factor
    #penalty_schedule = lambda p, i: (p-1)*np.round(4 * min(100, i) / 100)/4 + 1, # You can pass a function to set penalty schedule here
    heavyside= True, # You can pass a boolean to use heavyside or not
    eta=0.5, # You can pass a value to set eta - only used for heavyside
    beta=2.0, # You can pass a value to set beta, or a function of iteration to set beta schedule - only used for heavyside
)

# Define the optimizer
optimizer = PGD( # You can use any of the optimizers in pyFANTOM: OC, MMA, PGD
    problem=to_problem,
    change_tol=np.inf, # No change tolerance
    fun_tol=1e-4, # Function tolerance (convergence criterion for the optimizer)
)
maximum_iterations = 300

Progressbar = trange(maximum_iterations, desc='Optimizer Iterations', leave=True)
objective_history = []
for i in Progressbar:
    optimizer.iter()

    Progressbar.set_postfix(
        optimizer.logs()
    )

    objective_history.append(optimizer.logs()['objective'])

    if optimizer.converged():
        print(f'Converged in {i} iterations')
        break

The unstructured mesh optimization converged in 198 iterations:

Optimizer Iterations:  66%|██████▌   | 198/300 [08:03<04:09,  2.44s/it, objective=1.47, variable change=0.12, function change=9.93e-5, iteration=200, residual=9.82e-13]  Converged in 198 iterations
# Visualize the solution
plt.figure(figsize=(10, 10))
to_problem.visualize_solution()
plt.tight_layout()
plt.axis('off')
Unstructured mesh optimized solution