Exercise 09 - Topology optimization for continua¶

Task 1 - Book shelf¶

Let us consider a bookshelf that needs a support structure. The design domain is given by a unit square $x \in [0, 1]^2$ and a thickness $d=0.1$. The left boundary of the domain $\partial \Omega_D$ is fixed to the wall and the top boundary $\partial \Omega_N$ is loaded with a uniform line load representing the weight of books.

Bookshelf design domain

In [1]:
import matplotlib.pyplot as plt
import torch
from torchfem import Planar
from torchfem.materials import IsotropicElasticityPlaneStress
from torchfem.mesh import rect_quad
from tqdm import tqdm

torch.set_default_dtype(torch.double)
In [2]:
# Create nodes and elements for a rectangular mesh
N = 30
L = 1.0
nodes, elements = rect_quad(Nx=N + 1, Ny=N + 1, Lx=L, Ly=L)

# Define Material
material = IsotropicElasticityPlaneStress(E=1000.0, nu=0.3)

# Create model
square = Planar(nodes, elements, material)

# Define masks for boundary conditions
top = nodes[:, 1] == L
left = nodes[:, 0] == 0.0
right = nodes[:, 0] == L
tip = top & right

# Load at top
square.forces[top, 1] = -1.0 / N
square.forces[tip, 1] = -0.5 / N

# Constrained displacement at left end
square.constraints[left, :] = True

# Thickness
d = 0.1
square.thickness[:] = d

# Solve the system
u, f, sigma, F, state = square.solve()

# Compute von Mises stress
mises = torch.sqrt(
    sigma[:, 0, 0] ** 2
    - sigma[:, 0, 0] * sigma[:, 1, 1]
    + sigma[:, 1, 1] ** 2
    + 3 * sigma[:, 1, 0] ** 2
)

# Plot the result
square.plot(
    u=u,
    element_property=mises,
    cmap="viridis",
    title="von Mises stress",
    colorbar=True,
)
No description has been provided for this image

In addition, you are provided with a function that performs root finding with the bisection method and the computation of element surface areas from previous exercises.

In [3]:
def bisection(f, a, b, max_iter=50, tol=1e-12):
    # Bisection method always finds a root, if the bracket [a, b] contains a
    # sign change.
    if f(a) * f(b) > 0:
        raise Exception("No sign change in [a, b] - choose a different bracket.")
    i = 0
    while (b - a) > tol:
        c = (a + b) / 2.0
        if i > max_iter:
            raise Exception(f"Bisection did not converge in {max_iter} iterations.")
        if f(a) * f(c) > 0:
            a = c
        else:
            b = c
        i += 1
    return c
In [4]:
def compute_areas(model):
    areas = torch.zeros((model.n_elem))
    nodes = model.nodes[model.elements, :]
    for w, q in zip(model.etype.iweights, model.etype.ipoints):
        J = model.etype.B(q) @ nodes
        detJ = torch.linalg.det(J)
        areas[:] += w * detJ
    return areas

To save material, the bookshelf should use only 40% of the given design space, while being as stiff as possible to support many books without bending. We want to achieve this by topology optimization of the component.

To do so, we implement a topology optimization algorithm with the optimality criteria method in a function named optimize(fem, rho_0, rho_min, rho_max, V_0, iter=100, alpha=0.5, m=0.2, p=1.0, r=0.0) that takes the FEM model fem, the initial density distribution rho_0, the lower and upper density bounds rho_min, rho_max, the volume constraint V_0, the maximum iteration count iter with a default value of 100, the stabilization exponent alpha of the update rule with a default value of 0.5, the move limit m with a default value of 0.2, a SIMP penalty factor p with default 1, and a radius for sensitivity filtering r with a default 0.0. The function should return the histories of design variables and compliance values.

Recap from the lecture: The optimality criteria method updates each design variable with the rule

$$\hat{\rho}_j^{k+1} = \left(G_j^k\right)^\alpha \rho_j^k \quad \textrm{with} \quad G_j^k = \frac{-\frac{\partial C}{\partial \rho_j}(\pmb{\rho}^k)}{\mu V_j}$$

and clamps the result between the move limits

$$\rho_j^{k+1} = \textrm{clamp}\left(\hat{\rho}_j^{k+1},\; \max\left(\rho_j^-, (1-m)\rho_j^k\right),\; \min\left(\rho_j^+, (1+m)\rho_j^k\right)\right).$$

The Lagrange multiplier $\mu$ is determined with the bisection method such that the updated design satisfies the volume constraint

$$g(\mu) = \pmb{\rho}^{k+1}(\mu) \cdot \mathbf{V} - V_0 = 0.$$

The sensitivity of the compliance with the SIMP penalty $p$ is

$$\frac{\partial C}{\partial \rho_j} = -2p\rho_j^{p-1} d_j w_j(\pmb{\rho}) \quad \textrm{with the strain energy} \quad w_j = \frac{1}{2} \mathbf{u}_j \cdot \mathbf{k}^0_j \cdot \mathbf{u}_j$$

and may be smoothed with the sensitivity filter

$$\widetilde{\frac{\partial C}{\partial \rho_j}} = \frac{A_j}{\rho_j} \frac{\sum_i H_{ji} \frac{\rho_i}{A_i} \frac{\partial C}{\partial \rho_i}}{\sum_i H_{ji}} \quad \textrm{with the filter weights} \quad H_{ji} = \max\left(0, r - \textrm{dist}(i,j)\right).$$

a) Check if there is a feasible solution, i.e. if the design with minimum density has a volume smaller than the volume constraint. If not, raise an exception. Hint: You can compute the volume as the inner product of rho_min and the element volumes vols.

