A configurable application for simple LBM simulations
In this tutorial, we finally built a fully functional lattice Boltzmann application with the following features:
Since almost all the functionality is already included in functions and classes in the
lbm module of waLBerla, there is not much code that we actually have to implement ourselves.
This application will be fully configurable by a parameter file so that no recompilation is necessary if some parameters or the geometry has to be changed. waLBerla already has a mechanism for parsing configuration files in a specific format. When an application is started and walberla::Environment is used, the first parameter is interpreted as the path to a configuration file. This configuration file is parsed automatically and available as a config::Config object through Environment::config().
Here is an excerpt of a configuration file:
A configuration file consists of key-value pairs hierarchically organized in blocks, denoted by curly brackets. Keys have to be strings, values have to be parseable by the istream::operator>>. So everything that can be read in by std::cin can also be a value. Also, true/false/on/off/0/1 can all be interpreted as bool.
The following code snippet extracts parameters from a parsed configuration file:
The getParameter() calls can fail if no value for the key can be found and no default value was given (see "timesteps" key). The call also fails if the value is not convertible to the requested type.
Options from the configuration file can be used to directly create a StructuredBlockStorage. In previous tutorials this was done manually, here we use a helper function:
This function assumes that a "DomainSetup" block exists. For a detailed description of possible configuration parameters, see blockforest::createUniformBlockGridFromConfig().
A lattice model defines the basic ingredients needed for an LBM simulation:
For a more detailed description of lattice models, see lbm::LatticeModelBase
The flag field is added to the block storage using the field::addFlagFieldToStorage function, which is similar to the field::addToStorage function already described in previous tutorials. For the lbm::PdfField, a special add function exists: lbm::addPdfFieldToStorage, since additional parameters like the lattice model, the initial velocity, and the initial density are required.
waLBerla comes with a set of lattice Boltzmann boundary conditions. They can be found in folder lbm/boundary. All implement a common concept. To implement a custom boundary implement this concept which is described in detail here: boundary::Boundary.
Boundary conditions are grouped together in a class called boundary::BoundaryHandling. This handling class uses a FlagField to store which boundary condition is applied in which cell. For each boundary condition, one flag (bit) is reserved. Two more bits are automatically added by the handler:
To add or remove a boundary for a certain cell, do not modify the field::FlagField directly! Instead use the methods of boundary::BoundaryHandling. Otherwise the
near boundary and
domain flags are not maintained correctly!
The boundary handling is a heavily templated part of waLBerla since it contains performance critical code and at the same time has to be very flexible, i.e., it should be easy to write new boundary conditions. By using template concepts (compile-time polymorphism) instead of inheritance (runtime polymorphism) the compiler is able to resolve all function calls at compile time and can do optimizations like function inlining. To make setting up a boundary handling easier, a convenience factory class lbm::DefaultBoundaryHandlingFactory exists that creates a boundary::BoundaryHandling with six often used boundary conditions. Together with the
near boundary and
domain flag, exactly eight bits are used, so a uint8_t is enough for the FlagField.
Now have a look at the documentation of lbm::DefaultBoundaryHandlingFactory. There you can find a table of all boundary conditions that are part of the created boundary::BoundaryHandling. The listed FlagUIDs are important later on to specify which boundary should be set in which cell. You might wonder why there are two velocity and two pressure boundary conditions. With a single lbm::SimplePressure or lbm::SimpleUBB boundary, only one pressure or velocity value can be set. To prescribe one velocity at one position and another velocity at a different position, two SimpleUBB's are needed. If many different velocity values are required (for example for a parabolic inflow profile), it makes sense to use lbm::UBB instead of lbm::SimpleUBB.
For this tutorial, the boundary conditions provided by the framework are enough and we create a BoundaryHandling object using the lbm::DefaultBoundaryHandlingFactory class. The values for the two pressure and velocity boundary conditions are read from the configuration file. The function addBoundaryHandlingToStorage() adds a boundary handler to every block:
To specify where boundaries are located, we could now iterate all blocks, retrieve the boundary handler which was added as block data, and use its member functions like boundary::BoundaryHandling::forceFlag() to setup the domain.
However, there exists a more comfortable way to setup the domain using the geometry module. This module provides functionality to read domain information from images, voxel files, meshes, or specify boundaries at the domain border. Have a look at the files called "geometry/initializer/BoundaryFrom*.h". Each of these so called initializers can also be setup using a config::Config::Block. For information which parameters are required, have a look at the documentation of these initializers.
Here a convenience function is used that receives the boundary configuration block
boundariesConfig and makes use of all these initializers:
In order to know what to put in the configuration file for setting boundaries via this convenience function, please have a look at the documentation of walberla::geometry::initBoundaryHandling(). After all boundary cells have been marked, the remaining cells are tagged with the "domain" flag, i.e. as cells that should be updated by the LBM kernel.
Having completed the domain setup, the next step is to add all the necessary steps/algorithms to the time loop:
For the communication, a communication scheme is created as already described in the previous tutorial. However, this time, we use lbm::PdfFieldPackInfo instead of field::FieldPackInfo:
lbm::PdfFieldPackInfo uses the additional knowledge that the field we want to communicate is a PdfField. If you use lbm::PdfFieldPackInfo, not all PDF values are communicated for each cell at the block border, but only those PDF values that are about to stream from the ghost layer into the interior part of the field. This greatly reduces the amount of data that needs to be transfered! For our simulation, this is okay. But be aware: If you are using algorithms that rely on a complete set of valid PDF values also in the ghost layers, you cannot use lbm::PdfFieldPackInfo! If you have to communicate all PDF values to the ghost layers of neighboring blocks, you have to use a normal field::FieldPackInfo. The corresponding code would look like this:
The communication is set up to always run right before the boundary sweep. The boundary sweep can be obtained from the boundary handling factory:
After that, the LBM sweep is added. The right LBM algorithm is chosen by the lattice model which we already set up. Behind the scenes, an incompressible SRT sweep is selected via template specializations. Since lbm::makeCellwiseSweep returns a shared pointer, we use makeSharedSweep in order to wrap the shared pointer into an object that can be passed to the time loop.
After every time step, we use the field::StabilityChecker in order to check if there are any NaNs stored in the field. If NaNs are detected, the simulation is terminated and VTK data that indicates the position of these NaNs is written to file. The StabilityChecker instance can be controlled via the configuration file, for more information see Stability Checker. Since field::makeStabilityChecker returns a shared pointer, we use makeSharedFunctor in order to wrap the shared pointer into an object that can be passed to the time loop.
Additionally, a small functor is scheduled that periodically prints the estimated remaining time of the simulation:
Also, the usage of VTK output is enabled by lbm::VTKOutput. Please refer to the documentation of lbm::VTKOutput in order to learn the names of all VTK writers, filters, and "before" functions that are available when using this default VTK output for LBM. You need to know these names in order to select these writers/filters/functions via the configuration file.
In order to know how to use the
VTK block in the configuration file for setting up VTK output, please have a look at: VTK via Configuration File
Finally, the simulation is run by either calling the "run" function of the time loop or using the GUI.
Now you can build the tutorial application, play around with the configuration file, and try loading different obstacles.