Developer Guide
This guide outlines the mathematical foundation, architectural patterns, and engineering decisions behind the ggp framework.
Mathematical Foundation
The Generalized Geometry Projection (GGP) method parametrizes a design domain using explicit geometric primitives (e.g., rectangles, ellipses) rather than a dense grid of pixel densities. This framework implements the 2D Free formulation based on the original work by Coniglio et al.
1. Primitive Mapping
Each component \(i\) is defined by a set of continuous variables \(x_i = [X_c, Y_c, L, H, \theta, M_c]\), representing its center coordinates, length, thickness, orientation angle, and membership density.
To determine the density contribution of a primitive at any spatial point \((x, y)\), the evaluation proceeds in two steps.
Step 1: Local Coordinates The global coordinates are translated and rotated to the component’s local reference frame:
Step 2: Characteristic Function (AMNA — default)
The default method is AMNA (Anisotropic Moving Node Approach, called MNA in the original Matlab reference). It models each component as an axis-aligned rectangle in local coordinates, using four independent quintic window evaluations — one per edge:
Each \(\zeta_k\) measures the signed distance from the corresponding
edge (\(\zeta_k < 0\) means inside that half-space). The quintic window
function with bandwidth \(\sigma\) (parameter r_gp) is:
The component characteristic value is the product of all four window evaluations, integrated over a small Gauss-point sampling window of radius \(r_{gp}\) centred on each element:
Default settings (matching the original reference):
method='AMNA', r_gp=1.0 (one-element transition band), minh=3
(minimum bar thickness of 3 elements).
Legacy method (GP — circular capsule)
The original GP formulation uses a single distance-based transition. It is
still available via method='GP' in the YAML formulation block, but
produces less crisp bar boundaries and is not recommended for new runs.
2. Saturated Kreisselmeier-Steinhauser (KS) Aggregation
To combine multiple overlapping components into a single global density field \(\rho(x, y)\), the KS function is used as a smooth approximation of the maximum operator:
Because the KS function can exceed 1.0 (causing issues for physics solvers), we apply a smooth saturation function to strictly bound the final density \(\rho \in [0, 1]\).
3. Continuous Additive Layer Manufacturing (ALM) Mapping
The ALM formulation constructs a cohesive part made of \(N_y\) stacked horizontal layers (or trapezoidal segments). The design variable vector has the following interleaved layout (matching the GGP_ALM Matlab reference):
[Xc_0_0, L_0_0, Xc_0_1, L_0_1, …, Xc_{Ny-1}_{Np-1}, L_{Ny-1}_{Np-1},
h_0, …, h_{Np-1},
Mc_0, …, Mc_{Np-1},
y0, theta0]
\(X_{c,k,j}\), \(L_{k,j}\) — x-centre and width of column \(j\) in layer \(k\)
\(h_j \in [0.2,1]\) — normalised total print height per column (\(N_p\) values)
\(M_{c,j}\) — membership density per column, shared across all layers
\(y_0\), \(\theta_0\) — global printing-plane offset and rotation
Total variables: \(2 N_y N_p + 2 N_p + 2\).
The characteristic function uses the quintic MNA window and a height cutoff \(\zeta_3 = y_p - L_y h_j\) to terminate each column at its total print height. Left/right boundary tests are linearly interpolated between adjacent layer interfaces to produce smooth trapezoidal segments. Overhang and bridge-length constraints are linear in the \((X_c, L)\) variables; see Additive Manufacturing Constraints for full details.
Finite Element Solver
The physical evaluation is handled by a FEniCS-based Linear Elasticity solver. It takes the projected continuous density field \(\rho(x, y)\) and evaluates the mechanical compliance (stiffness) of the structure.
1. Material Penalization (SIMP) We use the Solid Isotropic Material with Penalization (SIMP) model to interpolate the Young’s modulus \(E\):
where \(p=3.0\) is the penalization power, \(E_0 = 1.0\) is the solid stiffness, and \(E_{min} = 10^{-6}\) is a tiny void stiffness to prevent a singular matrix.
2. Constitutive Relations The stress tensor \(\sigma(u)\) for a displacement field \(u\) under the assumption of linear isotropic elasticity is:
where the linear strain is \(\varepsilon(u) = \frac{1}{2}(\nabla u + (\nabla u)^T)\). For 2D Plane Stress formulations, the Lamé parameters are defined using Poisson’s ratio \(\nu = 0.3\):
3. Variational Weak Form The solver finds the displacement \(u \in V\) that satisfies the weak form of the equilibrium equation:
where \(t\) is the traction boundary load applied on \(\partial \Omega_N\).
4. Compliance Objective The objective function for stiffness maximization is the compliance \(C\), computed as the external work done by the applied loads:
Architecture & Design Patterns
The ggp package is structured around four interlocking design patterns that enable extension without modifying existing code.
Data Flow
graph TD
subgraph Config
A[YAML file / ProblemSpec] -->|load_problem| B(ProblemSpec)
end
subgraph GGPPipeline
B --> C(GeometryReader registry)
C -->|DomainRepresentation| D(FEMDiscretiser)
D -->|AnalysisDomain| E(GGPGeometryDiscipline)
D --> F(GGPPhysicsDiscipline)
E -->|rho_E, rho_V| F
E -.->|drho/dx Jacobian| G
F -->|compliance, volume| G
F -.->|adjoint gradients| G
end
subgraph GEMSEO
G(MDAChain) -->|objective + gradients| H[MMA / SLP / CONLIN]
H -->|x_vars| G
end
1. Declarative Problem Specification
Every optimisation run is fully described by a single ProblemSpec instance — a frozen dataclass tree that is the single source of truth. It is constructed from a YAML file by load_problem() and can be overridden programmatically using dataclasses.replace():
from dataclasses import replace
from ggp.problem.loader import load_problem
spec = load_problem("short_cantilever.yaml")
spec = replace(spec, volfrac=0.3,
solver=replace(spec.solver, algorithm="SLP", max_iter=100))
Because ProblemSpec is frozen (@dataclass(frozen=True)), it is safe to share across threads and to serialize back to YAML without mutation concerns.
2. Self-Registering Geometry Readers
The ggp.geometry.io.registry module holds a global dictionary mapping geometry type strings to reader classes. Registering a new geometry source requires only a decorator — no changes to any other file:
from ggp.geometry.io.registry import register_reader
from ggp.geometry.io.base import GeometryReader, DomainRepresentation
@register_reader("my_mesh_format")
class MyMeshReader(GeometryReader):
def read(self, spec):
...
return DomainRepresentation(...)
The reader can then be referenced in any YAML file as type: my_mesh_format.
3. Self-Registering Projection Mappers
The ggp.projection.registry module follows the same decorator pattern for geometry projection.
Each mapper is a pure-NumPy class (no FEniCS dependency) that implements the ProjectionMapper interface:
forward()— compute(rho_E, rho_V)fromx_varsjacobian()— compute analytic Jacobiansdrho_E/dx,drho_V/dxnum_vars_per_component()— design variables per primitivedefault_bounds()— sensible bounds given domain extents
Built-in mappers and their variable counts:
Mode key |
Class |
Variables per component |
|---|---|---|
|
|
6: Xc, Yc, length, thickness, angle, density |
|
|
8: Xc, Yc, Zc, length, width, theta, phi, density |
|
|
3: Xc, width, mass |
|
|
6: Xc, Yc, width_x, width_y, Zc, mass |
Adding a new mapper:
from ggp.projection.registry import register_mapper
from ggp.projection.base import ProjectionMapper
@register_mapper("Lattice_3D")
class Lattice3DMapper(ProjectionMapper):
def forward(self, x_vars, eval_coords, power_E=1.0, power_V=1.0):
...
def jacobian(self, x_vars, eval_coords, power_E=1.0, power_V=1.0):
...
The new mode is immediately available in the CLI via formulation.mode: Lattice_3D in a YAML config.
4. GEMSEO Discipline Wrapper
GGPGeometryDiscipline is a thin GEMSEO Discipline that:
Fetches the correct mapper from the registry via
get_mapper(mode, ...).Calls
mapper.forward()andmapper.jacobian()in_run()and_compute_jacobian().Applies KS aggregation and smooth saturation to produce final
rho_E/rho_Vfields.
GGPPhysicsDiscipline wraps the FEniCS elasticity solver and computes the compliance (objective) and volume constraint value.
Both disciplines run inside a GEMSEO MDAChain driven by an MDF scenario, which handles the gradient chain-rule automatically.
FEM Solver & Pre-computed Reference Matrix
To avoid FEniCS adjoint taping overhead at every iteration, GGPPhysicsDiscipline uses a pre-computed reference stiffness matrix strategy:
Initialisation: A reference element stiffness matrix \(K_{ref}\) (for \(E = 1\)) is computed once via FEniCS during setup.
Per-iteration assembly: At each optimisation step, the global stiffness matrix is assembled by scaling \(K_{ref}\) with the SIMP-penalized modulus \(E(\rho_e)\) per element and injecting the result into a SciPy COO sparse matrix (CSR after conversion).
Linear solve: The assembled \(K U = F\) is dispatched to one of three pluggable backends selected via
solver.fem_solverin the YAML or--fem-solveron the CLI:Backend
Key
Implementation
Best for
Direct
direct(default)scipy.sparse.linalg.spsolve(SuperLU / UMFPACK)Small–medium 2-D meshes; deterministic and always converges
Iterative
iterativePETSc CG with GAMG algebraic multigrid preconditioner
Large 3-D meshes; memory-efficient
AMJax
amjaxPyAMG smoothed-aggregation hierarchy as preconditioner for scipy CG
Both 2-D and 3-D; robust, memory-efficient, dependency-free
The assembly code is identical for 2-D and 3-D problems; only the size and sparsity of \(K\) differ. All three backends operate on the same CSR matrix and return the same NumPy displacement vector, so switching backends never affects post-processing or sensitivity computation.
AMJax backend details (ggp.physics.amjax_solver):
The amjax backend in solve_amjax() implements the core approach of the AMJax library — algebraic multigrid (AMG) preconditioning for FEM Krylov solves — using PyAMG and SciPy directly. It performs the following steps every iteration:
Converts \(K\) to a CSR
float64matrix.Builds a smoothed-aggregation AMG hierarchy via
pyamg.smoothed_aggregation_solver(optimal for SPD FEM matrices).Exposes the hierarchy as a
scipy.sparse.linalg.LinearOperatorpreconditioner viaml.aspreconditioner(cycle=cycle).Runs preconditioned CG via
scipy.sparse.linalg.cgwith a fallback retry if convergence is not achieved withinmaxitersteps.Returns the solution as a NumPy
float64array.
This approach delivers the same convergence benefits as AMJax (AMG-preconditioned CG vs raw iterative solve) while requiring only pyamg as an additional dependency alongside SciPy. The cycle parameter selects the AMG cycle type ("V", "W", or "F"; default "V").
Performance Monitoring
The ggp/utils/profiling.py module provides utilities to time individual pipeline stages. Profiling data is written to performance_logs/ and aggregated in performance_history.json (per Git commit).
To analyze bottlenecks in a saved .prof file, use snakeviz:
pip install snakeviz
snakeviz performance_logs/<benchmark>.prof
Citations
Original Paper: Coniglio, S., Morlier, J., Gogu, C. et al. Generalized Geometry Projection: A Unified Approach for Geometric Feature Based Topology Optimization. Arch Computat Methods Eng 27, 1573–1610 (2020). https://doi.org/10.1007/s11831-019-09362-8
Original MATLAB Code: GGP-Matlab Repository
AMJax FEM Solver: The
amjaxbackend implements the algebraic multigrid preconditioning approach introduced by AMJax (Boussange, V.), using PyAMG smoothed-aggregation hierarchies and SciPy CG in place of the original JAX implementation.