3D Topology Optimization Example

Classic 3D Structured Meshes

In this example we will show how you can use pyFANTOM to perform minimum compliance TO on structured meshes in 3D. 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(
    StructuredMesh3D, # Mesh Object for 3D Structured Meshes
    StructuredFilter3D, # Filter kernel for 3D Structured Meshes
    StructuredStiffnessKernel, # Stiffness Kernel for 3D 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
width = 0.25

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

# Create a mesh
mesh = StructuredMesh3D(nx=64, ny=32, nz=32, lx=length, ly=height, lz=width, 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
# This can be any of the sparse solvers in pyFANTOM but in 3D CHOLMOD quickly becomes infeasible
solver = MultiGrid(mesh=mesh, kernel=stiffness_kernel, n_level=4, cycle='W', w_level=1, maxiter=200, tol=1e-4)

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

# Visualize the problem
fe.visualize_problem()

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), 0.0]])
)

# 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, 1]]), # Degrees of freedom to apply the boundary condition to
    rhs=0.0 # Value to apply the boundary condition to
)

# Visualize the problem
fe.visualize_problem()

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 = StructuredFilter3D(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.1], # 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 46 iterations with the following output:

Optimizer Iterations:  15%|█▌        | 46/300 [02:12<12:11,  2.88s/it, objective=132, variable change=2.34, function change=7.19e-5, iteration=48, residual=8.09e-5] Converged in 46 iterations

Now we can visualize the resulting topology:

to_problem.visualize_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(
    StructuredMesh3D, # Mesh Object for 3D Structured Meshes
    StructuredFilter3D, # Filter kernel for 3D Structured Meshes
    StructuredStiffnessKernel, # Stiffness Kernel for 3D 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
width = 0.25
height = 0.25

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

# Create a mesh
mesh = StructuredMesh3D(nx=64, ny=32, nz=32, lx=length, ly=height, lz=width, 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=4,
    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), 0.0]])
)

# 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, 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 = StructuredFilter3D(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.1], # 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
fe.visualize_problem()
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 59 iterations:

Optimizer Iterations:  20%|█▉        | 59/300 [00:09<00:38,  6.32it/s, objective=131, variable change=1.97, function change=6.37e-5, iteration=61, residual=9.35e-5] Converged in 59 iterations
to_problem.visualize_solution()

Unstructured Meshes

pyFANTOM also supports unstructured meshes for topology optimization. Below we will use pygmesh to mesh a 3D 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. - The code below uses GPU since solving these larger 3D problems on CPU can be slow even with all of the efficient kernels that FANTOM ships with.

from pyFANTOM.CUDA 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
    CG, GMRES, MultiGrid, 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 cupy as cp
import matplotlib.pyplot as plt
from tqdm import trange

Create A Pac-Man Mesh

Below we will make a toy problem mesh using pygmesh.

# Create a geometry object
with pygmsh.occ.Geometry([
        '-setnumber', 'Mesh.Algorithm', '8',
        '-setnumber', 'Mesh.SubdivisionAlgorithm', '1',
        '-setnumber', 'Mesh.RecombinationAlgorithm', '2',
        '-setnumber', 'Mesh.RecombineAll', '0',
        '-setnumber', 'Mesh.SaveWithoutOrphans', '1'
    ]) as geom:
    # Load the STEP file
    geom.import_shapes("PacMan.step")
    geom.characteristic_length_max = 10
    geom.characteristic_length_min = 10
    mesh_uniform = geom.generate_mesh()

    mesh_uniform = [mesh_uniform.points[:,0:3], mesh_uniform.cells[2].data.astype(int).tolist()]

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

# Create a mesh
mesh = GeneralMesh(np.array(mesh_uniform[0])/1000, 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 = CG(kernel=stiffness_kernel, maxiter=5000, tol=1e-4)

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

# Visualize the mesh
fe.visualize_problem()

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].get() - np.array([0.5,0.5]), axis=1),0.5)
bc_nodes = np.logical_and(bc_nodes, mesh.nodes[:,0].get() < 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, 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.get(),np.array([-1,1, 0]))),0), mesh.nodes[:,0].get() > 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, 0]])/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.get(),np.array([1,1, 0]))),1.0), mesh.nodes[:,0].get() > 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, 0]])/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
fe.visualize_problem()

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

# Define the filter kernel for TO This may take a while to compute for a large unstructured mesh
filter_kernel = GeneralFilter(mesh=mesh, r_min=0.01) # 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.05], # 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 148 iterations:

Optimizer Iterations:  49%|████▉     | 148/300 [03:25<03:31,  1.39s/it, objective=50.6, variable change=0.726, function change=9.5e-5, iteration=150, residual=0.00177] Converged in 148 iterations
# Visualize the solution
to_problem.visualize_solution()

As you can see this geometry is a bit of a mess. For best results you should work with as uniform of a hex mesh as possible instead of this simple tetra mesh.