using TopOpt, LinearAlgebra
using WGLMakie
WGLMakie.activate!(; resize_to=:parent)
using Bonito
if haskey(ENV, "QUARTO_PROJECT_DIR")
Bonito.Page(exportable=true, offline=true)
else
Bonito.browser_display()
end
display_app(app) = display(app)Simultaneous Analysis and Design (SAND)
Description
Nested topology optimization solves the equilibrium equation K(x) u = f for the displacements u at every design iterate, then differentiates through that solve. Simultaneous analysis and design (SAND) instead treats the displacements u as optimization variables and enforces the equilibrium equation as a constraint:
\[ \min_{x, u}\; f^\mathsf{T} u \quad\text{s.t.}\quad K(x)\,u = f,\quad 0 \le x_e \le 1 \]
At a feasible point K u = f, the objective fᵀu equals the compliance uᵀKu, so this is the same physical problem — but the linear solve is folded into the optimization, and the equilibrium is enforced by a nonlinear solver rather than a nested factorization.
TopOpt.jl exposes the differentiable pieces needed for SAND: ElementKFun maps densities to element stiffness matrices, AssembleKFun assembles them into the global matrix, and apply_boundary_with_meandiag! applies the Dirichlet boundary conditions — all differentiable through Zygote.
Setup
WGLMakie.activate!(; resize_to=:parent) selects the browser renderer and fills the Quarto output column. Bonito.Page(exportable=true, offline=true) embeds the assets needed by visualize(...; static=true) in the Quarto output, so the visualization does not require a running Julia process.
The differentiable assembly
E, ν, f = 1.0, 0.3, 1.0
problem = PointLoadCantilever((20, 10), (1.0, 1.0), E, ν, f)
ch = problem.ch
nel = getncells(problem)
solver = FEASolver(DirectSolver, problem; xmin=0.001, penalty=PowerPenaltyFun(3.0))
solver.vars .= 1.0
solver()
fvec = copy(solver.globalinfo.f) # assembled load (BC applied)
ndofs = length(fvec)
element_k = ElementKFun(solver) # x -> element stiffness matrices
assemble_k = AssembleKFun(problem) # element matrices -> global K
K(x) = apply_boundary_with_meandiag!(assemble_k(element_k(PseudoDensities(x))), ch)We verify these blocks reproduce the solver’s own equilibrium: assembling K(x) and solving K u = f matches the nested displacement field, and the compliance fᵀu matches ComplianceFun.
x = ones(nel)
Kx = K(x)
u_nested = copy(solver.u)
u_direct = Kx \ fvec
comp = ComplianceFun(solver)
println("assembly reproduces equilibrium: |K u - f| = $(norm(Kx * u_direct - fvec))")
println("nested vs assembled u: |Δu| = $(norm(u_nested - u_direct))")
println("f'u = $(dot(fvec, u_direct)), ComplianceFun = $(comp(PseudoDensities(x)))")assembly reproduces equilibrium: |K u - f| = 1.7491395730072561e-13
nested vs assembled u: |Δu| = 0.0
f'u = 35.29121684231742, ComplianceFun = 35.29121684232222
The augmented Lagrangian method
The equilibrium is a vector of ndofs equality constraints r(x, u) = K(x) u - f = 0. The augmented Lagrangian folds them into the objective:
\[ L(x,u;\lambda,\rho) = f^\mathsf{T} u + \lambda^\mathsf{T} r + \frac{\rho}{2}\,r^\mathsf{T} r, \qquad \lambda \leftarrow \lambda + \rho\, r,\quad \rho \leftarrow \gamma\rho. \]
For a fixed design (analysis only) the subproblem is a convex quadratic in u, so it can be solved exactly. Below we run the multiplier/penalty loop and show the equilibrium residual collapse to zero, recovering the nested displacement.
function al_subproblem(λ, ρ)
# stationarity of L w.r.t. u: K(λ + ρ (K u - f)) = -f => u = K⁻¹f - K⁻¹(K⁻¹f + λ)/ρ
w = Kx \ fvec
return w .- (Kx \ (w .+ λ)) ./ ρ
endλ = zeros(ndofs)
ρ = 1.0
u = zeros(ndofs)
for k in 1:6
global λ, ρ, u
u = al_subproblem(λ, ρ)
r = Kx * u - fvec
println("AL iter $k: |r| = $(norm(r)), |u - u_nested| = $(norm(u - u_nested))")
λ .+= ρ .* r
ρ *= 10.0
endAL iter 1: |r| = 277.208878401867, |u - u_nested| = 647437.9746524752
AL iter 2: |r| = 4.415424300535528e-11, |u - u_nested| = 5.086145683582884e-9
AL iter 3: |r| = 1.650512649755094e-13, |u - u_nested| = 1.4029480611646794e-12
AL iter 4: |r| = 1.6058522997914846e-13, |u - u_nested| = 1.657937226649138e-12
AL iter 5: |r| = 1.6338611980131567e-13, |u - u_nested| = 3.5075010148938525e-12
AL iter 6: |r| = 1.6388752745859102e-13, |u - u_nested| = 2.4198905860534385e-12
After the first multiplier update the equilibrium is satisfied to machine precision — in exact arithmetic the augmented Lagrangian converges in a single multiplier update for a quadratic equality, and in practice a few iterations suffice.
Feasibility of the recovered solution
The displacement u is an optimization variable, so we verify the equilibrium constraint directly: the residual K u - f must vanish, and u must match the nested solver’s displacement.
r_final = Kx * u - fvec
println("equilibrium residual |K u - f| = $(norm(r_final))")
println("recovered vs nested |Δu| = $(norm(u - u_nested))")
println("feasible: $(norm(r_final) < 1e-8)")equilibrium residual |K u - f| = 1.6388752745859102e-13
recovered vs nested |Δu| = 2.4198905860534385e-12
feasible: true
fig = visualize(problem; static=true, topology=ones(nel), u=u, default_exagg_scale=0.5)
display_app(fig)The residual is at machine precision, so the SAND analysis solution satisfies K u = f exactly and reproduces the nested displacement field.
From analysis to design
The same augmented Lagrangian applies to the full SAND problem: add the volume constraint and optimize over (x, u) jointly, with K(x) u - f = 0 enforced as the augmented-Lagrangian term. TopOpt.jl’s first-order augmented Lagrangian solver, PercivalAlg from NonconvexPercival, does exactly this.
model = Model(z -> dot(fvec, z[nel+1:end]))
addvar!(model, vcat(zeros(nel), fill(-1e3, ndofs)), vcat(ones(nel), fill(1e3, ndofs)))
add_ineq_constraint!(model, z -> sum(z[1:nel]) / nel - 0.5)
add_eq_constraint!(model, z -> K(z[1:nel]) * z[nel+1:end] - fvec; dim=ndofs)
z_sand = optimize(model, PercivalAlg(), vcat(fill(0.5, nel), solver.u);
options=PercivalOptions(; max_iter=25, subsolver_max_eval=250)).minimizer
x_sand = z_sand[1:nel]
u_sand = z_sand[nel+1:end][ Info: iter fx normgp normcx μ normy sumc inner_status iter_type [ Info: 0 3.5e+01 2.3e+00 8.7e-01 1.0e+01 2.2e+01 5 [ Info: 1 3.5e+01 2.3e+00 8.7e-01 1.0e+02 2.2e+01 9 first_order update_μ [ Info: 2 3.5e+01 1.5e+00 1.5e-01 1.0e+02 1.4e+01 740 max_eval update_y [ Info: 3 3.5e+01 1.5e+00 1.5e-01 1.0e+03 1.4e+01 744 max_eval update_μ [ Info: 4 3.5e+01 3.6e+01 1.5e-01 1.0e+03 1.5e+02 748 max_eval update_y [ Info: 5 3.5e+01 3.6e+01 1.5e-01 1.0e+04 1.5e+02 752 max_eval update_μ [ Info: 6 3.5e+01 3.9e+02 1.5e-01 1.0e+04 1.6e+03 756 max_eval update_y [ Info: 7 3.5e+01 3.9e+02 1.5e-01 1.0e+05 1.6e+03 760 max_eval update_μ [ Info: 8 3.5e+01 3.9e+03 1.5e-01 1.0e+05 1.7e+04 764 max_eval update_y [ Info: 9 3.5e+01 3.9e+03 1.5e-01 1.0e+06 1.7e+04 768 max_eval update_μ [ Info: 10 3.5e+01 1.1e+04 1.5e-01 1.0e+06 1.7e+05 772 max_eval update_y [ Info: 11 3.5e+01 1.1e+04 1.5e-01 1.0e+07 1.7e+05 776 max_eval update_μ [ Info: 12 3.5e+01 1.8e+04 1.5e-01 1.0e+07 1.7e+06 780 max_eval update_y [ Info: 13 3.5e+01 1.8e+04 1.5e-01 1.0e+08 1.7e+06 784 max_eval update_μ [ Info: 14 3.5e+01 2.0e+04 1.5e-01 1.0e+08 1.7e+07 788 max_eval update_y [ Info: 15 3.5e+01 2.0e+04 1.5e-01 1.0e+09 1.7e+07 792 max_eval update_μ [ Info: 16 3.5e+01 2.0e+04 1.5e-01 1.0e+10 1.7e+07 796 max_eval update_μ [ Info: 17 3.5e+01 2.0e+04 1.5e-01 1.0e+11 1.7e+07 800 max_eval update_μ [ Info: 18 3.5e+01 2.0e+04 1.5e-01 1.0e+12 1.7e+07 804 max_eval update_μ [ Info: 19 3.5e+01 2.0e+04 1.5e-01 1.0e+13 1.7e+07 808 max_eval update_μ [ Info: 20 3.5e+01 2.0e+04 1.5e-01 1.0e+14 1.7e+07 812 max_eval update_μ [ Info: 21 3.5e+01 2.0e+04 1.5e-01 1.0e+15 1.7e+07 816 max_eval update_μ [ Info: 22 3.5e+01 2.0e+04 1.5e-01 1.0e+16 1.7e+07 820 max_eval update_μ [ Info: 23 3.5e+01 2.0e+04 1.5e-01 1.0e+17 1.7e+07 824 max_eval update_μ
462-element Vector{Float64}:
0.01636111637855903
0.01636111637855903
-1.2034417636527244
-0.8734758156064086
-0.7610502677316888
-0.5034453400585096
0.01636111637855903
0.01636111637855903
-2.1570522686847937
-1.5825553304712823
⋮
-24.659715534830095
10.237010150581538
-26.84677520156709
10.504408455323539
-29.070766052588272
10.401467890042532
-31.072628013871874
10.831331360594
-33.529567751262064
Feasibility of the SAND design
The optimization now jointly returns a design x_sand and a displacement u_sand. Because u is a decision variable, its feasibility is not guaranteed — we must check the equilibrium residual K(x) u - f and compare the objective fᵀu against the nested compliance (which satisfies equilibrium by construction).
r_sand = K(x_sand) * u_sand - fvec
solver.vars .= x_sand
solver()
u_nested_sand = copy(solver.u)
println("volume fraction = $(round(sum(x_sand) / nel, digits=4))")
println("equilibrium residual = $(round(norm(r_sand), digits=4)) (relative $(round(norm(r_sand) / norm(fvec), digits=4)))")
println("SAND objective f'u = $(round(dot(fvec, u_sand), digits=3))")
println("nested compliance at x = $(round(dot(fvec, u_nested_sand), digits=3))")
println("feasible (|K u - f| ≈ 0) = $(norm(r_sand) < 1e-6)")volume fraction = 0.5266
equilibrium residual = 0.1464 (relative 0.1464)
SAND objective f'u = 34.854
nested compliance at x = 860.004
feasible (|K u - f| ≈ 0) = false
The recovered u is only approximate: the problem is nonconvex and the solver may not find a solution that satisfies all constraints perfectly, so a significant residual K(x)u - f means the design is not actually feasible. Consequently fᵀu no longer equals the nested compliance (which satisfies equilibrium by construction).
Discussion
- SAND exposes
uas a design variable and enforcesK u = fas a constraint, avoiding a nested solve per iterate at the cost of a larger, nonconvex problem. - The building blocks (
ElementKFun,AssembleKFun,apply_boundary_with_meandiag!) are differentiable, so the equilibrium residual and its gradient are available through Zygote. - The augmented Lagrangian is the natural method for the equilibrium constraint; for the convex analysis-only case it converges immediately, while the full SAND problem relies on first-order solvers such as
NonconvexPercival.PercivalAlg.