b) The filter weights can be precomputed before the optimization loop. Implement the computation of the filter weights if the radius is greater than 0. Hints: Start by computing the center of each element and store it in a tensor of shape (Mx2) for M elements. Then, compute the distance between each element center using the function torch.cdist() and store it in a tensor of shape (MxM). Finally, compute the filter weights $H_{ji} = \max(0, r - \textrm{dist}(i,j))$. You can verify your result: H should be symmetric with the value $r$ on its diagonal, and plotting one of its rows with square.plot(element_property=H[465]) should show a small patch around element 465 in the center of the domain.

c) Add code that modifies the thickness according to the current design variables, solves the FEM problem, and records the compliance $C = \mathbf{f} \cdot \mathbf{u}$ in each iteration. Hints: The element stiffness scales linearly with the thickness. Hence, you can implement the SIMP interpolation by overwriting fem.thickness with $d \rho_j^p$.

d) Compute the sensitivity of the compliance with respect to the design variables using the equation from the recap above. Hints: Computing the strain energies $w_j$ is analogous to Exercise 08 - you can extract the element-wise displacements with u[fem.elements, :].reshape(fem.n_elem, -1). The tensor k0 defined at the top of the function contains the element stiffness matrices for unit thickness $\mathbf{k}^0_j$.

e) Filter the sensitivities with the filter weights according to the equation in the recap, if the radius is greater than 0. Hint: The element volumes vols are proportional to the element areas $A_j$, hence you may use them interchangeably in the filter equation.

f) Define a function make_step(mu) that computes the design variable update for a given Lagrange parameter mu. Hints: Evaluate $G_j$ and the update rule from the recap and clamp the result between the move limits using torch.clamp().

g) Define a function g(mu) that evaluates the volume constraint. Hints: Use the make_step function to compute the design variable update and return the volume constraint violation $g(\mu)$.

h) Use the bisection method to find the Lagrange parameter that satisfies the volume constraint and append the updated design to rho. Hints: $g(\mu)$ is monotonically decreasing - a larger $\mu$ penalizes volume more and leads to smaller densities. Hence, you need a bracket $[a, b]$ with $g(a) > 0 > g(b)$. The bracket $[10^{-10}, 100]$ works for this problem.

In [5]:
def optimize(
    fem, rho_0, rho_min, rho_max, V_0, iter=100, alpha=0.5, m=0.2, p=1.0, r=0.0
):
    # Element stiffness matrices for unit thickness. fem.k0() includes the
    # current thickness, so we divide it out. This also keeps the function
    # correct, if it is called again on a model with modified thickness.
    k0 = torch.einsum("i,ijk->ijk", 1.0 / fem.thickness, fem.k0())

    # Histories of design variables and compliance values
    rho = [rho_0]
    C = []

    # Element volumes at full density (rho=1) with the notebook-global
    # thickness d
    vols = d * compute_areas(fem)

    # a) Check if there is a feasible solution before starting iteration

    # b) Precompute filter weights
    if r > 0.0:
        pass

    # Iterate solutions
    for k in tqdm(range(iter), delay=0.1):
        # c) Adjust thickness, compute FEM solution, and record compliance

        # d) Compute sensitivities

        # e) Filter sensitivities (if r provided)
        if r > 0.0:
            pass

        # f) For a certain value of mu, apply the iteration scheme
        def make_step(mu):
            pass

        # g) Constraint function
        def g(mu):
            pass

        # h) Find the root of g(mu) and append the updated design to rho

    return rho, C

Once you have implemented a), you can verify it with the following checkpoint: the call below requests a volume constraint that is infeasible even at the minimum density, so your function must raise an exception.

In [6]:
ones = torch.ones(square.n_elem)
try:
    optimize(square, 0.9 * ones, 0.9 * ones, ones, V_0=0.05)
    print("FAILED: No exception was raised - check your feasibility test in a).")
except Exception as e:
    print(f"PASSED: Raised an exception: '{e}'")
FAILED: No exception was raised - check your feasibility test in a).

i) Set up the initial design variables to $\rho_0=0.4, \rho_{min}=0.01, \rho_{max}=1.0$ for all elements and a volume constraint $V_0= 0.4 V_{max}$ with the maximum design volume $V_{max}$. The uniform design $\rho_0=0.4$ satisfies the volume constraint exactly - starting from a feasible design guarantees that a suitable Lagrange parameter exists in every iteration.

In [7]:
# Initial density, minimum density, maximum density

# Volume constraint (40% of maximum design volume)

j) Perform the optimization with 50 iterations and the following parameters: $$ p=3 $$ $$ r=0 $$ Self-check: Every iterate must satisfy the volume constraint exactly, i.e. torch.dot(rho_opt[-1], vols) / V0 should evaluate to 1. The compliance of the final design should be $C \approx 0.0505$.

In [ ]:
 

k) Plot the evolution of the design variables and the compliance vs. iterations. To which values do the design variables converge and which parameter of the algorithm drives them there? How does the compliance evolve?

In [ ]:
 

l) Perform the optimization with 100 iterations and the following parameters $$ p=3 $$ $$ r=0.05 $$

In [ ]:
 

m) Compare the designs from j) and l). Which numerical artifact do you observe in the unfiltered design, why does it occur, and how does the sensitivity filter suppress it?

n) How do you interpret the design? Decide which manufacturing process you would like to use and use a CAD software to create a design based on your optimization.

Bonus task (optional): Repeat the optimization on a finer mesh (e.g. $N=60$) with $r=0$ and $r=0.05$. What do you observe regarding the mesh dependence of the two solutions?

In [ ]: