This program was contributed by David Schneider <dav.schneider@tum.de>.
It comes without any warranty or support by its authors or the authors of deal.II.
This program is part of the deal.II code gallery and consists of the following files (click to inspect):
Pictures from this code gallery program
Annotated version of README.md
Laplace equation coupled to an external simulation program
Overview
preCICE allows to couple deal.II to external simulation software, such as OpenFOAM, SU2, or CalculiX. To keep dependencies of this example minimal, we couple deal.II to an external C++ program, which provides a time varying boundary condition. The deal.II code consists mainly of the step-4 tutorial program, where a simple Laplace problem is solved.
Coupling with preCICE is usually carried out along surfaces in order to apply a Dirichlet-Neumann coupling between two domains (volume coupling is also possible). For the sake of simplicity, we couple here an external C++ program in a unidirectional fashion to one side of our quadrilateral domain. The external C++ program generates a parabolic boundary profile with time varying amplitude. The boundary values are then used in the Laplace solver as a Dirichlet boundary condition.
Time discretization
Coupled simulations deal mostly with time-dependent problems. Hence, we make the stationary Laplace problem from step-4 time dependent.
\begin{align*} \frac{\partial u}{\partial t}-\Delta u &= f \qquad\qquad & \text{in}\ \Omega,
\\
u &= x^2+y^2 \qquad\qquad & \text{on}\ \partial\Omega_s,
\\
u &= g(t) \qquad\qquad & \text{on}\ \partial\Omega_c.
\end{align*}
with the fixed Dirichlet boundary \(\Omega_s\), the coupling boundary \(\Omega_c\) and the time-dependent coupling data \(g(t)\).
The system is consequently discretized by a first-order backward Euler method, resulting in
\begin{align*} \frac{u^{n+1}-u^n}{\Delta t}-\Delta u^{n+1} &= f \qquad\qquad & \text{in}\ \Omega,
\end{align*}
at the next time level n+1.
Requirements
deal.II, version 9.2 or greater. Older versions might work as well, but have not been tested.
preCICE, version 2.0 or greater. Have a look at the provided link for an installation guide.
Compiling and running
Similar to the example programs, run
cmake -DDEAL_II_DIR=/path/to/deal.II -Dprecice_DIR=/path/to/precice .
in this directory to configure the problem. The explicit specification of the library locations can be omitted if they are installed globally or via environment variables. You can then switch between debug and release mode by calling either
or
This command will generate two executables: one for the coupled_laplace_problem and one for the fancy_boundary_condition participant.
./coupled_laplace_problem
executes the coupled_laplace_problem. In order to start the coupled simulation, execute
./fancy_boundary_condition
in the same directory from another terminal window.
preCICE and the deal.II solver create several log files during the simulation. In order to remove all result-associated files and clean-up the simulation directory the Allclean script can be executed.
Results
By default, the results are saved in each time step using vtk files, which can, for example, be visualized with ParaView. You should get something like: 
Further reading
A complete overview of the preCICE project can be found on the preCICE webpage. The source code of preCICE is hosted on Github. For more coupled deal.II codes, have a look at the deal.II adapter repository. In the preCICE tutorials, a fluid-structure interaction example is given coupling OpenFOAM (fluid dynamics) to deal.II (solid mechanics). The preCICE reference paper
Annotated version of Allclean.sh
## -----------------------------------------------------------------------------
##
## SPDX-License-Identifier: LGPL-2.1-or-later
## Copyright (C) 2020 by David Schneider
## Copyright (C) 2020 by Benjamin Uekermann
##
## This file is part of the deal.II code gallery.
##
## -----------------------------------------------------------------------------
#!/bin/sh
cd @f${0%/*} || exit 1 # Run from this directory
echo "Cleaning..."
# Participant 1: coupled_laplace_problem
Participant1="coupled_laplace_problem"
# Participant 2: fancy_boundary_condition
Participant2="fancy_boundary_condition"
# Remove vtk result files
rm -fv solution-*.vtk
# Remove the preCICE-related log files
echo "Deleting the preCICE log files..."
rm -fv \
precice-*.log \
precice-*-events.json
rm -rfv precice-run
rm -fv .@f${Participant1}-@f${Participant2}.address
echo "Cleaning complete!"
#------------------------------------------------------------------------------
Annotated version of coupled_laplace_problem.cc
The included deal.II header files are the same as in the other example programs:
#include <deal.II/base/function.h>
#include <deal.II/base/logstream.h>
#include <deal.II/base/quadrature_lib.h>
#include <deal.II/dofs/dof_accessor.h>
#include <deal.II/dofs/dof_handler.h>
#include <deal.II/dofs/dof_tools.h>
#include <deal.II/fe/fe_q.h>
#include <deal.II/fe/fe_values.h>
#include <deal.II/grid/grid_generator.h>
#include <deal.II/grid/tria.h>
#include <deal.II/grid/tria_accessor.h>
#include <deal.II/grid/tria_iterator.h>
#include <deal.II/lac/dynamic_sparsity_pattern.h>
#include <deal.II/lac/full_matrix.h>
#include <deal.II/lac/precondition.h>
#include <deal.II/lac/solver_cg.h>
#include <deal.II/lac/sparse_matrix.h>
#include <deal.II/lac/vector.h>
#include <deal.II/numerics/data_out.h>
#include <deal.II/numerics/matrix_tools.h>
#include <deal.II/numerics/vector_tools.h>
In addition to the deal.II header files, we include the preCICE API in order to obtain access to preCICE specific functionality
#include <precice/precice.hpp>
#include <fstream>
#include <iostream>
Configuration parameters
We set up a simple hard-coded struct containing all names we need for external coupling. The struct includes the name of the preCICE configuration file as well as the name of the simulation participant, the name of the coupling mesh and the name of the exchanged data. The last three names you also find in the preCICE configuration file. For real application cases, these names are better handled by a parameter file.
struct CouplingParamters
{
const std::string config_file = "precice-config.xml";
const std::string participant_name = "laplace-solver";
const std::string mesh_name = "dealii-mesh";
const std::string read_data_name = "boundary-data";
};
The Adapter class
The Adapter class handles all functionalities to couple the deal.II solver code to other solvers with preCICE, i.e., data structures are set up and all relevant information is passed to preCICE.
template <int dim, typename ParameterClass>
class Adapter
{
public:
Adapter(const ParameterClass & parameters,
void
std::map<types::global_dof_index, double> &boundary_data,
void
read_data(double relative_read_time,
std::map<types::global_dof_index, double> &boundary_data);
void
advance(
const double computed_timestep_length);
MappingQ< dim, spacedim > MappingQGeneric
MappingQ< dim, spacedim > StaticMappingQ1< dim, spacedim >::mapping
void advance(std::tuple< I1, I2 > &t, const unsigned int n)
public precCICE solver interface
precice::Participant precice;
Boundary ID of the deal.II triangulation, associated with the coupling interface. The variable is defined in the constructor of this class and intentionally public so that it can be used during the grid generation and system assembly. The only thing, one needs to make sure is that this ID is unique for a particular triangulation.
const unsigned int dealii_boundary_interface_id;
private:
preCICE related initializations These variables are specified in and read from a parameter file, which is in this simple tutorial program the CouplingParameter struct already introduced in the beginning.
const std::string mesh_name;
const std::string read_data_name;
The node IDs are filled by preCICE during the initialization and associated to the spherical vertices we pass to preCICE
DoF IndexSet, containing relevant coupling DoF indices at the coupling boundary
Data containers which are passed to preCICE in an appropriate preCICE specific format
std::vector<int> interface_nodes_ids;
std::vector<double> read_data_buffer;
The MPI rank and total number of MPI ranks is required by preCICE when the Participant is created. Since this tutorial runs only in serial mode we define the variables manually in this class instead of using the regular MPI interface.
static constexpr int this_mpi_process = 0;
static constexpr int n_mpi_processes = 1;
Function to transform the obtained data from preCICE into an appropriate map for Dirichlet boundary conditions
void
format_precice_to_dealii(
std::map<types::global_dof_index, double> &boundary_data) const;
};
In the constructor of the Adapter class, we set up the preCICE Participant. We need to tell preCICE our name as participant of the simulation and the name of the preCICE configuration file. Both have already been specified in the CouplingParameter class above. Thus, we pass the class directly to the constructor and read out all relevant information. As a second parameter, we need to specify the boundary ID of our triangulation, which is associated with the coupling interface.
template <int dim, typename ParameterClass>
Adapter<dim, ParameterClass>::Adapter(
const ParameterClass & parameters,
: precice(parameters.participant_name,
parameters.config_file,
this_mpi_process,
n_mpi_processes)
, dealii_boundary_interface_id(deal_boundary_interface_id)
, mesh_name(parameters.mesh_name)
, read_data_name(parameters.read_data_name)
{}
This function initializes preCICE (e.g. establishes communication channels and allocates memory) and passes all relevant data to preCICE. For surface coupling, relevant data is in particular the location of the data points at the associated interface(s). The boundary_data is an empty map, which is filled by preCICE, i.e., information of the other participant. Throughout the system assembly, the map can directly be used in order to apply the Dirichlet boundary conditions in the linear system.
template <int dim, typename ParameterClass>
void
Adapter<dim, ParameterClass>::initialize(
std::map<types::global_dof_index, double> &boundary_data,
{
Assert(dim > 1, ExcNotImplemented());
#define Assert(cond, exc)
#define AssertDimension(dim1, dim2)
Afterwards, we extract the number of interface nodes and the coupling DoFs at the coupling interface from our deal.II solver via extract_boundary_dofs()
std::set<types::boundary_id> couplingBoundary;
couplingBoundary.insert(dealii_boundary_interface_id);
The ComponentMask() might be important in case we deal with vector valued problems, because vector valued problems have a DoF for each component.
The coupling DoFs are used to set up the boundary_data map. At the end, we associate here each DoF with a respective boundary value.
for (const auto i : coupling_dofs)
boundary_data[i] = 0.0;
Since we deal with a scalar problem, the number of DoFs at the particular interface corresponds to the number of interface nodes.
n_interface_nodes = coupling_dofs.n_elements();
std::cout << "\t Number of coupling nodes: " << n_interface_nodes
<< std::endl;
Now, we need to tell preCICE the coordinates of the interface nodes. Hence, we set up a std::vector to pass the node positions to preCICE. Each node is specified only once.
std::vector<double> interface_nodes_positions;
interface_nodes_positions.reserve(dim * n_interface_nodes);
Set up the appropriate size of the data container needed for data exchange. Here, we deal with a scalar problem, so that only a scalar value is read/written per interface node.
read_data_buffer.resize(n_interface_nodes);
The IDs are filled by preCICE during the initializations.
interface_nodes_ids.resize(n_interface_nodes);
The node location is obtained using map_dofs_to_support_points().
std::map<types::global_dof_index, Point<dim>> support_points;
support_points contains now the coordinates of all DoFs. In the next step, the relevant coordinates are extracted using the IndexSet with the extracted coupling_dofs.
for (const auto element : coupling_dofs)
for (int i = 0; i < dim; ++i)
interface_nodes_positions.push_back(support_points[element][i]);
Now we have all information to define the coupling mesh and pass the information to preCICE.
precice.setMeshVertices(mesh_name,
interface_nodes_positions,
interface_nodes_ids);
Then, we initialize preCICE internally calling the API function initialize()
precice.initialize();
}
template <int dim, typename ParameterClass>
void
Adapter<dim, ParameterClass>::read_data(
double relative_read_time,
std::map<types::global_dof_index, double> &boundary_data)
{
here, we obtain data, i.e. the boundary condition, from another participant. We have already vertex IDs and just need to convert our obtained data to the deal.II compatible 'boundary map' , which is done in the format_deal_to_precice function.
precice.readData(mesh_name,
read_data_name,
interface_nodes_ids,
relative_read_time,
read_data_buffer);
After receiving the coupling data in read_data_buffer, we convert it to the std::map boundary_data which is later needed in order to apply Dirichlet boundary conditions
format_precice_to_dealii(boundary_data);
}
The function advance() is called in the main time loop after the computation in each time step. Here, preCICE exchanges the coupling data internally and computes mappings as well as acceleration methods.
template <int dim, typename ParameterClass>
void
Adapter<dim, ParameterClass>::advance(const double computed_timestep_length)
{
We specify the computed time-step length and pass it to preCICE.
precice.advance(computed_timestep_length);
}
This function takes the std::vector obtained by preCICE in read_data_buffer and inserts the values to the right position in the boundary map used throughout our deal.II solver for Dirichlet boundary conditions. The function is only used internally in the Adapter class and not called in the solver itself. The order, in which preCICE sorts the data in the read_data_buffer vector is exactly the same as the order of the initially passed vertices coordinates.
template <int dim, typename ParameterClass>
void
Adapter<dim, ParameterClass>::format_precice_to_dealii(
std::map<types::global_dof_index, double> &boundary_data) const
{
We already stored the coupling DoF indices in the boundary_data map, so that we can simply iterate over all keys in the map.
auto dof_component = boundary_data.begin();
for (int i = 0; i < n_interface_nodes; ++i)
{
boundary_data[dof_component->first] = read_data_buffer[i];
++dof_component;
}
}
#define AssertIndexRange(index, range)
The solver class is essentially the same as in step-4. We only extend the stationary problem to a time-dependent problem and introduced the coupling. Comments are added at any point, where the workflow differs from step-4.
template <int dim>
class CoupledLaplaceProblem
{
public:
CoupledLaplaceProblem();
void
run();
private:
void
make_grid();
void
setup_system();
void
assemble_system();
void
solve();
void
output_results() const;
We allocate all structures required for the preCICE coupling: The map is used to apply Dirichlet boundary conditions and filled in the Adapter class with data from the other participant. The CouplingParameters hold the preCICE configuration as described above. The interface boundary ID is the ID associated to our coupling interface and needs to be specified, when we set up the Adapter class object, because we pass it directly to the Constructor of this class.
std::map<types::global_dof_index, double> boundary_data;
CouplingParamters parameters;
Adapter<dim, CouplingParamters> adapter;
The time-step size delta_t is the actual time-step size used for all computations. The preCICE time-step size is obtained by preCICE in order to ensure a synchronization at all coupling time steps. The solver time step-size is the desired time-step size of our individual solver. In more sophisticated computations, it might be determined adaptively. The time_step counter is just used for the time-step number.
double delta_t;
double precice_delta_t;
const double solver_delta_t = 0.1;
unsigned int time_step = 0;
};
template <int dim>
class RightHandSide :
public Function<dim>
{
public:
virtual double
value(
const Point<dim> &p,
const unsigned int component = 0)
const override;
};
template <int dim>
class BoundaryValues :
public Function<dim>
{
public:
virtual double
value(
const Point<dim> &p,
const unsigned int component = 0)
const override;
};
template <int dim>
double
const unsigned int ) const
{
double return_value = 0.0;
for (unsigned int i = 0; i < dim; ++i)
return_value += 4.0 *
std::pow(p(i), 4.0);
return return_value;
}
template <int dim>
double
const unsigned int ) const
{
return p.square();
}
template <int dim>
CoupledLaplaceProblem<dim>::CoupledLaplaceProblem()
: fe(1)
, dof_handler(triangulation)
, interface_boundary_id(1)
, adapter(parameters, interface_boundary_id)
{}
template <int dim>
void
CoupledLaplaceProblem<dim>::make_grid()
{
triangulation.refine_global(4);
for (const auto &cell : triangulation.active_cell_iterators())
for (const auto &face : cell->face_iterators())
{
const bool IsBlockVector< VectorType >::value
void hyper_cube(Triangulation< dim, spacedim > &tria, const double left=0., const double right=1., const bool colorize=false)
::VectorizedArray< Number, width > pow(const ::VectorizedArray< Number, width > &, const Number p)
We choose the boundary in positive x direction for the interface coupling.
if (face->at_boundary() && (face->center()[0] == 1))
face->set_boundary_id(interface_boundary_id);
}
std::cout << " Number of active cells: " << triangulation.n_active_cells()
<< std::endl
<< " Total number of cells: " << triangulation.n_cells()
<< std::endl;
}
template <int dim>
void
CoupledLaplaceProblem<dim>::setup_system()
{
dof_handler.distribute_dofs(fe);
std::cout << " Number of degrees of freedom: " << dof_handler.n_dofs()
<< std::endl;
sparsity_pattern.copy_from(dsp);
system_matrix.reinit(sparsity_pattern);
solution.reinit(dof_handler.n_dofs());
old_solution.reinit(dof_handler.n_dofs());
system_rhs.reinit(dof_handler.n_dofs());
}
template <int dim>
void
CoupledLaplaceProblem<dim>::assemble_system()
{
void make_sparsity_pattern(const DoFHandler< dim, spacedim > &dof_handler, SparsityPatternBase &sparsity_pattern, const AffineConstraints< number > &constraints={}, const bool keep_constrained_dofs=true, const types::subdomain_id subdomain_id=numbers::invalid_subdomain_id)
Reset global structures
system_rhs = 0;
system_matrix = 0;
Update old solution values
old_solution = solution;
RightHandSide<dim> right_hand_side;
quadrature_formula,
const unsigned int dofs_per_cell = fe.n_dofs_per_cell();
std::vector<types::global_dof_index> local_dof_indices(dofs_per_cell);
@ update_values
Shape function values.
@ update_JxW_values
Transformed quadrature weights.
@ update_gradients
Shape function gradients.
@ update_quadrature_points
Transformed quadrature points.
The solution values from previous time steps are stored for each quadrature point
std::vector<double> local_values_old_solution(fe_values.n_quadrature_points);
for (const auto &cell : dof_handler.active_cell_iterators())
{
fe_values.reinit(cell);
cell_matrix = 0;
cell_rhs = 0;
Get the local values from the ‘fe_values’ object
fe_values.get_function_values(old_solution, local_values_old_solution);
The system matrix contains additionally a mass matrix due to the time discretization. The RHS has contributions from the old solution values.
for (const unsigned int q_index : fe_values.quadrature_point_indices())
for (const unsigned int i : fe_values.dof_indices())
{
for (const unsigned int j : fe_values.dof_indices())
cell_matrix(i, j) +=
((fe_values.shape_value(i, q_index) *
fe_values.shape_value(j, q_index)) +
(delta_t *
fe_values.shape_grad(i, q_index) *
fe_values.shape_grad(j, q_index))) *
fe_values.JxW(q_index);
const auto x_q = fe_values.quadrature_point(q_index);
const auto &local_value = local_values_old_solution[q_index];
cell_rhs(i) += ((delta_t *
fe_values.shape_value(i, q_index) *
right_hand_side.value(x_q)) +
fe_values.shape_value(i, q_index) *
local_value) *
fe_values.JxW(q_index);
}
Copy local to global
cell->get_dof_indices(local_dof_indices);
for (const unsigned int i : fe_values.dof_indices())
{
for (const unsigned int j : fe_values.dof_indices())
system_matrix.add(local_dof_indices[i],
local_dof_indices[j],
cell_matrix(i, j));
system_rhs(local_dof_indices[i]) += cell_rhs(i);
}
}
{
At first, we apply the Dirichlet boundary condition from step-4, as usual.
std::map<types::global_dof_index, double> boundary_values;
0,
BoundaryValues<dim>(),
boundary_values);
system_matrix,
solution,
system_rhs);
}
{
Afterwards, we apply the coupling boundary condition. The boundary_data has already been filled by preCICE.
system_matrix,
solution,
system_rhs);
}
}
template <int dim>
void
CoupledLaplaceProblem<dim>::solve()
{
std::cout << " " << solver_control.last_step()
<< " CG iterations needed to obtain convergence." << std::endl;
}
template <int dim>
void
CoupledLaplaceProblem<dim>::output_results() const
{
data_out.add_data_vector(solution, "solution");
std::ofstream output("solution-" + std::to_string(time_step) + ".vtk");
data_out.write_vtk(output);
}
template <int dim>
void
CoupledLaplaceProblem<dim>::run()
{
std::cout << "Solving problem in " << dim << " space dimensions."
<< std::endl;
make_grid();
setup_system();
void attach_dof_handler(const DoFHandler< dim, spacedim > &)
After we set up the system, we initialize preCICE and the adapter using the functionalities of the Adapter.
adapter.initialize(dof_handler, boundary_data,
mapping);
preCICE steers the coupled simulation: isCouplingOngoing is used to synchronize the end of the simulation with the coupling partner
while (adapter.precice.isCouplingOngoing())
{
The time step number is solely used to generate unique output files
preCICE returns the maximum admissible time-step size, which needs to be compared to our desired solver time-step size.
precice_delta_t = adapter.precice.getMaxTimeStepSize();
delta_t =
std::min(precice_delta_t, solver_delta_t);
::VectorizedArray< Number, width > min(const ::VectorizedArray< Number, width > &, const ::VectorizedArray< Number, width > &)
Next we read data. Since we use a fully backward Euler method, we want the data to be associated to the end of the current time-step (delta_t) Time-interpolation methods in preCICE allow to get readData at any point in time, if the coupling scheme allows it
adapter.read_data(delta_t, boundary_data);
In the time loop, we assemble the coupled system and solve it as usual.
assemble_system();
solve();
After we solved the system, we advance the coupling to the next time level. In a bi-directional coupled simulation, we would pass our calculated data to preCICE
adapter.advance(delta_t);
Write an output file if the time step is completed. In case of an implicit coupling, where individual time steps are computed more than once, the function isTimeWindowCompleted prevents unnecessary result writing. For this simple tutorial configuration (explicit coupling), the function returns always true.
if (adapter.precice.isTimeWindowComplete())
output_results();
}
}
int
main()
{
CoupledLaplaceProblem<2> laplace_problem;
laplace_problem.run();
return 0;
}
Annotated version of fancy_boundary_condition.cc
This program does not use any deal.II functionality and depends only on preCICE and the standard libraries.
#include <precice/precice.hpp>
#include <iostream>
#include <sstream>
#include <vector>
The program computes a time-varying parabolic boundary condition, which is passed to preCICE and serves as Dirichlet boundary condition for the other coupling participant.
Function to generate boundary values in each time step
void
define_boundary_values(std::vector<double> &boundary_data,
const double time,
const double end_time)
{
Scale the current time value
const double relative_time = time / end_time;
Define the amplitude. Values run from -0.5 to 0.5
const double amplitude = (relative_time - 0.5);
Specify the actual data we want to pass to the other participant. Here, we choose a parabola with boundary values 2 in order to enforce continuity to adjacent boundaries.
const double n_elements = boundary_data.size();
const double right_zero = boundary_data.size() - 1;
const double left_zero = 0;
const double offset = 2;
for (uint i = 0; i < n_elements; ++i)
boundary_data[i] =
-amplitude * ((i - left_zero) * (i - right_zero)) + offset;
}
int
main()
{
std::cout << "Boundary participant: starting... \n";
Configuration
const std::string configFileName("precice-config.xml");
const std::string solverName("boundary-participant");
const std::string meshName("boundary-mesh");
const std::string dataWriteName("boundary-data");
Adjust to MPI rank and size for parallel computation
const int commRank = 0;
const int commSize = 1;
precice::Participant precice(solverName, configFileName, commRank, commSize);
const int dimensions = precice.getMeshDimensions(meshName);
const int numberOfVertices = 6;
Set up data structures
std::vector<double> writeData(numberOfVertices);
std::vector<int> vertexIDs(numberOfVertices);
std::vector<double> vertices(numberOfVertices * dimensions);
Define a boundary mesh
std::cout << "Boundary participant: defining boundary mesh \n";
const double length = 2;
const double xCoord = 1;
const double deltaY = length / (numberOfVertices - 1);
for (int i = 0; i < numberOfVertices; ++i)
for (int j = 0; j < dimensions; ++j)
{
const unsigned int index = dimensions * i + j;
The x-coordinate is always 1, i.e., the boundary is parallel to the y-axis. The y-coordinate is descending from 1 to -1.
if (j == 0)
vertices[index] = xCoord;
else
vertices[index] = 1 - deltaY * i;
}
Pass the vertices to preCICE
precice.setMeshVertices(meshName, vertices, vertexIDs);
Variables for the time
const double end_time = 1;
double time = 0;
Not used in the configuration by default
if (precice.requiresInitialData())
{
std::cout << "Boundary participant: writing initial data \n";
define_boundary_values(writeData, time, end_time);
precice.writeData(meshName, dataWriteName, vertexIDs, writeData);
}
initialize the Participant
Start time loop
while (precice.isCouplingOngoing())
{
double dt = precice.getMaxTimeStepSize();
time += dt;
Generate new boundary data
define_boundary_values(writeData, time, end_time);
std::cout << "Boundary participant: writing coupling data \n";
precice.writeData(meshName, dataWriteName, vertexIDs, writeData);
std::cout << "Boundary participant: advancing in time\n";
precice.advance(dt);
}
std::cout << "Boundary participant: closing...\n";
return 0;
}