Setting up a minimal end-to-end ae108 example
An ae108 example wires together five things: a mesh, an element
type (shape + material model), an assembler, boundary conditions,
and a solver — then writes the result out. This walks through
examples/Basic.cc, the simplest complete example: two
quad elements sharing an edge, fixed on one end and pulled on the other.
1. Aliases & includes
Each example starts by pulling in the pieces it needs and aliasing them to
short, domain-level names, so the rest of the file reads in terms of Mesh,
Vector, EssentialBoundaryCondition, etc. instead of fully-qualified
ae108::... names:
#include "ae108/assembly/Assembler.h"
#include "ae108/assembly/DefaultFeaturePlugins.h"
#include "ae108/cpppetsc/BoundaryCondition.h"
#include "ae108/cpppetsc/Context.h"
#include "ae108/cpppetsc/Mesh.h"
#include "ae108/cpppetsc/Vector.h"
#include "ae108/cpppetsc/mpi.h"
#include "ae108/elements/CoreElement.h"
#include "ae108/elements/embedding/IsoparametricEmbedding.h"
#include "ae108/elements/integrator/IsoparametricIntegrator.h"
#include "ae108/elements/materialmodels/Hookean.h"
#include "ae108/elements/quadrature/Quadrature.h"
#include "ae108/elements/shape/Quad4.h"
#include "ae108/solve/NonlinearSolver.h"
#include <array>
#include <vector>
namespace cpppetsc = ae108::cpppetsc;
namespace elements = ae108::elements;
namespace solve = ae108::solve;
namespace assembly = ae108::assembly;
using Context = cpppetsc::Context;
using Mesh = cpppetsc::Mesh;
using Vector = cpppetsc::Vector;
using EssentialBoundaryCondition = cpppetsc::EssentialBoundaryCondition;
template <int Dim> using LabelConstraints = cpppetsc::LabelConstraints<Dim>;
Policy (here ParallelComputePolicy) selects the compute backend and is
threaded through nearly every other type; Context must be constructed
before anything else touches MPI/PETSc/cpppetsc.
2. Describe the geometry
Geometry is just vertex positions plus, per element, which vertex indices it
uses. Basic.cc connects two quads (A and B) sharing the edge between
vertices 1 and 2:
// 3 ------- 2 ------- 5
// | | |
// | A | B |
// 0---------1---------4
// Let's specify the parameters of this mesh.
constexpr auto number_of_vertices_per_element = Mesh::size_type{4};
constexpr auto number_of_elements = Mesh::size_type{2};
constexpr auto number_of_vertices = Mesh::size_type{6};
constexpr auto vertex_dimension = Mesh::size_type{2};
constexpr auto dof_per_vertex = Mesh::size_type{2};
// The connectivity specifies the vertex indices per Element.
using Connectivity =
std::array<std::array<Mesh::size_type, number_of_vertices_per_element>,
number_of_elements>;
constexpr auto connectivity = Connectivity{{
{{0, 1, 2, 3}}, // vertices of element A
{{1, 4, 5, 2}}, // vertices of element B
}};
// Each of the vertices is located at the following coordinates in physical
// space.
using VertexPositions =
std::array<std::array<Mesh::real_type, vertex_dimension>,
number_of_vertices>;
constexpr auto vertex_positions = VertexPositions{{
{{0., 0.}},
{{1., 0.}},
{{1., 1.}},
{{0., 1.}},
{{2., 0.}},
{{2., 1.}},
}};
3. Choose element behavior (the type-building chain)
An element type is assembled from smaller pieces, each answering one question:
| Type | Answers |
|---|---|
MaterialModel (Hookean<vertex_dimension, ...>) |
How does the material respond to strain? |
Shape (Quad4) |
What are the reference-space shape functions? |
Embedding (IsoparametricEmbedding<Shape>) |
How does reference space map to physical space? |
Quadrature |
Which points/weights are used to integrate? |
Integrator |
Combines Quadrature + Embedding to integrate over physical space. |
Element (CoreElement<MaterialModel, Integrator, ...>) |
The element used by the assembler. |
using MaterialModel =
materialmodels::Hookean<vertex_dimension, Vector::value_type,
Vector::real_type>;
// We'll choose the Quad4 shape functions (4 shape functions) that are defined
// in 2D reference space.
using Shape = shape::Quad4;
// Since also our physical space is 2D we may use the isoparametric embedding.
using Embedding = embedding::IsoparametricEmbedding<Shape>;
// Let's choose a simple quadrature rule in reference space.
using Quadrature = quadrature::Quadrature<quadrature::QuadratureType::Cube,
vertex_dimension, 1 /* order */>;
// We use an "Integrator" to integrate in physical space. Of course, it depends
// on the selected quadrature rule and the embedding. In the case of the
// isoparametric embedding, this embedding is specified by the "Shape".
using Integrator =
integrator::IsoparametricIntegrator<Shape, Quadrature, Vector::value_type,
Vector::real_type>;
// Now we are ready to select our element type.
using Element = elements::CoreElement<MaterialModel, Integrator,
Vector::value_type, Vector::real_type>;
4. Assembler
Basic.cc has only one element type, so a single Assembler is enough (no
AssemblerGroup needed). DefaultFeaturePlugins supplies the default
assembly features (energy/force/stiffness) that most elements support:
using Assembler = assembly::Assembler<Element, assembly::DefaultFeaturePlugins>;
// Our goals is to minimize the energy. This is done by the solver.
using Solver = solve::NonlinearSolver<Assembler>;
5. Build the mesh and populate the assembler
auto mesh = Mesh::fromConnectivity<cpppetsc::ElementType::Quadrilateral,
vertex_dimension>(
connectivity, vertex_positions, dof_per_vertex, 0);
for (const auto &element : mesh.localElements()) {
auto element_index = element.localIndex();
const auto cell_coords =
mesh.closureCoordinates<vertex_dimension>(element_index);
assembler.emplaceElement(
element, Element{.model = model,
.integrator = Integrator(Embedding(
Embedding::Collection<Embedding::PhysicalPoint>{{
cell_coords[0],
cell_coords[1],
cell_coords[2],
cell_coords[3],
}}))});
}
Iterating mesh.localElements() rather than indexing the connectivity
directly is what makes this loop MPI-safe: under a distributed mesh, each
rank only sees (and only needs to assemble) the elements it locally owns.
6. Boundary conditions
Boundary conditions are set up as their own three-step pipeline — tag mesh
points, describe constrained dofs, then resolve to local dof indices — which
is covered in full in boundaryconditions.md. Here
the left edge (x=0) is fully fixed and the right edge (x=2) is pulled by
0.5 in x:
// Define label value for all elements that should be constrained
constexpr auto left_bc_tag = 5;
constexpr auto right_bc_tag = 6;
// Associate a tag with a constrained dof at the nodes
const auto essential_bcs = std::vector<EssentialBoundaryCondition>{
{.label_value = left_bc_tag, .constrained_dofs{{0, 0.0}, {1, 0.0}}},
{.label_value = right_bc_tag, .constrained_dofs{{0, 0.5}, {1, 0.0}}}};
// Define the a condition on coordinates that associates a edges/faces
// with
// label value.
const auto functional_bc = std::vector<LabelConstraints<vertex_dimension>>{
{.condition =
[](std::span<const PetscReal> coords) {
const auto tolerance = .1;
const auto x_pos = coords[0];
return (std::abs(x_pos - 0.) < tolerance);
},
.label_value = left_bc_tag},
{.condition =
[](std::span<const PetscReal> coords) {
const auto tolerance = .1;
const auto x_pos = coords[0];
return (std::abs(x_pos - 2.) < tolerance);
},
.label_value = right_bc_tag},
};
// Assign a label value to the boundary elements
mesh.markBoundaryCells(functional_bc);
// Get the actual constrained dofs to be used in the solver
const auto boundary_conditions =
extractBoundaryDofs<Mesh, dof_per_vertex>(mesh, essential_bcs);
7. Solve
auto initial_guess = Vector::fromGlobalMesh(mesh);
// We are ready to minimize the energy.
const auto result = solver.computeSolution(
boundary_conditions, std::move(initial_guess), time, &assembler);
8. Output
Basic.cc writes the (undeformed) mesh to VTU up front, then, after
solving, both prints the result (reordered into canonical per-vertex-dof
order) and writes the deformed result to VTU:
result.unwrap().print();
// Write a vtu file with the result that can directly be visualized in
// Paraview
result.unwrap().writeAsVtu(mesh, "ae108-examples-basic.vtu");
auto full_result_local =
Vector::fromDistributedInCanonicalOrder(result, mesh);
full_result_local.unwrap().writeAsAscii(mesh, "ae108-examples-basic.txt");
}
Minimal end-to-end skeleton
int main(int argc, char **argv) {
const auto context = Context(&argc, &argv); // 0. init MPI/PETSc
// 1-2. geometry: Connectivity + VertexPositions (section 2)
// 3. element type chain: MaterialModel/Shape/Embedding/Quadrature/
// Integrator/Element (section 3)
// 4. Assembler + Solver (section 4)
auto mesh = Mesh::fromConnectivity<cpppetsc::ElementType::Quadrilateral,
vertex_dimension>(
connectivity, vertex_positions, dof_per_vertex, 0);
auto assembler = Assembler();
for (const auto &element : mesh.localElements()) { // 5. populate assembler
// emplaceElement(...) built from cell_coords via Integrator/Embedding
}
const auto solver = Solver(&mesh);
mesh.markBoundaryCells(functional_bc); // 6. boundary conditions
const auto boundary_conditions =
extractBoundaryDofs<Mesh, dof_per_vertex>(mesh, essential_bcs);
const auto result = solver.computeSolution( // 7. solve
boundary_conditions, Vector::fromGlobalMesh(mesh), /* time */ 0., &assembler);
result.unwrap().writeAsVtu(mesh, "result.vtu"); // 8. output
}
See examples/Basic.cc for the full, runnable version.
Building & running it
The example is registered in examples/CMakeLists.txt as an executable target linking against the libraries used above:
add_executable(${PROJECT_NAME}-Basic Basic.cc)
target_link_libraries(${PROJECT_NAME}-Basic
PRIVATE ae108::solve
PRIVATE ae108::assembly
PRIVATE ae108::elements
)
Build it as part of the project's normal CMake configure/build, then run the
resulting ae108-examples-Basic binary — it prints the solved displacements
on the primary rank and writes ae108-examples-basic-mesh.vtu and
ae108-examples-basic.vtu, which can be opened in ParaView (or similar) to
inspect the mesh and the solved deformation.