diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 77f6a476bd055fdb6db221ffed5571d80627d119..a924b0fb33ec4aed389d23f8039fc5fbbde170c0 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -2082,7 +2082,7 @@ msvc-14.1_Hybrid_dbg:
    only:
       variables:
          - $ENABLE_NIGHTLY_BUILDS
-   
+
 
 
 msvc-14.1_Hybrid_sp_dbg:
@@ -2095,7 +2095,7 @@ msvc-14.1_Hybrid_sp_dbg:
    only:
       variables:
          - $ENABLE_NIGHTLY_BUILDS
-   
+
 
 
 msvc-14.1_Hybrid:
@@ -2106,7 +2106,7 @@ msvc-14.1_Hybrid:
    only:
       variables:
          - $ENABLE_NIGHTLY_BUILDS
-   
+
 
 
 msvc-14.1_serial_dbg:
@@ -2120,7 +2120,7 @@ msvc-14.1_serial_dbg:
    only:
       variables:
          - $ENABLE_NIGHTLY_BUILDS
-   
+
 
 
 msvc-14.1_serial:
@@ -2133,7 +2133,7 @@ msvc-14.1_serial:
    only:
       variables:
          - $ENABLE_NIGHTLY_BUILDS
-   
+
 
 
 msvc-14.1_mpionly_dbg:
@@ -2146,7 +2146,7 @@ msvc-14.1_mpionly_dbg:
    only:
       variables:
          - $ENABLE_NIGHTLY_BUILDS
-   
+
 
 
 msvc-14.1_mpionly:
@@ -2158,7 +2158,7 @@ msvc-14.1_mpionly:
    only:
       variables:
          - $ENABLE_NIGHTLY_BUILDS
-   
+
 
 
 msvc-14.2_Hybrid_dbg:
diff --git a/apps/showcases/Antidunes/Antidunes.cpp b/apps/showcases/Antidunes/Antidunes.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..5ddc9cfa174373e58bb5c01d8146daf704f4a26e
--- /dev/null
+++ b/apps/showcases/Antidunes/Antidunes.cpp
@@ -0,0 +1,1413 @@
+//======================================================================================================================
+//
+//  This file is part of waLBerla. waLBerla is free software: you can
+//  redistribute it and/or modify it under the terms of the GNU General Public
+//  License as published by the Free Software Foundation, either version 3 of
+//  the License, or (at your option) any later version.
+//
+//  waLBerla is distributed in the hope that it will be useful, but WITHOUT
+//  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+//  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+//  for more details.
+//
+//  You should have received a copy of the GNU General Public License along
+//  with waLBerla (see COPYING.txt). If not, see <http://www.gnu.org/licenses/>.
+//
+//! \file AntiDunes.cpp
+//! \author Samuel Kemmler <samuel.kemmler@fau.de>
+//! \author Jonas Plewinski <jonas.plewinski@fau.de>
+//! \author Christoph Rettinger <christoph.rettinger@fau.de>
+//! \author Christoph Schwarzmeier <christoph.schwarzmeier@fau.de>
+//
+// This showcase simulates antidunes, i.e., particulate dunes that travel in opposite stream-wise direction.
+//======================================================================================================================
+
+#include "blockforest/Initialization.h"
+
+#include "core/Environment.h"
+#include "core/SharedFunctor.h"
+#include "core/timing/RemainingTimeLogger.h"
+
+#include "domain_decomposition/SharedSweep.h"
+
+#include "field/StabilityChecker.h"
+
+#include "lbm/PerformanceLogger.h"
+#include "lbm/field/AddToStorage.h"
+#include "lbm/free_surface/BlockStateDetectorSweep.h"
+#include "lbm/free_surface/SurfaceMeshWriter.h"
+#include "lbm/free_surface/TotalMassComputer.h"
+#include "lbm/free_surface/VtkWriter.h"
+#include "lbm/free_surface/dynamics/CellConversionSweep.h"
+#include "lbm/free_surface/dynamics/ConversionFlagsResetSweep.h"
+#include "lbm/free_surface/dynamics/ExcessMassDistributionSweep.h"
+#include "lbm/free_surface/dynamics/PdfRefillingSweep.h"
+#include "lbm/free_surface/dynamics/StreamReconstructAdvectSweep.h"
+#include "lbm/free_surface/surface_geometry/CurvatureSweep.h"
+#include "lbm/free_surface/surface_geometry/NormalSweep.h"
+#include "lbm/free_surface/surface_geometry/SmoothingSweep.h"
+#include "lbm/free_surface/surface_geometry/Utility.h"
+
+#include "lbm_mesapd_coupling/mapping/ParticleMapping.h"
+#include "lbm_mesapd_coupling/momentum_exchange_method/MovingParticleMapping.h"
+#include "lbm_mesapd_coupling/momentum_exchange_method/reconstruction/PdfReconstructionManager.h"
+#include "lbm_mesapd_coupling/momentum_exchange_method/reconstruction/Reconstructor.h"
+#include "lbm_mesapd_coupling/utility/AddHydrodynamicInteractionKernel.h"
+#include "lbm_mesapd_coupling/utility/AverageHydrodynamicForceTorqueKernel.h"
+#include "lbm_mesapd_coupling/utility/InitializeHydrodynamicForceTorqueForAveragingKernel.h"
+#include "lbm_mesapd_coupling/utility/LubricationCorrectionKernel.h"
+#include "lbm_mesapd_coupling/utility/ParticleSelector.h"
+#include "lbm_mesapd_coupling/utility/ResetHydrodynamicForceTorqueKernel.h"
+
+#include "mesa_pd/collision_detection/AnalyticContactDetection.h"
+#include "mesa_pd/data/ParticleAccessorWithBaseShape.h"
+#include "mesa_pd/data/ParticleStorage.h"
+#include "mesa_pd/data/shape/Sphere.h"
+#include "mesa_pd/domain/BlockForestDataHandling.h"
+#include "mesa_pd/domain/BlockForestDomain.h"
+#include "mesa_pd/kernel/DoubleCast.h"
+#include "mesa_pd/kernel/LinearSpringDashpot.h"
+#include "mesa_pd/kernel/ParticleSelector.h"
+#include "mesa_pd/kernel/VelocityVerlet.h"
+#include "mesa_pd/mpi/ClearGhostOwnerSync.h"
+#include "mesa_pd/mpi/ClearNextNeighborSync.h"
+#include "mesa_pd/mpi/ContactFilter.h"
+#include "mesa_pd/mpi/ReduceContactHistory.h"
+#include "mesa_pd/mpi/ReduceProperty.h"
+#include "mesa_pd/mpi/SyncGhostOwners.h"
+#include "mesa_pd/mpi/SyncNextNeighbors.h"
+#include "mesa_pd/mpi/notifications/ForceTorqueNotification.h"
+#include "mesa_pd/mpi/notifications/HydrodynamicForceTorqueNotification.h"
+#include "mesa_pd/vtk/ParticleVtkOutput.h"
+
+#include "vtk/VTKOutput.h"
+
+#include <core/waLBerlaBuildInfo.h>
+
+#include "AntidunesBoundaryHandling.h"
+#include "AntidunesLatticeModel.h"
+#include "PIDController.h"
+#include "Utility.h"
+
+namespace walberla
+{
+namespace antidunes
+{
+using ScalarField_T = GhostLayerField< real_t, 1 >;
+using VectorField_T = GhostLayerField< Vector3< real_t >, 1 >;
+
+using LatticeModel_T        = lbm::AntidunesLatticeModel;
+using LatticeModelStencil_T = LatticeModel_T::Stencil;
+using PdfField_T            = lbm::PdfField< LatticeModel_T >;
+using PdfCommunication_T    = blockforest::SimpleCommunication< LatticeModelStencil_T >;
+
+// the geometry computations in SurfaceGeometryHandler require meaningful values in the ghost layers in corner
+// directions (flag field and fill level field); this holds, even if the lattice model uses a D3Q19 stencil
+using CommunicationStencil_T =
+   typename std::conditional< LatticeModel_T::Stencil::D == uint_t(2), stencil::D2Q9, stencil::D3Q27 >::type;
+using Communication_T = blockforest::SimpleCommunication< CommunicationStencil_T >;
+
+using ParticleAccessor_T = mesa_pd::data::ParticleAccessorWithBaseShape;
+
+using flag_t      = uint32_t;
+using FlagField_T = FlagField< flag_t >;
+using AntidunesBoundaryHandling_T =
+   free_surface::AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >;
+
+using StateSweep = walberla::free_surface::BlockStateDetectorSweep< FlagField_T >;
+
+const FlagUID FormerMO_Flag("former moving obstacle");
+
+// empty sweep required for using selectors (e.g. StateSweep::fullFreeSurface)
+struct emptySweep
+{
+   void operator()(IBlock*) {}
+};
+
+// data handling for loading a field of type ScalarField_T from file
+template< typename ScalarField_T >
+class ScalarFieldHandling : public field::BlockDataHandling< ScalarField_T >
+{
+ public:
+   ScalarFieldHandling(const weak_ptr< StructuredBlockStorage >& blocks, uint_t numberGhostLayer)
+      : blocks_(blocks), numberGhostLayer_(numberGhostLayer)
+   {}
+
+ protected:
+   ScalarField_T* allocate(IBlock* const block) override { return allocateDispatch(block); }
+
+   ScalarField_T* reallocate(IBlock* const block) override { return allocateDispatch(block); }
+
+ private:
+   weak_ptr< StructuredBlockStorage > blocks_;
+   uint_t numberGhostLayer_;
+
+   ScalarField_T* allocateDispatch(IBlock* const block)
+   {
+      WALBERLA_ASSERT_NOT_NULLPTR(block);
+
+      auto blocks = blocks_.lock();
+      WALBERLA_CHECK_NOT_NULLPTR(blocks);
+
+      return new ScalarField_T(blocks->getNumberOfXCells(*block), blocks->getNumberOfYCells(*block),
+                               blocks->getNumberOfZCells(*block), numberGhostLayer_, real_c(0), field::fzyx);
+   }
+}; // class ScalarFieldHandling
+
+// function describing the global initialization profile
+inline real_t initializationProfile(real_t x, real_t amplitude, real_t offset, real_t wavelength)
+{
+   return amplitude * std::cos(x / wavelength * real_c(2) * math::pi + math::pi) + offset;
+}
+
+real_t getHydrostaticDensity(real_t height, real_t referenceHeight, real_t gravitationalAcceleration)
+{
+   return real_c(1) + real_c(3) * gravitationalAcceleration * (height - referenceHeight);
+}
+
+void initializePoiseuilleProfile(StructuredBlockForest& forest, const BlockDataID& pdfFieldID,
+                                 const ConstBlockDataID& fillFieldID, const real_t& averageBedHeight,
+                                 const real_t& averageFluidHeight, const real_t& forcingX, const real_t& viscosity,
+                                 real_t amplitude, real_t wavelength)
+{
+   WALBERLA_LOG_INFO_ON_ROOT("Initializing Poiseuille velocity profile");
+
+   const real_t rho = real_t(1);
+
+   for (auto blockIt = forest.begin(); blockIt != forest.end(); ++blockIt)
+   {
+      auto pdfField                        = blockIt->getData< PdfField_T >(pdfFieldID);
+      const ScalarField_T* const fillField = blockIt->getData< const ScalarField_T >(fillFieldID);
+
+      WALBERLA_FOR_ALL_CELLS_XYZ(
+         pdfField, const Vector3< real_t > coord = forest.getBlockLocalCellCenter(*blockIt, Cell(x, y, z));
+
+         Vector3< real_t > velocity(real_t(0));
+
+         auto localBedHeight = initializationProfile(coord[0], amplitude, averageBedHeight, wavelength);
+         auto heightAboveBed = coord[2] - localBedHeight;
+
+         const real_t fillLevel = fillField->get(x, y, z);
+
+         if (heightAboveBed >= 0_r && fillLevel > 0_r) {
+            velocity[0] =
+               forcingX / (real_t(2) * viscosity) * heightAboveBed * (2_r * averageFluidHeight - heightAboveBed);
+         } pdfField->setToEquilibrium(x, y, z, velocity, rho);)
+   }
+}
+
+/***********************************************************************************************************************
+ * Initialize the hydrostatic pressure in the direction in which a force is acting in ALL cells (regardless of a cell's
+ * flag). The velocity remains unchanged.
+ *
+ * The force vector must have only one component, i.e., the direction of the force can only be in x-, y- or z-axis.
+ * The variable fluidHeight determines the height at which the density is equal to reference density (=1).
+ **********************************************************************************************************************/
+template< typename PdfField_T >
+void initHydrostaticPressure(const std::weak_ptr< StructuredBlockForest >& blockForestPtr,
+                             const BlockDataID& pdfFieldID,
+                             std::function< real_t(const Vector3< real_t >&) > hydrostaticDensityFct)
+{
+   WALBERLA_LOG_INFO_ON_ROOT("Initializing hydrostatic pressure");
+
+   const auto blockForest = blockForestPtr.lock();
+   WALBERLA_CHECK_NOT_NULLPTR(blockForest);
+
+   for (auto blockIt = blockForest->begin(); blockIt != blockForest->end(); ++blockIt)
+   {
+      PdfField_T* const pdfField = blockIt->getData< PdfField_T >(pdfFieldID);
+
+      CellInterval local = pdfField->xyzSizeWithGhostLayer(); // block-, i.e., process-local cell interval
+
+      for (auto cellIt = local.begin(); cellIt != local.end(); ++cellIt)
+      {
+         // initialize the (hydrostatic) pressure, i.e., LBM density
+         // Bernoulli: p = p0 + density * gravity * height
+         // => LBM (density=1): rho = rho0 + gravity * height = 1 + 1/cs^2 * g * h = 1 + 3 * g * h
+         // shift global cell by 0.5 since density is set for cell center
+
+         Vector3< real_t > cellCenter = blockForest->getBlockLocalCellCenter(*blockIt, *cellIt);
+         const real_t rho             = hydrostaticDensityFct(cellCenter);
+
+         const Vector3< real_t > velocity = pdfField->getVelocity(*cellIt);
+
+         pdfField->setDensityAndVelocity(*cellIt, velocity, rho);
+      }
+   }
+}
+
+template< typename FreeSurfaceBoundaryHandling_T, typename PdfField_T, typename FlagField_T >
+class MeanVelocityComputer
+{
+ public:
+   MeanVelocityComputer(const std::weak_ptr< const StructuredBlockForest >& blockForest,
+                        const std::weak_ptr< const FreeSurfaceBoundaryHandling_T >& freeSurfaceBoundaryHandling,
+                        const ConstBlockDataID& pdfFieldID, const std::shared_ptr< Vector3< real_t > >& meanVelocity,
+                        real_t averagingFactor)
+      : blockForest_(blockForest), freeSurfaceBoundaryHandling_(freeSurfaceBoundaryHandling), pdfFieldID_(pdfFieldID),
+        meanVelocity_(meanVelocity), averagingFactor_(averagingFactor)
+   {}
+
+   void operator()()
+   {
+      auto blockForest = blockForest_.lock();
+      WALBERLA_CHECK_NOT_NULLPTR(blockForest);
+
+      auto freeSurfaceBoundaryHandling = freeSurfaceBoundaryHandling_.lock();
+      WALBERLA_CHECK_NOT_NULLPTR(freeSurfaceBoundaryHandling);
+
+      getMeanVelocity(blockForest, freeSurfaceBoundaryHandling);
+   }
+
+   void getMeanVelocity(const std::shared_ptr< const StructuredBlockForest >& blockForest,
+                        const std::shared_ptr< const FreeSurfaceBoundaryHandling_T >& freeSurfaceBoundaryHandling)
+   {
+      const BlockDataID flagFieldID = freeSurfaceBoundaryHandling->getFlagFieldID();
+      const typename FreeSurfaceBoundaryHandling_T::FlagInfo_T& flagInfo = freeSurfaceBoundaryHandling->getFlagInfo();
+
+      // use separate variables for the velocity in each direction; syntax meanVelocity[0] does not work in OMP-macro
+      real_t meanVelocityX = real_c(0);
+      real_t meanVelocityY = real_c(0);
+      real_t meanVelocityZ = real_c(0);
+
+      for (auto blockIt = blockForest->begin(); blockIt != blockForest->end(); ++blockIt)
+      {
+         const FlagField_T* const flagField = blockIt->template getData< const FlagField_T >(flagFieldID);
+         const PdfField_T* const pdfField   = blockIt->template getData< const PdfField_T >(pdfFieldID_);
+
+         WALBERLA_FOR_ALL_CELLS_OMP(flagFieldIt, flagField, pdfFieldIt, pdfField,
+                                    omp parallel for schedule(static) reduction(+:meanVelocityX)
+                                       reduction(+:meanVelocityY) reduction(+:meanVelocityZ),
+                                    {
+            if (flagInfo.isLiquid(flagFieldIt) || flagInfo.isInterface(flagFieldIt))
+            {
+               const Vector3< real_t > velocity = pdfField->getVelocity(pdfFieldIt.cell());
+
+               meanVelocityX += velocity[0];
+               meanVelocityY += velocity[1];
+               meanVelocityZ += velocity[2];
+            }
+                                    }) // WALBERLA_FOR_ALL_CELLS_OMP
+      }
+
+      Vector3< real_t > meanVelocity(meanVelocityX, meanVelocityY, meanVelocityZ);
+      mpi::allReduceInplace< real_t >(meanVelocity, mpi::SUM);
+
+      meanVelocity *= averagingFactor_;
+      *meanVelocity_ = meanVelocity;
+   };
+
+ private:
+   std::weak_ptr< const StructuredBlockForest > blockForest_;
+   std::weak_ptr< const FreeSurfaceBoundaryHandling_T > freeSurfaceBoundaryHandling_;
+
+   const ConstBlockDataID pdfFieldID_;
+
+   std::shared_ptr< Vector3< real_t > > meanVelocity_;
+   real_t averagingFactor_;
+}; // class MeanVelocityComputer
+
+class ForcingAdjuster
+{
+ public:
+   ForcingAdjuster(const shared_ptr< StructuredBlockStorage >& blocks, const BlockDataID& pdfFieldID,
+                   real_t targetVelocity, real_t externalForcing, real_t proportionalGain, real_t derivativeGain,
+                   real_t integralGain, real_t maxRamp, real_t minActuatingVariable, real_t maxActuatingVariable)
+      : blocks_(blocks), pdfFieldID_(pdfFieldID), currentExternalForcing_(externalForcing),
+        pid_(targetVelocity, externalForcing, proportionalGain, derivativeGain, integralGain, maxRamp,
+             minActuatingVariable, maxActuatingVariable)
+   {
+      WALBERLA_LOG_INFO_ON_ROOT("Creating PID controller with pg = " << pid_.getProportionalGain()
+                                                                     << ", dg = " << pid_.getDerivateGain()
+                                                                     << ", ig = " << pid_.getIntegralGain());
+   }
+
+   void operator()(const real_t currentMeanVelocity)
+   {
+      // compute new forcing value on root (since flow rate only known on root)
+      WALBERLA_ROOT_SECTION()
+      {
+         real_t newExternalForcing = pid_.update(currentMeanVelocity);
+         currentExternalForcing_   = newExternalForcing;
+      }
+
+      // send updated external forcing to all other processes
+      mpi::broadcastObject(currentExternalForcing_);
+
+      for (auto block = blocks_->begin(); block != blocks_->end(); ++block)
+      {
+         // get the field data out of the block
+         auto pdf                     = block->getData< PdfField_T >(pdfFieldID_);
+         pdf->latticeModel().force_0_ = currentExternalForcing_;
+      }
+   }
+
+   real_t getExternalForcing() { return currentExternalForcing_; }
+   void storePIDSnapshot(std::string filename)
+   {
+      WALBERLA_ROOT_SECTION() { pid_.writeStateToFile(filename); }
+   }
+   void loadPIDSnapshot(std::string filename) { pid_.readStateFromFile(filename); }
+
+ private:
+   shared_ptr< StructuredBlockStorage > blocks_;
+   BlockDataID pdfFieldID_;
+
+   real_t currentExternalForcing_;
+   PIDController pid_;
+}; // ForcingAdjuster
+
+int main(int argc, char** argv)
+{
+   Environment walberlaEnv(argc, argv);
+
+   WALBERLA_LOG_INFO_ON_ROOT("waLBerla Revision: " << std::string(WALBERLA_GIT_SHA1).substr(0, 8))
+
+   if (argc < 2) { WALBERLA_ABORT("Please specify a parameter file as input argument.") }
+
+   WALBERLA_LOG_DEVEL_ON_ROOT("Using generated lattice model.");
+   auto configPtr = walberlaEnv.config();
+
+   // print content of parameter file
+   WALBERLA_LOG_INFO_ON_ROOT(*configPtr);
+
+   WALBERLA_ROOT_SECTION()
+   {
+      std::ofstream file;
+      file.open("parameterConfiguration.cfg");
+      file << *configPtr;
+      file.close();
+   }
+
+   // get block forest parameters from parameter file
+   auto blockForestParameters            = configPtr->getOneBlock("BlockForestParameters");
+   const Vector3< uint_t > cellsPerBlock = blockForestParameters.getParameter< Vector3< uint_t > >("cellsPerBlock");
+   const Vector3< bool > periodicity     = blockForestParameters.getParameter< Vector3< bool > >("periodicity");
+   const bool loadSnapshot               = blockForestParameters.getParameter< bool >("loadSnapshot");
+   const bool storeSnapshot              = blockForestParameters.getParameter< bool >("storeSnapshot");
+   const uint_t snapshotFrequency        = blockForestParameters.getParameter< uint_t >("snapshotFrequency");
+   const std::string snapshotBaseFolder  = blockForestParameters.getParameter< std::string >("snapshotBaseFolder");
+
+   // get domain parameters from parameter file
+   auto domainParameters              = configPtr->getOneBlock("DomainParameters");
+   const Vector3< uint_t > domainSize = domainParameters.getParameter< Vector3< uint_t > >("domainSize");
+   const uint_t wavePeriods           = domainParameters.getParameter< uint_t >("wavePeriods");
+   const real_t liquidHeightFactor    = domainParameters.getParameter< real_t >("liquidHeightFactor");
+   const real_t floorHeightFactor     = domainParameters.getParameter< real_t >("floorHeightFactor");
+   const real_t initialAmplitude      = domainParameters.getParameter< real_t >("initialAmplitude");
+
+   // compute number of blocks as defined by domainSize and cellsPerBlock
+   Vector3< uint_t > numBlocks;
+   for (uint i = 0; i <= 2; ++i)
+   {
+      numBlocks[i] = domainSize[i] / cellsPerBlock[i];
+      WALBERLA_CHECK_EQUAL(numBlocks[i] * cellsPerBlock[i], domainSize[i],
+                           "Domain size in direction " << i << " is not a multiple of cells per block.")
+   }
+
+   // get number of (MPI) processes
+   const uint_t numProcesses = uint_c(MPIManager::instance()->numProcesses());
+   WALBERLA_CHECK_EQUAL(numProcesses, numBlocks[0] * numBlocks[1] * numBlocks[2],
+                        "The number of MPI processes is different from the number of blocks as defined by "
+                        "\"domainSize/cellsPerBlock\".")
+
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(numProcesses);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(numBlocks);
+
+   // get PID controller parameters
+   auto PIDParameters                       = configPtr->getOneBlock("PIDParameters");
+   const real_t targetMeanVelocityMagnitude = PIDParameters.getParameter< real_t >("targetMeanVelocityMagnitude");
+   const real_t proportionalGain            = PIDParameters.getParameter< real_t >("proportionalGain");
+   const real_t derivativeGain              = PIDParameters.getParameter< real_t >("derivativeGain");
+   const real_t integralGain                = PIDParameters.getParameter< real_t >("integralGain");
+   const real_t maxRamp                     = PIDParameters.getParameter< real_t >("maxRamp");
+   const real_t minActuatingVariable        = PIDParameters.getParameter< real_t >("minActuatingVariable");
+   const real_t maxActuatingVariable        = PIDParameters.getParameter< real_t >("maxActuatingVariable");
+
+   // read particle infos
+   const auto particleParameters               = configPtr->getOneBlock("ParticleParameters");
+   const std::string particleInFileName        = particleParameters.getParameter< std::string >("inFileName");
+   const uint_t bedCopiesInX                   = particleParameters.getParameter< uint_t >("bedCopiesInX");
+   const uint_t bedCopiesInY                   = particleParameters.getParameter< uint_t >("bedCopiesInY");
+   const real_t particleDensityRatio           = particleParameters.getParameter< real_t >("densityRatio");
+   const real_t particleFixingHeightFactor     = particleParameters.getParameter< real_t >("fixingHeightFactor");
+   const real_t particleFrictionCoefficient    = particleParameters.getParameter< real_t >("frictionCoefficient");
+   const real_t particleRestitutionCoefficient = particleParameters.getParameter< real_t >("restitutionCoefficient");
+   const uint_t particleNumSubCycles           = particleParameters.getParameter< uint_t >("numSubCycles");
+   const bool useLubricationCorrection         = particleParameters.getParameter< bool >("useLubricationCorrection");
+   const bool useNoSlipParticles               = particleParameters.getParameter< bool >("useNoSlipParticles");
+   const real_t particlePoissonsRatio          = 0.22_r;
+   const real_t particleKappa = real_t(2) * (real_t(1) - particlePoissonsRatio) / (real_t(2) - particlePoissonsRatio);
+   real_t particleCollisionTimeNonDim = 4_r;
+   bool useOpenMP                     = false;
+   const uint_t vtkSpacingParticles =
+      configPtr->getOneBlock("VTK").getOneBlock("fluid_field").getParameter< uint_t >("writeFrequency");
+   const std::string vtkFolder =
+      configPtr->getOneBlock("VTK").getOneBlock("fluid_field").getParameter< std::string >("baseFolder");
+
+   // get physics parameters from parameter file
+   auto physicsParameters   = configPtr->getOneBlock("PhysicsParameters");
+   const bool enableWetting = physicsParameters.getParameter< bool >("enableWetting");
+   const uint_t timesteps   = physicsParameters.getParameter< uint_t >("timesteps");
+   const real_t Re          = physicsParameters.getParameter< real_t >("Re");
+   const real_t Fr          = physicsParameters.getParameter< real_t >("Fr");
+   const real_t We          = physicsParameters.getParameter< real_t >("We");
+
+   // get avgDiameter and scaling factor
+   real_t avgParticleDiameter   = 0_r;
+   real_t particleScalingFactor = 0_r;
+   getAvgDiameterScalingFactor(particleInFileName, domainSize, bedCopiesInX, bedCopiesInY, avgParticleDiameter,
+                               particleScalingFactor);
+   const real_t liquidHeight         = avgParticleDiameter * liquidHeightFactor;
+   const real_t floorHeight          = avgParticleDiameter * floorHeightFactor;
+   const real_t particleFixingHeight = particleFixingHeightFactor * avgParticleDiameter;
+
+   WALBERLA_CHECK_FLOAT_UNEQUAL(liquidHeight, 0.0)
+   WALBERLA_CHECK_FLOAT_UNEQUAL(floorHeight, 0.0)
+
+   const real_t absoluteLiquidHeight = liquidHeight + floorHeight;
+
+   const real_t viscosity      = targetMeanVelocityMagnitude * liquidHeight / Re;
+   const real_t relaxationRate = real_t(1.0) / (real_t(3) * viscosity + real_t(0.5));
+
+   const real_t gravity = (targetMeanVelocityMagnitude / Fr) * (targetMeanVelocityMagnitude / Fr) / liquidHeight;
+   const real_t fx      = real_t(3) * targetMeanVelocityMagnitude * viscosity / (liquidHeight * liquidHeight);
+   Vector3< real_t > force(fx, real_t(0.0), -gravity);
+
+   const real_t surfaceTension =
+      real_t(1.0) * targetMeanVelocityMagnitude * targetMeanVelocityMagnitude * liquidHeight / We;
+
+   // compute SI dx and dt
+   const real_t viscosity_SI = real_t(1.0016e-6); // kinemtic viscosity of water at 20°C at 1 bar
+   const real_t dx_SI        = 1_r / particleScalingFactor;
+   const real_t dt_SI        = viscosity / viscosity_SI * dx_SI * dx_SI;
+
+   WALBERLA_LOG_INFO_ON_ROOT("\nPhysical parameters:")
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(liquidHeight);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(floorHeight);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(dx_SI);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(dt_SI);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(force);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(relaxationRate);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(viscosity);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(absoluteLiquidHeight);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(avgParticleDiameter);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(particleScalingFactor);
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(particleFixingHeight);
+   WALBERLA_LOG_INFO_ON_ROOT("\nFree surface physical parameters")
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(surfaceTension);
+
+   if ((periodicity[0] && numBlocks[0] < uint_c(3)) || (periodicity[1] && numBlocks[1] < uint_c(3)) ||
+       (periodicity[2] && numBlocks[2] < uint_c(3)))
+   {
+      WALBERLA_ABORT("When using particles, use at least three blocks per periodic direction.");
+   }
+
+   // read model parameters from parameter file
+   const auto modelParameters               = configPtr->getOneBlock("ModelParameters");
+   const std::string pdfReconstructionModel = modelParameters.getParameter< std::string >("pdfReconstructionModel");
+   const std::string pdfRefillingModel      = modelParameters.getParameter< std::string >("pdfRefillingModel");
+   const std::string excessMassDistributionModel =
+      modelParameters.getParameter< std::string >("excessMassDistributionModel");
+   const std::string curvatureModel          = modelParameters.getParameter< std::string >("curvatureModel");
+   const bool useSimpleMassExchange          = modelParameters.getParameter< bool >("useSimpleMassExchange");
+   const real_t cellConversionThreshold      = modelParameters.getParameter< real_t >("cellConversionThreshold");
+   const real_t cellConversionForceThreshold = modelParameters.getParameter< real_t >("cellConversionForceThreshold");
+
+   // read evaluation parameters from parameter file
+   const auto evaluationParameters      = configPtr->getOneBlock("EvaluationParameters");
+   const uint_t performanceLogFrequency = evaluationParameters.getParameter< uint_t >("performanceLogFrequency");
+   const uint_t evaluationFrequency     = evaluationParameters.getParameter< uint_t >("evaluationFrequency");
+   const std::string baseFolderName     = evaluationParameters.getParameter< std::string >("baseFolderName");
+
+   uint_t beginTimeStep = 0;
+   const std::string checkpointConfigFile("antidunesCheckpointConfig.file");
+   if (loadSnapshot)
+   {
+      WALBERLA_ROOT_SECTION()
+      {
+         std::ifstream file;
+         file.open(snapshotBaseFolder + "/" + checkpointConfigFile);
+         if (file.fail()) WALBERLA_ABORT("Error: " << checkpointConfigFile << " could not be opened!");
+         file >> beginTimeStep;
+         file >> force[0];
+         file.close();
+      }
+      mpi::broadcastObject(beginTimeStep);
+      mpi::broadcastObject(force);
+
+      WALBERLA_LOG_INFO_ON_ROOT("Successfully read config parameters from checkpoint config file:")
+      WALBERLA_LOG_INFO_ON_ROOT(" - beginTimeStep = " << beginTimeStep)
+      WALBERLA_LOG_INFO_ON_ROOT(" - force = < " << force[0] << ", " << force[1] << ", " << force[2] << " >")
+   }
+
+   if (loadSnapshot)
+   {
+      // modify config file to start VTK output from "loadFromTimestep" rather than from 0
+      std::vector< config::Config::Block* > configVTKBlock;
+      configPtr->getWritableGlobalBlock().getWritableBlocks("VTK", configVTKBlock, 1, 1);
+      std::vector< config::Config::Block* > configVTKFluidFieldBlock;
+      configVTKBlock[0]->getWritableBlocks("fluid_field", configVTKFluidFieldBlock, 1, 1);
+      configVTKFluidFieldBlock[0]->setOrAddParameter("initialExecutionCount", std::to_string(beginTimeStep));
+   }
+
+   WALBERLA_ROOT_SECTION()
+   {
+      // create base directories if they do not yet exist
+      filesystem::path tpath(baseFolderName);
+      if (!filesystem::exists(tpath)) filesystem::create_directory(tpath);
+
+      filesystem::path snapshotPath(snapshotBaseFolder);
+      if (!filesystem::exists(snapshotPath)) filesystem::create_directory(snapshotPath);
+   }
+
+   std::shared_ptr< StructuredBlockForest > blockForest(nullptr);
+   const std::string blockForestFile("blockForest.file");
+
+   if (loadSnapshot)
+   {
+      // load block forest from file
+      MPIManager::instance()->useWorldComm();
+
+      blockForest = make_shared< StructuredBlockForest >(
+         make_shared< BlockForest >(uint_c(MPIManager::instance()->rank()),
+                                    (std::string(snapshotBaseFolder + "/" + blockForestFile)).c_str(), true, false),
+         cellsPerBlock[0], cellsPerBlock[1], cellsPerBlock[2]);
+      blockForest->createCellBoundingBoxes();
+   }
+   else
+   {
+      // create uniform block forest
+      blockForest = blockforest::createUniformBlockGrid(numBlocks[0], numBlocks[1], numBlocks[2],             // blocks
+                                                        cellsPerBlock[0], cellsPerBlock[1], cellsPerBlock[2], // cells
+                                                        real_c(1.0),                                          // dx
+                                                        true, // one block per process
+                                                        periodicity[0], periodicity[1], periodicity[2]); // periodicity
+   }
+
+   // save block forest to file (but do not overwrite existing file if snapshot is loaded)
+   if (storeSnapshot && !loadSnapshot)
+   {
+      blockForest->getBlockForest().saveToFile(snapshotBaseFolder + "/" + blockForestFile);
+   }
+
+   const auto vtkParameters      = configPtr->getOneBlock("VTK");
+   const auto vtkFluidParameters = vtkParameters.getOneBlock("fluid_field");
+
+   LatticeModel_T latticeModel = LatticeModel_T(force[0], force[1], force[2], relaxationRate);
+
+   BlockDataID pdfFieldID;
+   const std::string pdfFieldFile("pdfField.file");
+   BlockDataID fillFieldID;
+   const std::string fillFieldFile("fillField.file");
+
+   if (loadSnapshot)
+   {
+      // load PDF field from file
+      shared_ptr< lbm::internal::PdfFieldHandling< LatticeModel_T > > pdfFieldDataHandling =
+         make_shared< lbm::internal::PdfFieldHandling< LatticeModel_T > >(
+            blockForest, latticeModel, false, Vector3< real_t >(real_c(0)), real_c(1), uint_c(1), field::fzyx);
+      pdfFieldID = (blockForest->getBlockStorage())
+                      .loadBlockData(snapshotBaseFolder + "/" + pdfFieldFile, pdfFieldDataHandling, "PDF field");
+
+      // load fill level field from file
+      std::shared_ptr< ScalarFieldHandling< ScalarField_T > > fillFieldDataHandling =
+         std::make_shared< ScalarFieldHandling< ScalarField_T > >(blockForest, uint_c(2));
+      fillFieldID =
+         (blockForest->getBlockStorage())
+            .loadBlockData(snapshotBaseFolder + "/" + fillFieldFile, fillFieldDataHandling, "Fill level field");
+   }
+   else
+   {
+      // add PDF field
+      pdfFieldID =
+         lbm::addPdfFieldToStorage(blockForest, "PDF field", latticeModel,
+                                   Vector3< real_t >(targetMeanVelocityMagnitude, 0, 0), real_t(1.0), field::fzyx);
+
+      // add fill level field (initialized with 0, i.e., gas everywhere)
+      fillFieldID =
+         field::addToStorage< ScalarField_T >(blockForest, "Fill level field", real_c(0.0), field::fzyx, uint_c(2));
+   }
+
+   // MesaPD data structures
+   auto particleStorage  = std::make_shared< mesa_pd::data::ParticleStorage >(1);
+   auto particleAccessor = std::make_shared< mesa_pd::data::ParticleAccessorWithBaseShape >(particleStorage);
+   auto mesapdDomain     = std::make_shared< mesa_pd::domain::BlockForestDomain >(blockForest->getBlockForestPointer());
+
+   BlockDataID particleStorageID;
+   const std::string particleStorageFile("particleStorageFile.file");
+   if (loadSnapshot)
+   {
+      WALBERLA_LOG_INFO_ON_ROOT("Initializing particles from checkpointing file!");
+      particleStorageID = blockForest->loadBlockData(snapshotBaseFolder + "/" + particleStorageFile,
+                                                     mesa_pd::domain::createBlockForestDataHandling(particleStorage),
+                                                     "Particle Storage");
+      mesa_pd::mpi::ClearNextNeighborSync CNNS;
+      CNNS(*particleAccessor);
+
+      mesa_pd::mpi::ClearGhostOwnerSync CGOS;
+      CGOS(*particleAccessor);
+   }
+   else
+   {
+      particleStorageID =
+         blockForest->addBlockData(mesa_pd::domain::createBlockForestDataHandling(particleStorage), "Particle Storage");
+   }
+
+   BlockDataID particleFieldID = field::addToStorage< lbm_mesapd_coupling::ParticleField_T >(
+      blockForest, "Particle field", particleAccessor->getInvalidUid(), field::zyxf, uint_c(2));
+
+   auto densityReferenceHeight = absoluteLiquidHeight;
+   auto hydrostaticDensityFct  = [force, densityReferenceHeight](const Vector3< real_t >& position) {
+      uint_t forceComponent = 2; // gravity is here strictly only acting in z direction!
+      return getHydrostaticDensity(position[forceComponent], densityReferenceHeight, force[forceComponent]);
+   };
+
+   // add boundary handling
+   const std::shared_ptr< AntidunesBoundaryHandling_T > antidunesBoundaryHandling =
+      std::make_shared< AntidunesBoundaryHandling_T >(blockForest, pdfFieldID, fillFieldID, particleFieldID,
+                                                      particleAccessor, hydrostaticDensityFct);
+   const BlockDataID flagFieldID                                    = antidunesBoundaryHandling->getFlagFieldID();
+   const typename AntidunesBoundaryHandling_T::FlagInfo_T& flagInfo = antidunesBoundaryHandling->getFlagInfo();
+
+   real_t sinusAmplitude  = real_c(0.5) * initialAmplitude;
+   real_t sinusOffset     = floorHeight;
+   real_t sinusWavelength = real_c(domainSize[0]) / real_c(wavePeriods);
+
+   if (!loadSnapshot)
+   {
+      // samples used in the Monte-Carlo like estimation of the fill level
+      const uint_t fillLevelInitSamples = uint_c(100); // actually there will be 101 since 0 is also included
+
+      const uint_t numTotalPoints = (fillLevelInitSamples + uint_c(1)) * (fillLevelInitSamples + uint_c(1));
+      const real_t stepsize       = real_c(1) / real_c(fillLevelInitSamples);
+
+      // initialize free-surface sine profile
+      for (auto blockIt = blockForest->begin(); blockIt != blockForest->end(); ++blockIt)
+      {
+         ScalarField_T* const fillField = blockIt->getData< ScalarField_T >(fillFieldID);
+
+         WALBERLA_FOR_ALL_CELLS(fillFieldIt, fillField, {
+            // cell in block-local coordinates
+            const Cell localCell = fillFieldIt.cell();
+
+            // get cell in global coordinates
+            Cell globalCell = localCell;
+            blockForest->transformBlockLocalToGlobalCell(globalCell, *blockIt, localCell);
+
+            // Monte-Carlo like estimation of the fill level:
+            // create uniformly-distributed sample points in each cell and count the number of points below the sine
+            // profile; this fraction of points is used as the fill level to initialize the profile
+            uint_t numPointsBelow = uint_c(0);
+
+            for (uint_t xSample = uint_c(0); xSample <= fillLevelInitSamples; ++xSample)
+            {
+               // Pascal et al. (2021) defined the amplitude to span from minimum peak to maximum peak; in
+               // initializationProfile(), the amplitude is defined to range from the average to the maximum peak
+               const real_t functionValue =
+                  initializationProfile(real_c(globalCell[0]) + real_c(xSample) * stepsize, sinusAmplitude,
+                                        absoluteLiquidHeight + real_c(0.5), sinusWavelength);
+
+               for (uint_t zSample = uint_c(0); zSample <= fillLevelInitSamples; ++zSample)
+               {
+                  const real_t zPoint = real_c(globalCell[2]) + real_c(zSample) * stepsize;
+                  // with operator <, a fill level of 1 can not be reached when the line is equal to the cell's top
+                  // border; with operator <=, a fill level of 0 can not be reached when the line is equal to the cell's
+                  // bottom border
+                  if (zPoint < functionValue) { ++numPointsBelow; }
+               }
+            }
+
+            // fill level is fraction of points below sine profile
+            fillField->get(localCell) = real_c(numPointsBelow) / real_c(numTotalPoints);
+         }) // WALBERLA_FOR_ALL_CELLS
+      }
+
+      initializePoiseuilleProfile(*blockForest, pdfFieldID, fillFieldID, floorHeight, liquidHeight + real_c(0.5),
+                                  force[0], viscosity, sinusAmplitude, sinusWavelength);
+   }
+
+   // initialize domain boundary conditions from config file
+   const auto boundaryParameters = configPtr->getOneBlock("BoundaryParameters");
+   antidunesBoundaryHandling->initFromConfig(boundaryParameters);
+
+   std::function< void(void) > syncCall;
+   auto simulationDomainAABB = blockForest->getDomain();
+
+   lbm_mesapd_coupling::ParticleMappingKernel< AntidunesBoundaryHandling_T::BoundaryHandling_T > particleMappingKernel(
+      blockForest, antidunesBoundaryHandling->getHandlingID());
+   lbm_mesapd_coupling::MovingParticleMappingKernel< AntidunesBoundaryHandling_T::BoundaryHandling_T >
+      movingParticleMappingKernel(blockForest, antidunesBoundaryHandling->getHandlingID(), particleFieldID);
+
+   uint_t numParticles = 0;
+   // initialize bottom solid sine profile
+   if (!loadSnapshot)
+   {
+      auto createParticleFct = [sinusAmplitude, sinusOffset, sinusWavelength](Vector3< real_t > pos) {
+         return pos[2] < initializationProfile(pos[0], sinusAmplitude, sinusOffset, sinusWavelength);
+      };
+
+      real_t maxParticleHeight = 0_r;
+      initSpheresFromFile(particleInFileName, *particleStorage, *mesapdDomain, particleDensityRatio, domainSize,
+                          createParticleFct, simulationDomainAABB, bedCopiesInX, bedCopiesInY, numParticles,
+                          maxParticleHeight, particleScalingFactor);
+      WALBERLA_LOG_INFO_ON_ROOT("Max particle height " << maxParticleHeight);
+      if ((sinusOffset + sinusAmplitude) > maxParticleHeight)
+         WALBERLA_ABORT("Created particle bed is below desired sinus shape!");
+      if (2_r * sinusAmplitude > (maxParticleHeight - particleFixingHeight))
+         WALBERLA_ABORT("Created mobile particle bed is not high enough for desired sinus shape!");
+      if (useNoSlipParticles && (particleFixingHeight < maxParticleHeight))
+         WALBERLA_ABORT("You are using no-slip BCs on particles (which does not set hydrodynamic forces) but do not "
+                        "fix all particles - this leads to wrong behavior and is not permitted!")
+
+      // fix lower particles
+      particleStorage->forEachParticle(
+         useOpenMP, mesa_pd::kernel::SelectAll(), *particleAccessor,
+         [particleFixingHeight](const size_t idx, auto& ac) {
+            if (ac.getPosition(idx)[2] < particleFixingHeight)
+               mesa_pd::data::particle_flags::set(ac.getFlagsRef(idx), mesa_pd::data::particle_flags::FIXED);
+         },
+         *particleAccessor);
+   }
+   else
+   {
+      real_t avgParticleDiameterTest = 0_r;
+      particleStorage->forEachParticle(
+         false, mesa_pd::kernel::SelectLocal(), *particleAccessor,
+         [&numParticles, &avgParticleDiameterTest](const size_t idx, auto& ac) {
+            auto sp = static_cast< mesa_pd::data::Sphere* >(ac.getBaseShape(idx).get());
+            ++numParticles;
+            avgParticleDiameterTest += 2_r * sp->getRadius();
+         },
+         *particleAccessor);
+      mpi::allReduceInplace(numParticles, mpi::SUM);
+      mpi::allReduceInplace(avgParticleDiameterTest, mpi::SUM);
+      avgParticleDiameterTest /= real_c(numParticles);
+      WALBERLA_LOG_INFO_ON_ROOT("Read particles from check pointing file with avg diameter of "
+                                << avgParticleDiameterTest)
+      if (std::abs(avgParticleDiameterTest - avgParticleDiameter) / avgParticleDiameterTest > 0.05)
+      {
+         WALBERLA_ABORT("Particle diameters not correct.")
+      }
+   }
+   WALBERLA_LOG_INFO_ON_ROOT("Created " << numParticles << " particles");
+
+   // create planes
+   createPlane(*particleStorage, simulationDomainAABB.minCorner(), Vector3< real_t >(0, 0, 1));
+   createPlane(*particleStorage, simulationDomainAABB.maxCorner(), Vector3< real_t >(0, 0, -1));
+
+   const real_t blockSyncExtension    = real_t(2.5);
+   real_t maxPossibleParticleDiameter = avgParticleDiameter * 1.1_r;
+   if (maxPossibleParticleDiameter < 2_r * real_c(cellsPerBlock.min()) - blockSyncExtension)
+   {
+      WALBERLA_LOG_INFO_ON_ROOT("Using next neighbor sync for particles");
+      syncCall = [particleStorage, mesapdDomain, blockSyncExtension]() {
+         mesa_pd::mpi::SyncNextNeighbors syncNextNeighborFunc;
+         syncNextNeighborFunc(*particleStorage, *mesapdDomain, blockSyncExtension);
+      };
+      syncCall();
+   }
+   else
+   {
+      WALBERLA_LOG_INFO_ON_ROOT("Using ghost owner sync for particles")
+      syncCall = [particleStorage, mesapdDomain, blockSyncExtension]() {
+         mesa_pd::mpi::SyncGhostOwners syncGhostOwnersFunc;
+         syncGhostOwnersFunc(*particleStorage, *mesapdDomain, blockSyncExtension);
+      };
+      for (uint_t i = 0; i < uint_c(std::ceil(maxPossibleParticleDiameter / real_c(cellsPerBlock.min()))); ++i)
+         syncCall();
+   }
+
+   if (useNoSlipParticles)
+   {
+      particleStorage->forEachParticle(useOpenMP, SphereSelector(), *particleAccessor, particleMappingKernel,
+                                       *particleAccessor, AntidunesBoundaryHandling_T::noSlipFlagID);
+   }
+   else
+   {
+      particleStorage->forEachParticle(useOpenMP, SphereSelector(), *particleAccessor, movingParticleMappingKernel,
+                                       *particleAccessor, AntidunesBoundaryHandling_T::movingObstacleFlagID);
+   }
+
+   // IMPORTANT REMARK: this must be only called after every solid flag has been set; otherwise, the boundary handling
+   // might not detect solid flags correctly
+   antidunesBoundaryHandling->initFlagsFromFillLevel();
+
+   // communication after initialization
+   Communication_T communication(blockForest, flagFieldID, fillFieldID);
+   communication();
+
+   PdfCommunication_T pdfCommunication(blockForest, pdfFieldID);
+   pdfCommunication();
+
+   // add bubble model
+   std::shared_ptr< walberla::free_surface::bubble_model::BubbleModelBase > bubbleModel =
+      std::make_shared< walberla::free_surface::bubble_model::BubbleModelConstantPressure >(real_c(1));
+
+   // initialize hydrostatic pressure
+   if (!loadSnapshot) { initHydrostaticPressure< PdfField_T >(blockForest, pdfFieldID, hydrostaticDensityFct); }
+
+   // set density in non-liquid or non-interface cells to 1 (after initializing with hydrostatic pressure)
+   // setDensityInNonFluidCellsToOne< FlagField_T, PdfField_T >(blockForest, flagInfo, flagFieldID, pdfFieldID);
+
+   // create timeloop
+   SweepTimeloop timeloop(blockForest, timesteps);
+   timeloop.setCurrentTimeStep(beginTimeStep);
+
+   timeloop.addFuncBeforeTimeStep(RemainingTimeLogger(timeloop.getNrOfTimeSteps()), "Remaining Time Logger");
+
+   // Laplace pressure = 2 * surface tension * curvature; curvature computation is not necessary with no surface
+   // tension
+   bool computeCurvature = false;
+   if (!realIsEqual(surfaceTension, real_c(0), real_c(1e-14))) { computeCurvature = true; }
+
+   auto blockStateUpdate = StateSweep(blockForest, flagInfo, flagFieldID);
+
+   // add surface geometry handler
+   BlockDataID curvatureFieldID =
+      field::addToStorage< ScalarField_T >(blockForest, "Curvature field", real_c(0), field::fzyx, uint_c(1));
+   BlockDataID normalFieldID = field::addToStorage< VectorField_T >(
+      blockForest, "Normal field", Vector3< real_t >(real_c(0)), field::fzyx, uint_c(1));
+   BlockDataID obstacleNormalFieldID = field::addToStorage< VectorField_T >(
+      blockForest, "Obstacle normal field", Vector3< real_t >(real_c(0)), field::fzyx, uint_c(1));
+   // add field for smoothed fill levels
+   BlockDataID smoothFillFieldID =
+      field::addToStorage< ScalarField_T >(blockForest, "Smooth fill level field", real_c(0), field::fzyx, uint_c(1));
+
+   // smooth fill level field for decreasing error in finite difference normal and curvature computation (see
+   // dissertation of S. Bogner, 2017 (section 4.4.2.1))
+   walberla::free_surface::SmoothingSweep< CommunicationStencil_T, FlagField_T, ScalarField_T, VectorField_T >
+      smoothingSweep(smoothFillFieldID, fillFieldID, flagFieldID,
+                     walberla::free_surface::flagIDs::liquidInterfaceGasFlagIDs, flagInfo.getObstacleIDSet(),
+                     enableWetting);
+   // IMPORTANT REMARK: SmoothingSweep must be executed on all blocks, because the algorithm works on all liquid,
+   // interface and gas cells. This is necessary since the normals are not only computed in interface cells, but also
+   // in the neighborhood of interface cells. Therefore, meaningful values for the fill levels of the second
+   // neighbors of interface cells are also required in NormalSweep.
+   timeloop.add() << Sweep(smoothingSweep, "Sweep: fill level smoothing")
+                  << AfterFunction(Communication_T(blockForest, smoothFillFieldID),
+                                   "Communication: after smoothing sweep");
+
+   // compute interface normals (using smoothed fill level field)
+   walberla::free_surface::NormalSweep< CommunicationStencil_T, FlagField_T, ScalarField_T, VectorField_T > normalSweep(
+      normalFieldID, smoothFillFieldID, flagFieldID, walberla::free_surface::flagIDs::interfaceFlagID,
+      walberla::free_surface::flagIDs::liquidInterfaceGasFlagIDs, flagInfo.getObstacleIDSet(), true, false, true,
+      false);
+   timeloop.add() << Sweep(normalSweep, "Sweep: normal computation", StateSweep::fullFreeSurface)
+                  << Sweep(emptySweep(), "Empty sweep: normal")
+                  << AfterFunction(Communication_T(blockForest, normalFieldID), "Communication: after normal sweep");
+
+   if (computeCurvature)
+   {
+      // compute interface curvature using finite differences according to Brackbill et al.
+      walberla::free_surface::CurvatureSweepFiniteDifferences< CommunicationStencil_T, FlagField_T, ScalarField_T,
+                                                               VectorField_T >
+         curvSweep(curvatureFieldID, normalFieldID, obstacleNormalFieldID, flagFieldID,
+                   walberla::free_surface::flagIDs::interfaceFlagID,
+                   walberla::free_surface::flagIDs::liquidInterfaceGasFlagIDs, flagInfo.getObstacleIDSet(), false,
+                   real_c(0));
+      timeloop.add() << Sweep(curvSweep, "Sweep: curvature computation (finite difference method)",
+                              StateSweep::fullFreeSurface)
+                     << Sweep(emptySweep(), "Empty sweep: curvature")
+                     << AfterFunction(Communication_T(blockForest, curvatureFieldID),
+                                      "Communication: after curvature sweep");
+   }
+
+   // add surface dynamics handler
+
+   // add standard waLBerla boundary handling
+   timeloop.add() << Sweep(antidunesBoundaryHandling->getBoundarySweep(), "Sweep: boundary handling",
+                           Set< SUID >::emptySet(), StateSweep::onlyGasAndBoundary)
+                  << Sweep(emptySweep(), "Empty sweep: boundary handling", StateSweep::onlyGasAndBoundary);
+
+   // sweep for
+   // - reconstruction of PDFs in interface cells
+   // - streaming of PDFs in interface cells (and liquid cells on the same block)
+   // - advection of mass
+   // - update bubble volumes
+   // - marking interface cells for conversion
+   const walberla::free_surface::StreamReconstructAdvectSweep<
+      LatticeModel_T, typename AntidunesBoundaryHandling_T::BoundaryHandling_T, FlagField_T,
+      typename AntidunesBoundaryHandling_T::FlagInfo_T, ScalarField_T, VectorField_T, true >
+      streamReconstructAdvectSweep(surfaceTension, antidunesBoundaryHandling->getHandlingID(), fillFieldID, flagFieldID,
+                                   pdfFieldID, normalFieldID, curvatureFieldID, flagInfo, bubbleModel.get(),
+                                   pdfReconstructionModel, useSimpleMassExchange, cellConversionThreshold,
+                                   cellConversionForceThreshold);
+   // sweep acts only on blocks with at least one interface cell (due to StateSweep::fullFreeSurface)
+   timeloop.add() << Sweep(streamReconstructAdvectSweep, "Sweep: StreamReconstructAdvect", StateSweep::fullFreeSurface)
+                  << Sweep(emptySweep(), "Empty sweep: StreamReconstructAdvect")
+                  // do not communicate PDFs here:
+                  // - stream on blocks with "StateSweep::fullFreeSurface" was performed here using post-collision PDFs
+                  // - stream on other blocks is performed below and should also use post-collision PDFs
+                  // => if PDFs were communicated here, the ghost layer of other blocks would have post-stream PDFs
+                  << AfterFunction(Communication_T(blockForest, fillFieldID, flagFieldID),
+                                   "Communication: after StreamReconstructAdvect sweep")
+                  << AfterFunction(blockforest::UpdateSecondGhostLayer< ScalarField_T >(blockForest, fillFieldID),
+                                   "Second ghost layer update: after StreamReconstructAdvect sweep (fill level field)")
+                  << AfterFunction(blockforest::UpdateSecondGhostLayer< FlagField_T >(blockForest, flagFieldID),
+                                   "Second ghost layer update: after StreamReconstructAdvect sweep (flag field)");
+
+   auto lbmSweepGenerated = typename LatticeModel_T::Sweep(pdfFieldID);
+
+   // temporary class for being able to call the LBM collision with operator()
+   class CollideSweep
+   {
+    public:
+      CollideSweep(const typename LatticeModel_T::Sweep& sweep) : sweep_(sweep){};
+
+      void operator()(IBlock* const block, const uint_t numberOfGhostLayersToInclude = uint_t(0))
+      {
+         sweep_.collide(block, numberOfGhostLayersToInclude);
+      }
+
+    private:
+      typename LatticeModel_T::Sweep sweep_;
+   };
+
+   timeloop.add() << Sweep(CollideSweep(lbmSweepGenerated), "Sweep: collision (generated)", StateSweep::fullFreeSurface)
+                  << Sweep(lbmSweepGenerated, "Sweep: streamCollide (generated)", StateSweep::onlyLBM)
+                  << Sweep(emptySweep(), "Empty sweep: streamCollide (generated)")
+                  << AfterFunction(PdfCommunication_T(blockForest, pdfFieldID),
+                                   "Communication: after streamCollide (generated)");
+
+   // convert cells
+   // - according to the flags from StreamReconstructAdvectSweep (interface -> gas/liquid)
+   // - to ensure a closed layer of interface cells (gas/liquid -> interface)
+   // - detect and register bubble merges/splits (bubble volumes are already updated in StreamReconstructAdvectSweep)
+   // - convert cells and initialize PDFs near inflow boundaries
+   const walberla::free_surface::CellConversionSweep< LatticeModel_T, AntidunesBoundaryHandling_T::BoundaryHandling_T,
+                                                      ScalarField_T >
+      cellConvSweep(antidunesBoundaryHandling->getHandlingID(), pdfFieldID, flagInfo, bubbleModel.get());
+   timeloop.add() << Sweep(cellConvSweep, "Sweep: cell conversion", StateSweep::fullFreeSurface)
+                  << Sweep(emptySweep(), "Empty sweep: cell conversion")
+                  //<< AfterFunction(PdfCommunication_T(blockForest, pdfFieldID),
+                  //
+                  //                 "Communication: after cell conversion sweep (PDF field)")
+                  // communicate the flag field also in corner directions
+                  << AfterFunction(Communication_T(blockForest, flagFieldID),
+                                   "Communication: after cell conversion sweep (flag field)")
+                  << AfterFunction(blockforest::UpdateSecondGhostLayer< FlagField_T >(blockForest, flagFieldID),
+                                   "Second ghost layer update: after cell conversion sweep (flag field)");
+
+   // reinitialize PDFs, i.e., refill cells that were converted from gas to interface
+   // - when the flag "convertedFromGasToInterface" has been set (by CellConversionSweep)
+   // - according to the method specified with pdfRefillingModel_
+   const walberla::free_surface::EquilibriumRefillingSweep< LatticeModel_T, FlagField_T > equilibriumRefillingSweep(
+      pdfFieldID, flagFieldID, flagInfo, true);
+   timeloop.add() << Sweep(equilibriumRefillingSweep, "Sweep: EquilibriumRefilling", StateSweep::fullFreeSurface)
+                  << Sweep(emptySweep(), "Empty sweep: EquilibriumRefilling")
+                  << AfterFunction(PdfCommunication_T(blockForest, pdfFieldID),
+                                   "Communication: after EquilibriumRefilling sweep");
+
+   // distribute excess mass:
+   // - excess mass: mass that is free after conversion from interface to gas/liquid cells
+   // - update the bubble model
+   // IMPORTANT REMARK: this sweep computes the mass via the density, i.e., the PDF field must be up-to-date and the
+   // PdfRefillingSweep must have been performed
+   const walberla::free_surface::ExcessMassDistributionSweepInterfaceEvenly< LatticeModel_T, FlagField_T, ScalarField_T,
+                                                                             VectorField_T >
+      distributeMassSweep(excessMassDistributionModel, fillFieldID, flagFieldID, pdfFieldID, flagInfo);
+   timeloop.add() << Sweep(distributeMassSweep, "Sweep: excess mass distribution", StateSweep::fullFreeSurface)
+                  << Sweep(emptySweep(), "Empty sweep: distribute excess mass")
+                  << AfterFunction(Communication_T(blockForest, fillFieldID),
+                                   "Communication: after excess mass distribution sweep")
+                  << AfterFunction(blockforest::UpdateSecondGhostLayer< ScalarField_T >(blockForest, fillFieldID),
+                                   "Second ghost layer update: after excess mass distribution sweep (fill level field)")
+                  // update bubble model, i.e., perform registered bubble merges/splits; bubble merges/splits are
+                  // already detected and registered by CellConversionSweep
+                  << AfterFunction(
+                        std::bind(&walberla::free_surface::bubble_model::BubbleModelBase::update, bubbleModel),
+                        "Sweep: bubble model update");
+
+   // reset all flags that signal cell conversions (except "keepInterfaceForWettingFlag")
+   walberla::free_surface::ConversionFlagsResetSweep< FlagField_T > resetConversionFlagsSweep(flagFieldID, flagInfo);
+   timeloop.add() << Sweep(resetConversionFlagsSweep, "Sweep: conversion flag reset", StateSweep::fullFreeSurface)
+                  << Sweep(emptySweep(), "Empty sweep: conversion flag reset")
+                  << AfterFunction(Communication_T(blockForest, flagFieldID),
+                                   "Communication: after excess mass distribution sweep")
+                  << AfterFunction(blockforest::UpdateSecondGhostLayer< FlagField_T >(blockForest, flagFieldID),
+                                   "Second ghost layer update: after excess mass distribution sweep (flag field)");
+
+   // update block states
+   timeloop.add() << Sweep(blockStateUpdate, "Sweep: block state update");
+
+   // add VTK output
+   walberla::free_surface::addVTKOutput< LatticeModel_T, AntidunesBoundaryHandling_T, PdfField_T, FlagField_T,
+                                         ScalarField_T, VectorField_T >(
+      blockForest, timeloop, configPtr, flagInfo, pdfFieldID, flagFieldID, fillFieldID, BlockDataID(), curvatureFieldID,
+      normalFieldID, obstacleNormalFieldID);
+
+   // add triangle mesh output of free surface
+   walberla::free_surface::SurfaceMeshWriter< ScalarField_T, FlagField_T > surfaceMeshWriter(
+      blockForest, fillFieldID, flagFieldID, walberla::free_surface::flagIDs::liquidInterfaceGasFlagIDs, real_c(0),
+      configPtr);
+   surfaceMeshWriter(); // write initial mesh
+   timeloop.addFuncAfterTimeStep(surfaceMeshWriter, "Writer: surface mesh");
+
+   if (vtkSpacingParticles != uint_t(0))
+   {
+      // particle field
+      auto particleFieldVTK =
+         vtk::createVTKOutput_BlockData(blockForest, "particle_field", vtkSpacingParticles, 0, false, vtkFolder);
+      auto cellBB_filterParameters             = vtkFluidParameters.getOneBlock("CellBB_filter");
+      const Vector3< uint_t > cellBB_filterMin = cellBB_filterParameters.getParameter< Vector3< uint_t > >("min");
+      const Vector3< uint_t > cellBB_filterMax = cellBB_filterParameters.getParameter< Vector3< uint_t > >("max");
+      AABB sliceAABB(real_c(cellBB_filterMin[0]), real_c(cellBB_filterMin[1]), real_c(cellBB_filterMin[2]),
+                     real_c(cellBB_filterMax[0] + uint_t(1)), real_c(cellBB_filterMax[1] + uint_t(1)),
+                     real_c(cellBB_filterMax[2] + uint_t(1)));
+
+      particleFieldVTK->addCellInclusionFilter(vtk::AABBCellFilter(sliceAABB));
+      particleFieldVTK->addCellDataWriter(
+         make_shared< field::VTKWriter< GhostLayerField< walberla::id_t, 1 > > >(particleFieldID, "particleField"));
+      particleFieldVTK->setSamplingResolution(vtkFluidParameters.getParameter< real_t >("samplingResolution"));
+      timeloop.addFuncBeforeTimeStep(vtk::writeFiles(particleFieldVTK), "VTK (particle field data");
+   }
+
+   if (vtkSpacingParticles != uint_t(0))
+   {
+      // sphere
+      auto particleVtkOutput = make_shared< mesa_pd::vtk::ParticleVtkOutput >(particleStorage);
+      particleVtkOutput->addOutput< mesa_pd::data::SelectParticleUid >("uid");
+      particleVtkOutput->addOutput< mesa_pd::data::SelectParticleLinearVelocity >("velocity");
+      particleVtkOutput->addOutput< mesa_pd::data::SelectParticleInteractionRadius >("radius");
+      // limit output to process-local spheres
+      particleVtkOutput->setParticleSelector([](const mesa_pd::data::ParticleStorage::iterator& pIt) {
+         using namespace walberla::mesa_pd::data::particle_flags;
+         return (pIt->getBaseShape()->getShapeType() == mesa_pd::data::Sphere::SHAPE_TYPE) &&
+                !isSet(pIt->getFlags(), GHOST);
+      });
+      auto particleVtkWriter =
+         vtk::createVTKOutput_PointData(particleVtkOutput, "particles", vtkSpacingParticles, vtkFolder,
+                                        std::string("simulation_step"), false, true, true, true, beginTimeStep);
+      timeloop.addFuncAfterTimeStep(vtk::writeFiles(particleVtkWriter), "VTK (sphere data)");
+   }
+
+   // add logging for computational performance
+   const lbm::PerformanceLogger< FlagField_T > performanceLogger(
+      blockForest, flagFieldID, walberla::free_surface::flagIDs::liquidInterfaceFlagIDs, performanceLogFrequency);
+   timeloop.addFuncAfterTimeStep(performanceLogger, "Evaluator: performance logging");
+
+   // LBM stability check
+   timeloop.addFuncAfterTimeStep(makeSharedFunctor(field::makeStabilityChecker< PdfField_T, FlagField_T >(
+                                    walberlaEnv.config(), blockForest, pdfFieldID, flagFieldID,
+                                    walberla::free_surface::flagIDs::liquidInterfaceFlagIDs)),
+                                 "LBM stability check");
+
+   // add sweep for evaluating the fluid's mean velocity
+   const std::shared_ptr< Vector3< real_t > > meanVelocity = std::make_shared< Vector3< real_t > >(real_c(0));
+   const real_t velocityAveragingFactor = 1_r / (liquidHeight * real_c(domainSize[0]) * real_c(domainSize[1]));
+   MeanVelocityComputer< AntidunesBoundaryHandling_T, PdfField_T, FlagField_T > meanVelocityComputer(
+      blockForest, antidunesBoundaryHandling, pdfFieldID, meanVelocity, velocityAveragingFactor);
+
+   // PID Controller
+   shared_ptr< ForcingAdjuster > forcingAdjuster =
+      make_shared< ForcingAdjuster >(blockForest, pdfFieldID, targetMeanVelocityMagnitude, force[0], proportionalGain,
+                                     derivativeGain, integralGain, maxRamp, minActuatingVariable, maxActuatingVariable);
+
+   if (loadSnapshot) { forcingAdjuster->loadPIDSnapshot(snapshotBaseFolder + "/" + "pidState.file"); }
+
+   WcTimingPool timingPool;
+
+   // this is carried out after the particle integration, it corrects the flag field and restores missing PDF
+   // information then, the checkpointing file can be written, as otherwise some cells are invalid and can not be
+   // recovered
+   SweepTimeloop timeloopAfterParticles(blockForest, timesteps);
+   timeloopAfterParticles.setCurrentTimeStep(beginTimeStep);
+
+   // sweep for updating the particle mapping into the LBM simulation
+   bool strictlyConserveMomentum = false;
+   timeloopAfterParticles.add() << Sweep(
+      lbm_mesapd_coupling::makeMovingParticleMapping< PdfField_T, AntidunesBoundaryHandling_T::BoundaryHandling_T >(
+         blockForest, pdfFieldID, antidunesBoundaryHandling->getHandlingID(), particleFieldID, particleAccessor,
+         AntidunesBoundaryHandling_T::movingObstacleFlagID, FormerMO_Flag,
+         lbm_mesapd_coupling::RegularParticlesSelector(), strictlyConserveMomentum),
+      "Particle Mapping");
+
+   // sweep for restoring PDFs in cells previously occupied by particles
+   bool reconstruction_recomputeTargetDensity = false;
+   bool reconstruction_useCentralDifferences  = true;
+   auto gradReconstructor =
+      lbm_mesapd_coupling::makeGradsMomentApproximationReconstructor< AntidunesBoundaryHandling_T::BoundaryHandling_T >(
+         blockForest, antidunesBoundaryHandling->getHandlingID(), relaxationRate, reconstruction_recomputeTargetDensity,
+         reconstruction_useCentralDifferences);
+
+   timeloopAfterParticles.add()
+      << Sweep(makeSharedSweep(
+                  lbm_mesapd_coupling::makePdfReconstructionManager< PdfField_T,
+                                                                     AntidunesBoundaryHandling_T::BoundaryHandling_T >(
+                     blockForest, pdfFieldID, antidunesBoundaryHandling->getHandlingID(), particleFieldID,
+                     particleAccessor, FormerMO_Flag, walberla::free_surface::flagIDs::liquidFlagID, gradReconstructor,
+                     strictlyConserveMomentum)),
+               "PDF Restore")
+      << AfterFunction(Communication_T(blockForest, flagFieldID, particleFieldID),
+                       "Communication: after PDF reconstruction sweep") // unsure if necessary but added for consistency
+      << AfterFunction(pdfCommunication, "PDF Communication");
+
+   real_t timeStepSizeParticles = real_t(1) / real_t(particleNumSubCycles);
+   mesa_pd::kernel::VelocityVerletPreForceUpdate vvIntegratorPreForce(timeStepSizeParticles);
+   mesa_pd::kernel::VelocityVerletPostForceUpdate vvIntegratorPostForce(timeStepSizeParticles);
+   mesa_pd::kernel::LinearSpringDashpot collisionResponse(1);
+   collisionResponse.setFrictionCoefficientDynamic(0, 0, particleFrictionCoefficient);
+   mesa_pd::mpi::ReduceProperty reduceProperty;
+   mesa_pd::mpi::ReduceContactHistory reduceAndSwapContactHistory;
+   lbm_mesapd_coupling::ResetHydrodynamicForceTorqueKernel resetHydrodynamicForceTorque;
+   lbm_mesapd_coupling::AverageHydrodynamicForceTorqueKernel averageHydrodynamicForceTorque;
+   real_t particleCollisionTime = particleCollisionTimeNonDim * avgParticleDiameter;
+   lbm_mesapd_coupling::LubricationCorrectionKernel lubricationCorrectionKernel(
+      viscosity, [](real_t r) { return (real_t(0.001 + real_t(0.00007) * r)) * r; });
+
+   WALBERLA_LOG_INFO_ON_ROOT("Will use particle time step size of "
+                             << timeStepSizeParticles << " and collision time of " << particleCollisionTime);
+
+   AverageDataSliceEvaluator< PdfField_T, AntidunesBoundaryHandling_T, FlagField_T, ScalarField_T >
+      averageDataSliceEvaluator(blockForest, flagFieldID, fillFieldID, pdfFieldID);
+
+   std::shared_ptr< real_t > totalFluidMass = std::make_shared< real_t >(real_c(0));
+   walberla::free_surface::TotalMassComputer< AntidunesBoundaryHandling_T, PdfField_T, FlagField_T, ScalarField_T >
+      totalFluidMassEvaluator(blockForest, antidunesBoundaryHandling, pdfFieldID, fillFieldID, evaluationFrequency,
+                              totalFluidMass);
+
+   BedloadTransportEvaluator< ParticleAccessor_T > bedloadTransportEvaluator(
+      particleAccessor, 1_r / real_c(domainSize[0] * domainSize[1]), numParticles);
+   auto bedLoadTransportFileName = baseFolderName + "/bedload.txt";
+   WALBERLA_LOG_INFO_ON_ROOT("Writing bedload info to file " << bedLoadTransportFileName);
+
+   auto fluidInfoFileName = baseFolderName + "/fluidInfo.txt";
+   WALBERLA_LOG_INFO_ON_ROOT("Writing fluid info to file " << fluidInfoFileName);
+
+   // write info file
+   WALBERLA_ROOT_SECTION()
+   {
+      std::ofstream evalInfoFile(baseFolderName + "/info.txt");
+      evalInfoFile << evaluationFrequency << "\n";
+      evalInfoFile << gravity << "\n";
+      evalInfoFile << viscosity << "\n";
+      evalInfoFile << particleDensityRatio << "\n";
+      evalInfoFile << avgParticleDiameter << "\n";
+      evalInfoFile << domainSize[0] << "\n";
+      evalInfoFile << domainSize[1] << "\n";
+      evalInfoFile << domainSize[2] << "\n";
+      evalInfoFile << numParticles << "\n";
+      evalInfoFile << dx_SI << "\n";
+      evalInfoFile << dt_SI << "\n";
+      evalInfoFile.close();
+   }
+
+   Vector3< real_t > totalHydrodynamicForceOnParticles(0_r); // only root will have valid values
+
+   for (uint_t t = beginTimeStep; t != timesteps; ++t)
+   {
+      timeloop.singleStep(timingPool, true);
+
+      timingPool["Mesa_pd"].start();
+
+      reduceProperty.operator()< mesa_pd::HydrodynamicForceTorqueNotification >(*particleStorage);
+
+      if (t == 0)
+      {
+         lbm_mesapd_coupling::InitializeHydrodynamicForceTorqueForAveragingKernel
+            initializeHydrodynamicForceTorqueForAveragingKernel;
+         particleStorage->forEachParticle(useOpenMP, mesa_pd::kernel::SelectLocal(), *particleAccessor,
+                                          initializeHydrodynamicForceTorqueForAveragingKernel, *particleAccessor);
+      }
+      particleStorage->forEachParticle(useOpenMP, mesa_pd::kernel::SelectLocal(), *particleAccessor,
+                                       averageHydrodynamicForceTorque, *particleAccessor);
+
+      for (auto subCycle = uint_t(0); subCycle < particleNumSubCycles; ++subCycle)
+      {
+         particleStorage->forEachParticle(useOpenMP, mesa_pd::kernel::SelectLocal(), *particleAccessor,
+                                          vvIntegratorPreForce, *particleAccessor);
+         syncCall();
+
+         if (useLubricationCorrection)
+         {
+            // lubrication correction
+            particleStorage->forEachParticlePairHalf(
+               useOpenMP, mesa_pd::kernel::ExcludeInfiniteInfinite(), *particleAccessor,
+               [&lubricationCorrectionKernel, &mesapdDomain](const size_t idx1, const size_t idx2, auto& ac) {
+                  mesa_pd::collision_detection::AnalyticContactDetection acd;
+                  acd.getContactThreshold() = lubricationCorrectionKernel.getNormalCutOffDistance();
+                  mesa_pd::kernel::DoubleCast double_cast;
+                  mesa_pd::mpi::ContactFilter contact_filter;
+                  if (double_cast(idx1, idx2, ac, acd, ac))
+                  {
+                     if (contact_filter(acd.getIdx1(), acd.getIdx2(), ac, acd.getContactPoint(), *mesapdDomain))
+                     {
+                        double_cast(acd.getIdx1(), acd.getIdx2(), ac, lubricationCorrectionKernel, ac,
+                                    acd.getContactNormal(), acd.getPenetrationDepth());
+                     }
+                  }
+               },
+               *particleAccessor);
+         }
+
+         // collision response
+         particleStorage->forEachParticlePairHalf(
+            useOpenMP, mesa_pd::kernel::ExcludeInfiniteInfinite(), *particleAccessor,
+            [&collisionResponse, &mesapdDomain, timeStepSizeParticles, particleRestitutionCoefficient,
+             particleCollisionTime, particleKappa](const size_t idx1, const size_t idx2, auto& ac) {
+               mesa_pd::collision_detection::AnalyticContactDetection acd;
+               mesa_pd::kernel::DoubleCast double_cast;
+               mesa_pd::mpi::ContactFilter contact_filter;
+               if (double_cast(idx1, idx2, ac, acd, ac))
+               {
+                  if (contact_filter(acd.getIdx1(), acd.getIdx2(), ac, acd.getContactPoint(), *mesapdDomain))
+                  {
+                     auto meff = real_t(1) / (ac.getInvMass(idx1) + ac.getInvMass(idx2));
+                     collisionResponse.setStiffnessAndDamping(0, 0, particleRestitutionCoefficient,
+                                                              particleCollisionTime, particleKappa, meff);
+                     collisionResponse(acd.getIdx1(), acd.getIdx2(), ac, acd.getContactPoint(), acd.getContactNormal(),
+                                       acd.getPenetrationDepth(), timeStepSizeParticles);
+                  }
+               }
+            },
+            *particleAccessor);
+
+         reduceAndSwapContactHistory(*particleStorage);
+
+         // add hydrodynamic force
+         lbm_mesapd_coupling::AddHydrodynamicInteractionKernel addHydrodynamicInteraction;
+         particleStorage->forEachParticle(useOpenMP, mesa_pd::kernel::SelectLocal(), *particleAccessor,
+                                          addHydrodynamicInteraction, *particleAccessor);
+
+         // add external forces
+         particleStorage->forEachParticle(
+            useOpenMP, mesa_pd::kernel::SelectLocal(), *particleAccessor,
+            [particleDensityRatio, force](const size_t idx, auto& ac) {
+               mesa_pd::addForceAtomic(
+                  idx, ac,
+                  ac.getVolume(idx) *
+                     Vector3< real_t >(force[0], force[1], (particleDensityRatio - real_t(1)) * force[2]));
+            },
+            *particleAccessor);
+
+         reduceProperty.operator()< mesa_pd::ForceTorqueNotification >(*particleStorage);
+
+         particleStorage->forEachParticle(useOpenMP, mesa_pd::kernel::SelectLocal(), *particleAccessor,
+                                          vvIntegratorPostForce, *particleAccessor);
+         syncCall();
+      }
+
+      // has to be evaluated here before the force info is erased from particles
+      if (t % evaluationFrequency == uint_c(0))
+         totalHydrodynamicForceOnParticles = getTotalHydrodynamicForceOnParticles(particleAccessor);
+
+      particleStorage->forEachParticle(useOpenMP, mesa_pd::kernel::SelectAll(), *particleAccessor,
+                                       resetHydrodynamicForceTorque, *particleAccessor);
+      timingPool["Mesa_pd"].end();
+
+      // update particle mapping
+      timeloopAfterParticles.singleStep(timingPool, true);
+
+      timingPool["Evaluation"].start();
+
+      if (t % evaluationFrequency == uint_c(0))
+      {
+         averageDataSliceEvaluator();
+         totalFluidMassEvaluator.computeMass(blockForest, antidunesBoundaryHandling);
+         bedloadTransportEvaluator();
+         meanVelocityComputer();
+
+         WALBERLA_ROOT_SECTION()
+         {
+            write2DVectorToFile(averageDataSliceEvaluator.getSolidVolumeFractionVector(),
+                                averageDataSliceEvaluator.getXLen(), averageDataSliceEvaluator.getZLen(),
+                                baseFolderName + "/svfSlice_" + std::to_string(t) + ".txt");
+            write2DVectorToFile(averageDataSliceEvaluator.getFillLevelVector(), averageDataSliceEvaluator.getXLen(),
+                                averageDataSliceEvaluator.getZLen(),
+                                baseFolderName + "/fillSlice_" + std::to_string(t) + ".txt");
+            write2DVectorToFile(averageDataSliceEvaluator.getVelocityXVector(), averageDataSliceEvaluator.getXLen(),
+                                averageDataSliceEvaluator.getZLen(),
+                                baseFolderName + "/velXSlice_" + std::to_string(t) + ".txt");
+
+            std::ofstream bedloadFile(bedLoadTransportFileName, std::ofstream::app);
+            bedloadFile << t << " " << bedloadTransportEvaluator.getTransportRate() << " "
+                        << bedloadTransportEvaluator.getAverageVelocity() << " " << totalHydrodynamicForceOnParticles[0]
+                        << " " << totalHydrodynamicForceOnParticles[1] << " " << totalHydrodynamicForceOnParticles[2]
+                        << "\n";
+            bedloadFile.close();
+
+            WALBERLA_LOG_DEVEL("____________________________________________________________________");
+            WALBERLA_LOG_DEVEL("time step = " << t);
+            const real_t froudeNumber = (*meanVelocity)[0] / real_c(std::sqrt(liquidHeight * std::abs(force[2])));
+
+            const real_t reynoldsNumber = (*meanVelocity)[0] * liquidHeight / viscosity;
+
+            const real_t weberNumber =
+               real_t(1.0) * (*meanVelocity)[0] * (*meanVelocity)[0] * liquidHeight / surfaceTension;
+
+            WALBERLA_LOG_DEVEL(" - Total fluid mass = " << std::setprecision(16) << (*totalFluidMass));
+            auto maxFluidZPos = averageDataSliceEvaluator.getMaxFluidZPos();
+            WALBERLA_LOG_DEVEL(" - Max fluid z-position = " << maxFluidZPos);
+            WALBERLA_LOG_DEVEL(" - Froude number = " << froudeNumber);
+            WALBERLA_LOG_DEVEL(" - Reynolds number = " << reynoldsNumber);
+            WALBERLA_LOG_DEVEL(" - We = " << weberNumber);
+
+            WALBERLA_LOG_DEVEL(" - meanVelocity = " << *meanVelocity);
+
+            std::ofstream fluidInfoFile(fluidInfoFileName, std::ofstream::app);
+            fluidInfoFile << t << " " << force[0] << " " << (*meanVelocity)[0] << " " << maxFluidZPos << " "
+                          << std::setprecision(16) << (*totalFluidMass) << "\n";
+            fluidInfoFile.close();
+
+            if (std::isnan(reynoldsNumber)) WALBERLA_ABORT("reynoldsNumber is inf!")
+         }
+
+         WALBERLA_LOG_DEVEL_ON_ROOT(" -> CurrentExternalForce in x-direction before update = " << force[0]);
+         (*forcingAdjuster)(meanVelocity->length());
+         force[0] = forcingAdjuster->getExternalForcing();
+         WALBERLA_LOG_DEVEL_ON_ROOT(" -> CurrentExternalForce in x-direction after update  = " << force[0]);
+      }
+      timingPool["Evaluation"].end();
+
+      if (storeSnapshot)
+      {
+         if (t % snapshotFrequency == uint_c(0) && t > uint_c(0))
+         {
+            WALBERLA_LOG_INFO_ON_ROOT("Writing checkpointing file in time step " << t)
+
+            blockForest->saveBlockData(snapshotBaseFolder + "/tmp_" + pdfFieldFile, pdfFieldID);
+            blockForest->saveBlockData(snapshotBaseFolder + "/tmp_" + fillFieldFile, fillFieldID);
+            blockForest->saveBlockData(snapshotBaseFolder + "/tmp_" + particleStorageFile, particleStorageID);
+
+            WALBERLA_ROOT_SECTION()
+            {
+               std::string tmpCheckpointConfigFile = snapshotBaseFolder + "/tmp_" + checkpointConfigFile;
+               std::ofstream file;
+               file.open(tmpCheckpointConfigFile.c_str());
+
+               file << std::setprecision(16);
+               file << t + 1 << "\n";
+               file << force[0] << "\n";
+               file.close();
+            }
+
+            forcingAdjuster->storePIDSnapshot(snapshotBaseFolder + "/" + "pidState.file");
+
+            WALBERLA_MPI_BARRIER();
+
+            // rename checkpoint files to "real" ones
+            // otherwise, the checkpointed state might be incomplete if the simulation stops due to over time during
+            // checkpointing
+            WALBERLA_ROOT_SECTION()
+            {
+               renameFile(snapshotBaseFolder + "/tmp_" + pdfFieldFile, snapshotBaseFolder + "/" + pdfFieldFile);
+               renameFile(snapshotBaseFolder + "/tmp_" + fillFieldFile, snapshotBaseFolder + "/" + fillFieldFile);
+               renameFile(snapshotBaseFolder + "/tmp_" + particleStorageFile,
+                          snapshotBaseFolder + "/" + particleStorageFile);
+               renameFile(snapshotBaseFolder + "/tmp_" + checkpointConfigFile,
+                          snapshotBaseFolder + "/" + checkpointConfigFile);
+            }
+         }
+      }
+
+      if (t % performanceLogFrequency == uint_c(0) && t > uint_c(0)) { timingPool.logResultOnRoot(); }
+   }
+
+   return EXIT_SUCCESS;
+}
+
+} // namespace antidunes
+} // namespace walberla
+
+int main(int argc, char** argv) { return walberla::antidunes::main(argc, argv); }
diff --git a/apps/showcases/Antidunes/Antidunes.prm b/apps/showcases/Antidunes/Antidunes.prm
new file mode 100644
index 0000000000000000000000000000000000000000..306cb548ec21423211c3586c41fc9ac8d0a738d1
--- /dev/null
+++ b/apps/showcases/Antidunes/Antidunes.prm
@@ -0,0 +1,147 @@
+BlockForestParameters
+{
+   cellsPerBlock                 < 50, 20, 40 >;
+   periodicity                   < 1, 1, 0 >;
+   loadSnapshot                  false;
+   storeSnapshot                 true;
+   snapshotFrequency             10000;
+   snapshotBaseFolder            snapshots;
+}
+
+DomainParameters
+{
+   domainSize         <3200, 60, 160>;
+   wavePeriods        1;      // never set to 0 -> division by zero, even if you initialize a flat particle bed
+   liquidHeightFactor 2.9655; // h_0 / d (water height / avg particle diameter) -> from experiment [E1 = 2.9655, E4 = 3.5862]
+   floorHeightFactor  4.1393;  // from domain bottom to sine's average
+   initialAmplitude   0;     // defined from minimum peak to maximum peak as by Pascal et al. (2021)
+}
+
+PIDParameters
+{
+    targetMeanVelocityMagnitude 0.02;
+    proportionalGain            2e-4;
+    derivativeGain              1e-6;
+    integralGain                2e-4;
+    maxRamp                     1e-4;
+    minActuatingVariable        0;
+    maxActuatingVariable        1e-3;
+}
+
+PhysicsParameters
+{
+   enableWetting     false;
+   timesteps         2000000;
+   Re                3100; // [E1=3100, E4=4800]
+   Fr                1.31; // [E1=1.31, E4=1.45]
+   We                15.6188;   // [E1=15.6188 , E4=30.2493]
+}
+
+ParticleParameters
+{
+    inFileName spheres_out.dat;
+    bedCopiesInX 1;
+    bedCopiesInY 1;
+    densityRatio 2.55;
+    fixingHeightFactor 1.5; // proportional to the mean particle diameter
+    frictionCoefficient 0.5;
+    restitutionCoefficient 0.97;
+    numSubCycles 10;
+    useLubricationCorrection true;
+    useNoSlipParticles false;
+}
+
+ModelParameters
+{
+   pdfReconstructionModel        OnlyMissing;
+   pdfRefillingModel             EquilibriumRefilling;
+   excessMassDistributionModel   EvenlyAllInterface;
+   curvatureModel                FiniteDifferenceMethod;
+   enableForceWeighting          false;
+   useSimpleMassExchange         false;
+   cellConversionThreshold       1e-2;
+   cellConversionForceThreshold  1e-1;
+}
+
+EvaluationParameters
+{
+   performanceLogFrequency 10000;
+   evaluationFrequency     1000;
+   baseFolderName          eval;
+}
+
+StabilityChecker
+{
+   checkFrequency 0;
+   streamOutput   false;
+   vtkOutput      true;
+}
+
+BoundaryParameters
+{
+   // X
+   //Border { direction W;  walldistance -1; NoSlip{} }
+   //Border { direction E;  walldistance -1; NoSlip{} }
+
+   // Y
+   //Border { direction N;  walldistance -1; NoSlip{} }
+   //Border { direction S;  walldistance -1; NoSlip{} }
+
+   // Z
+   Border { direction T;  walldistance -1; NoSlip{} }
+   Border { direction B;  walldistance -1; NoSlip{} }
+}
+
+MeshOutputParameters
+{
+   writeFrequency 0;
+   baseFolder     mesh-out;
+}
+
+VTK
+{
+   fluid_field
+   {
+      writeFrequency       1000;
+      ghostLayers          0;
+      baseFolder           vtk-out;
+      samplingResolution   1;
+
+      writers
+      {
+         fill_level;
+         mapped_flag;
+         velocity;
+         density;
+         //curvature;
+         //normal;
+         //obstacle_normal;
+         //pdf;
+         //flag;
+         //force;
+      }
+
+      CellBB_filter {
+         min < 0, 29, 0 >;
+         max < 3200, 30, 160 >;
+      }
+
+      inclusion_filters
+      {
+         CellBB_filter;
+         //liquidInterfaceFilter; // only include liquid and interface cells in VTK output
+      }
+
+      before_functions
+      {
+         //ghost_layer_synchronization; // only needed if writing the ghost layer
+         gas_cell_zero_setter;          // sets velocity=0 and density=1 all gas cells before writing VTK output
+      }
+   }
+   domain_decomposition
+   {
+      writeFrequency             10000000000;
+      baseFolder                 vtk-out;
+      outputDomainDecomposition  true;
+   }
+}
diff --git a/apps/showcases/Antidunes/AntidunesBoundaryHandling.h b/apps/showcases/Antidunes/AntidunesBoundaryHandling.h
new file mode 100644
index 0000000000000000000000000000000000000000..f210d8e74f3a638ddfa691f3c1cac05bba14acd8
--- /dev/null
+++ b/apps/showcases/Antidunes/AntidunesBoundaryHandling.h
@@ -0,0 +1,207 @@
+//======================================================================================================================
+//
+//  This file is part of waLBerla. waLBerla is free software: you can
+//  redistribute it and/or modify it under the terms of the GNU General Public
+//  License as published by the Free Software Foundation, either version 3 of
+//  the License, or (at your option) any later version.
+//
+//  waLBerla is distributed in the hope that it will be useful, but WITHOUT
+//  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+//  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+//  for more details.
+//
+//  You should have received a copy of the GNU General Public License along
+//  with waLBerla (see COPYING.txt). If not, see <http://www.gnu.org/licenses/>.
+//
+//! \file Boundary.h
+//! \ingroup free_surface
+//! \author Martin Bauer
+//! \author Christoph Schwarzmeier <christoph.schwarzmeier@fau.de>
+//! \brief Boundary handling for the free surface LBM module.
+//
+//======================================================================================================================
+
+#pragma once
+
+#include "blockforest/StructuredBlockForest.h"
+#include "blockforest/communication/UniformBufferedScheme.h"
+
+#include "boundary/BoundaryHandling.h"
+
+#include "field/GhostLayerField.h"
+
+#include "geometry/initializer/InitializationManager.h"
+#include "geometry/initializer/OverlapFieldFromBody.h"
+
+#include "lbm/boundary/all.h"
+#include "lbm/field/PdfField.h"
+#include "lbm/free_surface/FlagInfo.h"
+#include "lbm/free_surface/InitFunctions.h"
+#include "lbm/free_surface/InterfaceFromFillLevel.h"
+#include "lbm/free_surface/boundary/SimplePressureWithFreeSurface.h"
+
+#include "lbm_mesapd_coupling/momentum_exchange_method/boundary/CurvedLinear.h"
+#include "lbm_mesapd_coupling/momentum_exchange_method/boundary/SimpleBB.h"
+
+namespace walberla
+{
+namespace antidunes
+{
+namespace free_surface
+{
+/***********************************************************************************************************************
+ * Boundary handling for the free surface LBM extension.
+ **********************************************************************************************************************/
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+class AntidunesBoundaryHandling
+{
+ public:
+   using flag_t    = typename FlagField_T::value_type;
+   using Stencil_T = typename LatticeModel_T::Stencil;
+   using CommunicationStencil_T =
+      typename std::conditional< LatticeModel_T::Stencil::D == uint_t(2), stencil::D2Q9, stencil::D3Q27 >::type;
+   using PdfField_T = lbm::PdfField< LatticeModel_T >;
+
+   // boundary
+   using NoSlip_T   = lbm::NoSlip< LatticeModel_T, flag_t >;
+   using FreeSlip_T = lbm::FreeSlip< LatticeModel_T, FlagField_T >;
+   using UBB_T      = lbm::UBB< LatticeModel_T, flag_t >;
+   using Pressure_T = walberla::free_surface::SimplePressureWithFreeSurface< LatticeModel_T, FlagField_T >;
+   using Outlet_T   = lbm::Outlet< LatticeModel_T, FlagField_T, 4, 3 >;
+   using UBB_Inflow_T =
+      lbm::UBB< LatticeModel_T, flag_t >; // creates interface cells in the direction of the prescribed velocity, i.e.,
+                                          // represents an inflow boundary condition
+   using MovingObstacle_T = lbm_mesapd_coupling::SimpleBB< LatticeModel_T, FlagField_T, ParticleAccessor_T >;
+
+   // handling type
+   using BoundaryHandling_T =
+      BoundaryHandling< FlagField_T, Stencil_T, NoSlip_T, UBB_T, UBB_Inflow_T, Pressure_T, Pressure_T, Outlet_T,
+                        FreeSlip_T,
+                        MovingObstacle_T >; // 2 pressure boundaries with different densities, e.g., inflow-outflow
+
+   using FlagInfo_T = walberla::free_surface::FlagInfo< FlagField_T >;
+
+   // constructor
+   AntidunesBoundaryHandling(const std::shared_ptr< StructuredBlockForest >& blockForest, BlockDataID pdfFieldID,
+                             BlockDataID fillLevelID, BlockDataID particleFieldID,
+                             const shared_ptr< ParticleAccessor_T >& ac,
+                             std::function< real_t(const Vector3< real_t >&) > hydrostaticDensityFct);
+
+   // initialize fluid field from config file using (quotes indicate the string to be used in the file):
+   // - "CellInterval" (see src/geometry/initializer/BoundaryFromCellInterval.h)
+   // - "Border" (see src/geometry/initializer/BoundaryFromDomainBorder.h)
+   // - "Image" (see src/geometry/initializer/BoundaryFromImage.h)
+   // - "Body" (see src/geometry/initializer/OverlapFieldFromBody.h)
+   inline void initFromConfig(const Config::BlockHandle& block);
+
+   // initialize free surface object from geometric body (see src/geometry/initializer/OverlapFieldFromBody.h)
+   template< typename Body_T >
+   inline void addFreeSurfaceObject(const Body_T& body, bool addOrSubtract = false);
+
+   // clear and initialize flags in every cell according to the fill level and initialize fill level in boundaries (with
+   // value 1) and obstacles such that the bubble model does not detect obstacles as gas cells
+   void initFlagsFromFillLevel();
+
+   inline void setNoSlipAtBorder(stencil::Direction d, cell_idx_t wallDistance = cell_idx_c(0));
+   inline void setNoSlipAtAllBorders(cell_idx_t wallDistance = cell_idx_c(0));
+   void setNoSlipInCell(const Cell& globalCell);
+
+   inline void setFreeSlipAtBorder(stencil::Direction d, cell_idx_t wallDistance = cell_idx_c(0));
+   inline void setFreeSlipAtAllBorders(cell_idx_t wallDistance = cell_idx_c(0));
+   void setFreeSlipInCell(const Cell& globalCell);
+
+   void setUBBInCell(const Cell& globalCell, const Vector3< real_t >& velocity);
+
+   // UBB that generates interface cells to resemble an inflow boundary
+   void setInflowInCell(const Cell& globalCell, const Vector3< real_t >& velocity);
+
+   inline void setPressure(real_t density);
+   void setPressureOutflow(real_t density);
+   void setBodyForce(const Vector3< real_t >& bodyForce);
+
+   void enableBubbleOutflow(BubbleModelBase* bubbleModel);
+
+   // checks if an obstacle cell is located in an outermost ghost layer (corners are explicitly ignored, as they do not
+   // influence periodic communication)
+   Vector3< bool > isObstacleInGlobalGhostLayer();
+
+   // flag management
+   const walberla::free_surface::FlagInfo< FlagField_T >& getFlagInfo() const { return flagInfo_; }
+
+   // flag IDs
+   static const field::FlagUID noSlipFlagID;
+   static const field::FlagUID ubbFlagID;
+   static const field::FlagUID ubbInflowFlagID;
+   static const field::FlagUID pressureFlagID;
+   static const field::FlagUID pressureOutflowFlagID;
+   static const field::FlagUID outletFlagID;
+   static const field::FlagUID freeSlipFlagID;
+   static const field::FlagUID movingObstacleFlagID;
+
+   // boundary IDs
+   static const BoundaryUID noSlipBoundaryID;
+   static const BoundaryUID ubbBoundaryID;
+   static const BoundaryUID ubbInflowBoundaryID;
+   static const BoundaryUID pressureBoundaryID;
+   static const BoundaryUID pressureOutflowBoundaryID;
+   static const BoundaryUID outletBoundaryID;
+   static const BoundaryUID freeSlipBoundaryID;
+   static const BoundaryUID movingObstacleBoundaryID;
+
+   inline BlockDataID getHandlingID() const { return handlingID_; }
+   inline BlockDataID getPdfFieldID() const { return pdfFieldID_; }
+   inline BlockDataID getFillFieldID() const { return fillFieldID_; }
+   inline BlockDataID getFlagFieldID() const { return flagFieldID_; }
+   inline BlockDataID getParticleFieldID() const { return particleFieldID_; }
+
+   inline std::shared_ptr< StructuredBlockForest > getBlockForest() const { return blockForest_; };
+   inline shared_ptr< ParticleAccessor_T > getParticleAccessor() const { return ac_; }
+   inline std::function< real_t(const Vector3< real_t >&) > getHydrostaticDensityFct() const
+   {
+      return hydrostaticDensityFct_;
+   }
+
+   // executes standard waLBerla boundary handling
+   class ExecuteBoundaryHandling
+   {
+    public:
+      ExecuteBoundaryHandling(const BlockDataID& collection) : handlingID_(collection) {}
+      void operator()(IBlock* const block) const
+      {
+         BoundaryHandling_T* const handling = block->getData< BoundaryHandling_T >(handlingID_);
+         // reset "near boundary" flags
+         handling->refresh();
+         (*handling)();
+      }
+
+    protected:
+      BlockDataID handlingID_;
+   }; // class ExecuteBoundaryHandling
+
+   ExecuteBoundaryHandling getBoundarySweep() const { return ExecuteBoundaryHandling(getHandlingID()); }
+
+ private:
+   walberla::free_surface::FlagInfo< FlagField_T > flagInfo_;
+
+   // register standard waLBerla initializers
+   geometry::initializer::InitializationManager getInitManager();
+
+   std::shared_ptr< StructuredBlockForest > blockForest_;
+
+   BlockDataID flagFieldID_;
+   BlockDataID pdfFieldID_;
+   BlockDataID fillFieldID_;
+   BlockDataID particleFieldID_;
+   shared_ptr< ParticleAccessor_T > ac_;
+   std::function< real_t(const Vector3< real_t >&) > hydrostaticDensityFct_;
+
+   BlockDataID handlingID_;
+
+   blockforest::communication::UniformBufferedScheme< CommunicationStencil_T > comm_;
+}; // class AntidunesBoundaryHandling
+
+} // namespace free_surface
+} // namespace antidunes
+} // namespace walberla
+
+#include "AntidunesBoundaryHandling.impl.h"
diff --git a/apps/showcases/Antidunes/AntidunesBoundaryHandling.impl.h b/apps/showcases/Antidunes/AntidunesBoundaryHandling.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..3aa86f16a5e3d93bcf030e4235b898242767ea90
--- /dev/null
+++ b/apps/showcases/Antidunes/AntidunesBoundaryHandling.impl.h
@@ -0,0 +1,583 @@
+//======================================================================================================================
+//
+//  This file is part of waLBerla. waLBerla is free software: you can
+//  redistribute it and/or modify it under the terms of the GNU General Public
+//  License as published by the Free Software Foundation, either version 3 of
+//  the License, or (at your option) any later version.
+//
+//  waLBerla is distributed in the hope that it will be useful, but WITHOUT
+//  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+//  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+//  for more details.
+//
+//  You should have received a copy of the GNU General Public License along
+//  with waLBerla (see COPYING.txt). If not, see <http://www.gnu.org/licenses/>.
+//
+//! \file AntidunesBoundaryHandling.impl.h
+//! \ingroup free_surface
+//! \author Martin Bauer
+//! \author Christoph Schwarzmeier <christoph.schwarzmeier@fau.de>
+//! \brief Boundary handling for the free surface LBM module.
+//
+//======================================================================================================================
+
+#pragma once
+
+#include "field/AddToStorage.h"
+#include "field/FlagField.h"
+#include "field/communication/PackInfo.h"
+
+#include "geometry/initializer/BoundaryFromCellInterval.h"
+#include "geometry/initializer/BoundaryFromDomainBorder.h"
+#include "geometry/initializer/BoundaryFromImage.h"
+#include "geometry/structured/GrayScaleImage.h"
+
+#include "lbm/free_surface/FlagInfo.h"
+#include "lbm/free_surface/InterfaceFromFillLevel.h"
+#include "lbm/lattice_model/CollisionModel.h"
+
+#include "AntidunesBoundaryHandling.h"
+
+namespace walberla
+{
+namespace antidunes
+{
+namespace free_surface
+{
+namespace internal
+{
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+class BoundaryBlockDataHandling
+   : public domain_decomposition::BlockDataHandling< typename AntidunesBoundaryHandling<
+        LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::BoundaryHandling_T >
+{
+ public:
+   using BoundaryHandling_T =
+      typename AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T,
+                                          ParticleAccessor_T >::BoundaryHandling_T; // handling as defined in
+                                                                                    // AntidunesBoundaryHandling.h
+
+   BoundaryBlockDataHandling(
+      const AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >* boundary)
+      : boundary_(boundary)
+   {}
+
+   // initialize standard waLBerla boundary handling
+   BoundaryHandling_T* initialize(IBlock* const block)
+   {
+      using B      = AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >;
+      using flag_t = typename B::flag_t;
+
+      // get fields
+      FlagField_T* const flagField           = block->getData< FlagField_T >(boundary_->getFlagFieldID());
+      typename B::PdfField_T* const pdfField = block->getData< typename B::PdfField_T >(boundary_->getPdfFieldID());
+      lbm_mesapd_coupling::ParticleField_T* const particleField =
+         block->getData< lbm_mesapd_coupling::ParticleField_T >(boundary_->getParticleFieldID());
+
+      auto interfaceFlag = flag_t(flagField->getFlag(walberla::free_surface::flagIDs::interfaceFlagID));
+      auto liquidFlag    = flag_t(flagField->getFlag(walberla::free_surface::flagIDs::liquidFlagID));
+
+      // domainMask is used to identify liquid and interface cells
+      auto domainMask = flag_t(liquidFlag | interfaceFlag);
+      WALBERLA_ASSERT(domainMask != 0);
+
+      // initialize boundary conditions
+      typename B::UBB_T ubb(B::ubbBoundaryID, B::ubbFlagID, pdfField, flagField);
+      typename B::UBB_Inflow_T ubbInflow(B::ubbInflowBoundaryID, B::ubbInflowFlagID, pdfField, flagField);
+      typename B::NoSlip_T noslip(B::noSlipBoundaryID, B::noSlipFlagID, pdfField);
+      typename B::Pressure_T pressure(B::pressureBoundaryID, B::pressureFlagID, block, pdfField, flagField,
+                                      interfaceFlag, real_c(1.0));
+      typename B::Pressure_T pressureOutflow(B::pressureOutflowBoundaryID, B::pressureOutflowFlagID, block, pdfField,
+                                             flagField, interfaceFlag, real_c(1.0));
+      typename B::Outlet_T outlet(B::outletBoundaryID, B::outletFlagID, pdfField, flagField, domainMask);
+      typename B::FreeSlip_T freeSlip(B::freeSlipBoundaryID, B::freeSlipFlagID, pdfField, flagField, domainMask);
+      typename B::MovingObstacle_T curvedLinear(B::movingObstacleBoundaryID, B::movingObstacleFlagID, pdfField,
+                                                flagField, particleField, boundary_->getParticleAccessor(), domainMask,
+                                                *boundary_->getBlockForest(), *block,
+                                                boundary_->getHydrostaticDensityFct());
+
+      return new BoundaryHandling_T("Boundary Handling", flagField, domainMask, noslip, ubb, ubbInflow, pressure,
+                                    pressureOutflow, outlet, freeSlip, curvedLinear);
+   }
+
+   void serialize(IBlock* const block, const BlockDataID& id, mpi::SendBuffer& buffer)
+   {
+      BoundaryHandling_T* const boundaryHandlingPtr = block->getData< BoundaryHandling_T >(id);
+      CellInterval everyCell                        = boundaryHandlingPtr->getFlagField()->xyzSizeWithGhostLayer();
+      boundaryHandlingPtr->pack(buffer, everyCell, true);
+   }
+
+   BoundaryHandling_T* deserialize(IBlock* const block) { return initialize(block); }
+
+   void deserialize(IBlock* const block, const BlockDataID& id, mpi::RecvBuffer& buffer)
+   {
+      BoundaryHandling_T* const boundaryHandlingPtr = block->getData< BoundaryHandling_T >(id);
+      CellInterval everyCell                        = boundaryHandlingPtr->getFlagField()->xyzSizeWithGhostLayer();
+      boundaryHandlingPtr->unpack(buffer, everyCell, true);
+   }
+
+ private:
+   const AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >* boundary_;
+}; // class BoundaryBlockDataHandling
+
+// helper function wrapper for adding the flag field to the block storage; since the input parameter for an
+// initialization function in field::addFlagFieldToStorage() is a std::function<void(FlagField_T*,IBlock* const)>, we
+// need a function wrapper that has both these input parameters; as FlagInfo< FlagField_T >::registerFlags() does not
+// have both of these input parameters, a function wrapper with both input parameters is created and the second input
+// parameter is simply ignored inside the function wrapper
+template< typename FlagField_T >
+void flagFieldInitFunction(FlagField_T* flagField, IBlock* const, const Set< field::FlagUID >& obstacleIDs,
+                           const Set< field::FlagUID >& outflowIDs, const Set< field::FlagUID >& inflowIDs,
+                           const Set< field::FlagUID >& freeSlipIDs)
+{
+   // register flags in the flag field
+   walberla::free_surface::FlagInfo< FlagField_T >::registerFlags(flagField, obstacleIDs, outflowIDs, inflowIDs,
+                                                                  freeSlipIDs);
+}
+
+} // namespace internal
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::AntidunesBoundaryHandling(
+   const std::shared_ptr< StructuredBlockForest >& blockForest, BlockDataID pdfFieldID, BlockDataID fillLevelID,
+   BlockDataID particleFieldID, const shared_ptr< ParticleAccessor_T >& ac,
+   std::function< real_t(const Vector3< real_t >&) > hydrostaticDensityFct)
+   : blockForest_(blockForest), pdfFieldID_(pdfFieldID), fillFieldID_(fillLevelID), particleFieldID_(particleFieldID),
+     ac_(ac), hydrostaticDensityFct_(std::move(hydrostaticDensityFct)), comm_(blockForest)
+{
+   // initialize obstacleIDs
+   Set< FlagUID > obstacleIDs;
+   obstacleIDs += noSlipFlagID;
+   obstacleIDs += ubbFlagID;
+   obstacleIDs += ubbInflowFlagID;
+   obstacleIDs += pressureFlagID;
+   obstacleIDs += pressureOutflowFlagID;
+   obstacleIDs += freeSlipFlagID;
+   obstacleIDs += outletFlagID;
+   obstacleIDs += movingObstacleFlagID;
+
+   // initialize outflowIDs
+   Set< FlagUID > outflowIDs;
+   outflowIDs += pressureOutflowFlagID;
+   outflowIDs += outletFlagID;
+
+   // initialize outflowIDs
+   Set< FlagUID > inflowIDs;
+   inflowIDs += ubbInflowFlagID;
+
+   // initialize freeSlipIDs
+   Set< FlagUID > freeSlipIDs;
+   freeSlipIDs += freeSlipFlagID;
+
+   // create callable function wrapper with input arguments 1 and 2 unset, whereas arguments 3, 4 and 5 are set to be
+   // obstacleIDs, outflowIDs, and inflowIDs, respectively; this is necessary for field::addFlagFieldToStorage()
+   auto ffInitFunc = std::bind(internal::flagFieldInitFunction< FlagField_T >, std::placeholders::_1,
+                               std::placeholders::_2, obstacleIDs, outflowIDs, inflowIDs, freeSlipIDs);
+
+   // IMPORTANT REMARK: The flag field needs two ghost layers because of function advectMass(). There, the flags of all
+   // D3Q* neighbors are determined for each cell, including cells in the first ghost layer. Therefore, all D3Q*
+   // neighbors of the first ghost layer must be accessible. This requires a second ghost layer.
+   flagFieldID_ = field::addFlagFieldToStorage< FlagField_T >(blockForest, "Flags", uint_c(2), true, ffInitFunc);
+
+   // create FlagInfo
+   flagInfo_ = walberla::free_surface::FlagInfo< FlagField_T >(obstacleIDs, outflowIDs, inflowIDs, freeSlipIDs);
+   WALBERLA_ASSERT(flagInfo_.isConsistentAcrossBlocksAndProcesses(blockForest, flagFieldID_));
+
+   // add boundary handling to blockForest
+   handlingID_ = blockForest_->addBlockData(
+      std::make_shared<
+         internal::BoundaryBlockDataHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T > >(this),
+      "Boundary Handling");
+
+   // create communication object with fill level field, since fill levels determine the flags during the simulation
+   comm_.addPackInfo(std::make_shared< field::communication::PackInfo< ScalarField_T > >(fillFieldID_));
+}
+
+// define IDs (static const variables)
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const field::FlagUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::noSlipFlagID =
+      field::FlagUID("NoSlip");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const field::FlagUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::ubbFlagID =
+      field::FlagUID("UBB");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const field::FlagUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::ubbInflowFlagID =
+      field::FlagUID("UBB_Inflow");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const field::FlagUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::pressureFlagID =
+      field::FlagUID("Pressure");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const field::FlagUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::pressureOutflowFlagID =
+      field::FlagUID("PressureOutflow");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const field::FlagUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::outletFlagID =
+      field::FlagUID("Outlet");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const field::FlagUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::freeSlipFlagID =
+      field::FlagUID("FreeSlip");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const field::FlagUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::movingObstacleFlagID =
+      field::FlagUID("MovingObstacle");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const BoundaryUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::noSlipBoundaryID =
+      BoundaryUID("NoSlip");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const BoundaryUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::ubbBoundaryID =
+      BoundaryUID("UBB");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const BoundaryUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::ubbInflowBoundaryID =
+      BoundaryUID("UBB_Inflow");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const BoundaryUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::pressureBoundaryID =
+      BoundaryUID("Pressure");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const BoundaryUID AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T,
+                                             ParticleAccessor_T >::pressureOutflowBoundaryID =
+   BoundaryUID("PressureOutflow");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const BoundaryUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::outletBoundaryID =
+      BoundaryUID("Outlet");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const BoundaryUID
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::freeSlipBoundaryID =
+      BoundaryUID("FreeSlip");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+const BoundaryUID AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T,
+                                             ParticleAccessor_T >::movingObstacleBoundaryID =
+   BoundaryUID("MovingObstacle");
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+geometry::initializer::InitializationManager
+   AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::getInitManager()
+{
+   using namespace geometry::initializer;
+
+   InitializationManager initManager(blockForest_->getBlockStorage());
+
+   // define initializers
+   auto cellIntvInit = std::make_shared< BoundaryFromCellInterval< BoundaryHandling_T > >(*blockForest_, handlingID_);
+   auto borderInit   = std::make_shared< BoundaryFromDomainBorder< BoundaryHandling_T > >(*blockForest_, handlingID_);
+   auto imgInit =
+      std::make_shared< BoundaryFromImage< BoundaryHandling_T, geometry::GrayScaleImage > >(*blockForest_, handlingID_);
+   auto bodyInit = std::make_shared< OverlapFieldFromBody >(*blockForest_, fillFieldID_);
+
+   // register initializers
+   initManager.registerInitializer("CellInterval", cellIntvInit);
+   initManager.registerInitializer("Border", borderInit);
+   initManager.registerInitializer("Image", imgInit);
+   initManager.registerInitializer("Body", bodyInit);
+
+   return initManager;
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::initFromConfig(
+   const Config::BlockHandle& configBlock)
+{
+   // initialize from config file
+   getInitManager().init(configBlock);
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+template< typename Body_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::addFreeSurfaceObject(
+   const Body_T& body, bool addOrSubtract)
+{
+   geometry::initializer::OverlapFieldFromBody(*blockForest_, fillFieldID_).init(body, addOrSubtract);
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::setNoSlipAtBorder(
+   stencil::Direction d, cell_idx_t wallDistance)
+{
+   geometry::initializer::BoundaryFromDomainBorder< BoundaryHandling_T > init(*blockForest_, handlingID_);
+   init.init(noSlipFlagID, d, wallDistance);
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::setNoSlipAtAllBorders(
+   cell_idx_t wallDistance)
+{
+   geometry::initializer::BoundaryFromDomainBorder< BoundaryHandling_T > init(*blockForest_, handlingID_);
+   init.initAllBorders(noSlipFlagID, wallDistance);
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::setNoSlipInCell(
+   const Cell& globalCell)
+{
+   for (auto blockIt = blockForest_->begin(); blockIt != blockForest_->end(); ++blockIt)
+   {
+      BoundaryHandling_T* const handling = blockIt->template getData< BoundaryHandling_T >(handlingID_);
+
+      // transform cell in global coordinates to cell in block local coordinates
+      Cell blockLocalCell;
+      blockForest_->transformGlobalToBlockLocalCell(blockLocalCell, *blockIt, globalCell);
+
+      handling->forceBoundary(noSlipFlagID, blockLocalCell[0], blockLocalCell[1], blockLocalCell[2]);
+   }
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::setFreeSlipAtBorder(
+   stencil::Direction d, cell_idx_t wallDistance)
+{
+   geometry::initializer::BoundaryFromDomainBorder< BoundaryHandling_T > init(*blockForest_, handlingID_);
+   init.init(freeSlipFlagID, d, wallDistance);
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T,
+                                ParticleAccessor_T >::setFreeSlipAtAllBorders(cell_idx_t wallDistance)
+{
+   geometry::initializer::BoundaryFromDomainBorder< BoundaryHandling_T > init(*blockForest_, handlingID_);
+   init.initAllBorders(freeSlipFlagID, wallDistance);
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::setFreeSlipInCell(
+   const Cell& globalCell)
+{
+   for (auto blockIt = blockForest_->begin(); blockIt != blockForest_->end(); ++blockIt)
+   {
+      BoundaryHandling_T* const handling = blockIt->template getData< BoundaryHandling_T >(handlingID_);
+
+      // transform cell in global coordinates to cell in block local coordinates
+      Cell blockLocalCell;
+      blockForest_->transformGlobalToBlockLocalCell(blockLocalCell, *blockIt, globalCell);
+
+      handling->forceBoundary(freeSlipFlagID, blockLocalCell[0], blockLocalCell[1], blockLocalCell[2]);
+   }
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::setPressure(
+   real_t density)
+{
+   for (auto blockIt = blockForest_->begin(); blockIt != blockForest_->end(); ++blockIt)
+   {
+      BoundaryHandling_T* const handling = blockIt->template getData< BoundaryHandling_T >(handlingID_);
+      Pressure_T& pressure =
+         handling->template getBoundaryCondition< Pressure_T >(handling->getBoundaryUID(pressureFlagID));
+      pressure.setLatticeDensity(density);
+   }
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::setUBBInCell(
+   const Cell& globalCell, const Vector3< real_t >& velocity)
+{
+   for (auto blockIt = blockForest_->begin(); blockIt != blockForest_->end(); ++blockIt)
+   {
+      BoundaryHandling_T* const handling = blockIt->template getData< BoundaryHandling_T >(handlingID_);
+
+      typename UBB_Inflow_T::Velocity ubbVel(velocity);
+
+      // transform cell in global coordinates to cell in block-local coordinates
+      Cell blockLocalCell;
+      blockForest_->transformGlobalToBlockLocalCell(blockLocalCell, *blockIt, globalCell);
+
+      // get block cell bounding box to check if cell is contained in block
+      CellInterval blockCellBB = blockForest_->getBlockCellBB(*blockIt);
+
+      // flag field has two ghost layers so blockCellBB is actually larger than returned; this is relevant for setups
+      // where the UBB is set in a ghost layer cell
+      blockCellBB.expand(cell_idx_c(2));
+
+      if (blockCellBB.contains(globalCell))
+      {
+         handling->forceBoundary(ubbFlagID, blockLocalCell[0], blockLocalCell[1], blockLocalCell[2], ubbVel);
+      }
+   }
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::setInflowInCell(
+   const Cell& globalCell, const Vector3< real_t >& velocity)
+{
+   for (auto blockIt = blockForest_->begin(); blockIt != blockForest_->end(); ++blockIt)
+   {
+      BoundaryHandling_T* const handling = blockIt->template getData< BoundaryHandling_T >(handlingID_);
+
+      typename UBB_Inflow_T::Velocity ubbVel(velocity);
+
+      // transform cell in global coordinates to cell in block-local coordinates
+      Cell blockLocalCell;
+      blockForest_->transformGlobalToBlockLocalCell(blockLocalCell, *blockIt, globalCell);
+
+      // get block cell bounding box to check if cell is contained in block
+      CellInterval blockCellBB = blockForest_->getBlockCellBB(*blockIt);
+
+      // flag field has two ghost layers so blockCellBB is actually larger than returned; this is relevant for setups
+      // where the UBB is set in a ghost layer cell
+      blockCellBB.expand(cell_idx_c(2));
+
+      if (blockCellBB.contains(globalCell))
+      {
+         handling->forceBoundary(ubbInflowFlagID, blockLocalCell[0], blockLocalCell[1], blockLocalCell[2], ubbVel);
+      }
+   }
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::setPressureOutflow(
+   real_t density)
+{
+   for (auto blockIt = blockForest_->begin(); blockIt != blockForest_->end(); ++blockIt)
+   {
+      BoundaryHandling_T* const handling = blockIt->template getData< BoundaryHandling_T >(handlingID_);
+      Pressure_T& pressure =
+         handling->template getBoundaryCondition< Pressure_T >(handling->getBoundaryUID(pressureOutflowFlagID));
+      pressure.setLatticeDensity(density);
+   }
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T, ParticleAccessor_T >::enableBubbleOutflow(
+   BubbleModelBase* bubbleModel)
+{
+   for (auto blockIt = blockForest_->begin(); blockIt != blockForest_->end(); ++blockIt)
+   {
+      BoundaryHandling_T* const handling = blockIt->template getData< BoundaryHandling_T >(handlingID_);
+
+      // get pressure from boundary handling
+      Pressure_T& pressure =
+         handling->template getBoundaryCondition< Pressure_T >(handling->getBoundaryUID(pressureFlagID));
+      Pressure_T& pressureOutflow =
+         handling->template getBoundaryCondition< Pressure_T >(handling->getBoundaryUID(pressureOutflowFlagID));
+
+      // set pressure in bubble model
+      pressure.setBubbleModel(bubbleModel);
+      pressureOutflow.setBubbleModel(bubbleModel);
+   }
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+Vector3< bool > AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T,
+                                           ParticleAccessor_T >::isObstacleInGlobalGhostLayer()
+{
+   Vector3< bool > isObstacleInGlobalGhostLayer(false, false, false);
+
+   for (auto blockIt = blockForest_->begin(); blockIt != blockForest_->end(); ++blockIt)
+   {
+      const FlagField_T* const flagField = blockIt->template getData< const FlagField_T >(flagFieldID_);
+
+      const CellInterval domainCellBB = blockForest_->getDomainCellBB();
+
+      // disable OpenMP such that loop termination works correctly
+      WALBERLA_FOR_ALL_CELLS_INCLUDING_GHOST_LAYER_XYZ_OMP(flagField, uint_c(1), omp critical, {
+         // get cell in global coordinates
+         Cell globalCell = Cell(x, y, z);
+         blockForest_->transformBlockLocalToGlobalCell(globalCell, *blockIt);
+
+         // check if the current cell is located in a global ghost layer
+         const bool isCellInGlobalGhostLayerX =
+            globalCell[0] < domainCellBB.xMin() || globalCell[0] > domainCellBB.xMax();
+
+         const bool isCellInGlobalGhostLayerY =
+            globalCell[1] < domainCellBB.yMin() || globalCell[1] > domainCellBB.yMax();
+
+         const bool isCellInGlobalGhostLayerZ =
+            globalCell[2] < domainCellBB.zMin() || globalCell[2] > domainCellBB.zMax();
+
+         // skip corners, as they do not influence periodic communication
+         if ((isCellInGlobalGhostLayerX && (isCellInGlobalGhostLayerY || isCellInGlobalGhostLayerZ)) ||
+             (isCellInGlobalGhostLayerY && isCellInGlobalGhostLayerZ))
+         {
+            continue;
+         }
+
+         if (!isObstacleInGlobalGhostLayer[0] && isCellInGlobalGhostLayerX &&
+             isPartOfMaskSet(flagField->get(x, y, z), flagField->getMask(flagInfo_.getObstacleIDSet())))
+         {
+            isObstacleInGlobalGhostLayer[0] = true;
+         }
+
+         if (!isObstacleInGlobalGhostLayer[1] && isCellInGlobalGhostLayerY &&
+             isPartOfMaskSet(flagField->get(x, y, z), flagField->getMask(flagInfo_.getObstacleIDSet())))
+         {
+            isObstacleInGlobalGhostLayer[1] = true;
+         }
+
+         if (!isObstacleInGlobalGhostLayer[2] && isCellInGlobalGhostLayerZ &&
+             isPartOfMaskSet(flagField->get(x, y, z), flagField->getMask(flagInfo_.getObstacleIDSet())))
+         {
+            isObstacleInGlobalGhostLayer[2] = true;
+         }
+
+         if (isObstacleInGlobalGhostLayer[0] && isObstacleInGlobalGhostLayer[1] && isObstacleInGlobalGhostLayer[2])
+         {
+            break; // there is no need to check other cells on this block
+         }
+      }) // WALBERLA_FOR_ALL_CELLS_INCLUDING_GHOST_LAYER_XYZ_OMP
+   }
+
+   mpi::allReduceInplace(isObstacleInGlobalGhostLayer, mpi::LOGICAL_OR);
+
+   return isObstacleInGlobalGhostLayer;
+}
+
+template< typename LatticeModel_T, typename FlagField_T, typename ScalarField_T, typename ParticleAccessor_T >
+void AntidunesBoundaryHandling< LatticeModel_T, FlagField_T, ScalarField_T,
+                                ParticleAccessor_T >::initFlagsFromFillLevel()
+{
+   const Vector3< bool > isObstacleInGlobalGhostLayer = this->isObstacleInGlobalGhostLayer();
+
+   WALBERLA_ROOT_SECTION()
+   {
+      if ((blockForest_->isXPeriodic() && isObstacleInGlobalGhostLayer[0]) ||
+          (blockForest_->isYPeriodic() && isObstacleInGlobalGhostLayer[1]) ||
+          (blockForest_->isZPeriodic() && isObstacleInGlobalGhostLayer[2]))
+      {
+         WALBERLA_LOG_WARNING_ON_ROOT(
+            "WARNING: An obstacle cell is located in a global outermost ghost layer in a periodic "
+            "direction. Be aware that due to periodicity, this obstacle cell will be "
+            "overwritten during communication.");
+      }
+   }
+
+   // communicate fill level (neighborhood is used in initialization)
+   comm_();
+
+   // initialize fill level in boundaries (with value 1), i.e., obstacles such that the bubble model does not detect
+   // obstacles as gas cells
+   walberla::free_surface::initFillLevelsInBoundaries< BoundaryHandling_T, typename LatticeModel_T::Stencil,
+                                                       ScalarField_T >(blockForest_, handlingID_, fillFieldID_);
+
+   // clear and initialize flags in every cell according to the fill level
+   walberla::free_surface::initFlagsFromFillLevels< BoundaryHandling_T, typename LatticeModel_T::Stencil, FlagField_T,
+                                                    const ScalarField_T >(blockForest_, flagInfo_, handlingID_,
+                                                                          fillFieldID_);
+}
+
+} // namespace free_surface
+} // namespace antidunes
+} // namespace walberla
diff --git a/apps/showcases/Antidunes/AntidunesLatticeModelGeneration.py b/apps/showcases/Antidunes/AntidunesLatticeModelGeneration.py
new file mode 100644
index 0000000000000000000000000000000000000000..92376f290c3d1d54e4dc76bd0598689151ff16c4
--- /dev/null
+++ b/apps/showcases/Antidunes/AntidunesLatticeModelGeneration.py
@@ -0,0 +1,70 @@
+import sympy as sp
+
+from lbmpy.creationfunctions import LBMConfig, LBMOptimisation, create_lb_collision_rule
+from lbmpy.enums import ForceModel, Method, Stencil
+from lbmpy.stencils import LBStencil
+
+from pystencils_walberla import CodeGeneration
+from lbmpy_walberla import generate_lattice_model
+
+# general parameters
+generatedMethod = "Cumulants"  # "TRT", "SRT"
+stencilStr = "D3Q27"
+stencil = LBStencil(Stencil.D3Q19 if stencilStr == "D3Q19" else Stencil.D3Q27)
+force = sp.symbols("force_:3")
+layout = "fzyx"
+
+if generatedMethod == "Cumulants":
+    omega = sp.Symbol("omega")
+    # method definition
+    lbm_config = LBMConfig(
+        stencil=stencil,
+        method=Method.CUMULANT,
+        relaxation_rate=omega,
+        compressible=True,
+        force=force,
+        zero_centered=False,
+        streaming_pattern="pull",
+        galilean_correction=True if stencil == LBStencil(Stencil.D3Q27) else False,
+    )  # free surface implementation only works with pull pattern
+elif generatedMethod == "TRT":
+    omega_e = sp.Symbol("omega_e")
+    omega_o = sp.Symbol("omega_o")
+    # method definition
+    lbm_config = LBMConfig(
+        stencil=stencil,
+        method=Method.TRT,
+        smagorinsky=False,
+        relaxation_rates=[omega_e, omega_o],
+        compressible=True,
+        force=force,
+        force_model=ForceModel.GUO,
+        zero_centered=False,
+        streaming_pattern="pull",
+    )  # free surface implementation only works with pull pattern
+elif generatedMethod == "SRT":
+    omega = sp.Symbol("omega")
+    # method definition
+    lbm_config = LBMConfig(
+        stencil=stencil,
+        method=Method.SRT,
+        smagorinsky=True,
+        relaxation_rate=omega,
+        compressible=True,
+        force=force,
+        force_model=ForceModel.GUO,
+        zero_centered=False,
+        streaming_pattern="pull",
+    )  # free surface implementation only works with pull pattern
+
+# optimizations to be used by the code generator
+lbm_opt = LBMOptimisation(cse_global=True, field_layout=layout)
+
+collision_rule = create_lb_collision_rule(
+    lbm_config=lbm_config, lbm_optimisation=lbm_opt
+)
+
+with CodeGeneration() as ctx:
+    generate_lattice_model(
+        ctx, "AntidunesLatticeModel", collision_rule, field_layout=layout
+    )
diff --git a/apps/showcases/Antidunes/BedGeneration.cpp b/apps/showcases/Antidunes/BedGeneration.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..baac22f4d82bcd9ed161df5136c9919a13d83ac1
--- /dev/null
+++ b/apps/showcases/Antidunes/BedGeneration.cpp
@@ -0,0 +1,265 @@
+//======================================================================================================================
+//
+//  This file is part of waLBerla. waLBerla is free software: you can
+//  redistribute it and/or modify it under the terms of the GNU General Public
+//  License as published by the Free Software Foundation, either version 3 of
+//  the License, or (at your option) any later version.
+//
+//  waLBerla is distributed in the hope that it will be useful, but WITHOUT
+//  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+//  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+//  for more details.
+//
+//  You should have received a copy of the GNU General Public License along
+//  with waLBerla (see COPYING.txt). If not, see <http://www.gnu.org/licenses/>.
+//
+//! \file   BedGeneration.cpp
+//! \author Samuel Kemmler <samuel.kemmler@fau.de>
+//! \author Christoph Rettinger <christoph.rettinger@fau.de>
+//
+//======================================================================================================================
+
+#include "blockforest/Initialization.h"
+
+#include "core/Environment.h"
+#include "core/grid_generator/HCPIterator.h"
+#include "core/grid_generator/SCIterator.h"
+#include "core/math/Random.h"
+
+#include "mesa_pd/collision_detection/AnalyticContactDetection.h"
+#include "mesa_pd/data/DataTypes.h"
+#include "mesa_pd/data/LinkedCells.h"
+#include "mesa_pd/data/ParticleAccessorWithBaseShape.h"
+#include "mesa_pd/data/ParticleStorage.h"
+#include "mesa_pd/data/shape/Sphere.h"
+#include "mesa_pd/domain/BlockForestDomain.h"
+#include "mesa_pd/kernel/AssocToBlock.h"
+#include "mesa_pd/kernel/DoubleCast.h"
+#include "mesa_pd/kernel/ExplicitEuler.h"
+#include "mesa_pd/kernel/InsertParticleIntoLinkedCells.h"
+#include "mesa_pd/kernel/LinearSpringDashpot.h"
+#include "mesa_pd/kernel/ParticleSelector.h"
+#include "mesa_pd/mpi/ContactFilter.h"
+#include "mesa_pd/mpi/ReduceProperty.h"
+#include "mesa_pd/mpi/SyncNextNeighborsBlockForest.h"
+#include "mesa_pd/vtk/ParticleVtkOutput.h"
+
+#include "vtk/VTKOutput.h"
+
+#include <mesa_pd/mpi/notifications/ForceTorqueNotification.h>
+
+#include "Utility.h"
+
+namespace walberla
+{
+namespace antidunes
+{
+
+using namespace mesa_pd;
+
+int main(int argc, char** argv)
+{
+   Environment env(argc, argv);
+   walberla::mpi::MPIManager::instance()->useWorldComm();
+
+   // Config
+   auto cfg = env.config();
+   if (cfg == nullptr) WALBERLA_ABORT("No config specified!");
+   WALBERLA_LOG_INFO_ON_ROOT(*cfg);
+   const Config::BlockHandle bedGenerationConf = cfg->getBlock("BedGeneration");
+
+   Vec3 domainSize_SI             = bedGenerationConf.getParameter< Vec3 >("domainSize_SI");
+   Vector3< int > blocks          = bedGenerationConf.getParameter< Vector3< int > >("blocks");
+   real_t diameter_SI             = bedGenerationConf.getParameter< real_t >("diameter_SI");
+   real_t gravity_SI              = bedGenerationConf.getParameter< real_t >("gravity_SI");
+   real_t densityFluid_SI         = bedGenerationConf.getParameter< real_t >("densityFluid_SI");
+   real_t densityParticle_SI      = bedGenerationConf.getParameter< real_t >("densityParticle_SI");
+   real_t generationSpacing_SI    = bedGenerationConf.getParameter< real_t >("generationSpacing_SI");
+   real_t initialVelocity_SI      = bedGenerationConf.getParameter< real_t >("initialVelocity_SI");
+   real_t dt_SI                   = bedGenerationConf.getParameter< real_t >("dt_SI");
+   real_t frictionCoefficient     = bedGenerationConf.getParameter< real_t >("frictionCoefficient");
+   real_t restitutionCoefficient  = bedGenerationConf.getParameter< real_t >("restitutionCoefficient");
+   real_t collisionTime_SI        = bedGenerationConf.getParameter< real_t >("collisionTime_SI");
+   real_t poissonsRatio           = bedGenerationConf.getParameter< real_t >("poissonsRatio");
+   uint_t timeSteps               = bedGenerationConf.getParameter< uint_t >("timeSteps");
+   uint_t visSpacing              = bedGenerationConf.getParameter< uint_t >("visSpacing");
+   std::string outFileName        = bedGenerationConf.getParameter< std::string >("outFileName");
+   bool denseBottomLayer          = bedGenerationConf.getParameter< bool >("denseBottomLayer");
+   real_t bottomLayerOffsetFactor = bedGenerationConf.getParameter< real_t >("bottomLayerOffsetFactor");
+
+   // BlockForest
+   math::AABB simulationDomain_SI(real_t(0.0), real_t(0.0), real_t(0.0), domainSize_SI[0], domainSize_SI[1],
+                                  domainSize_SI[2]);
+   Vector3< bool > isPeriodic{ true, true, false };
+
+   shared_ptr< BlockForest > forest = blockforest::createBlockForest(simulationDomain_SI, blocks, isPeriodic);
+   auto domain                      = std::make_shared< mesa_pd::domain::BlockForestDomain >(forest);
+
+   // MesaPD data structures
+   auto ps = std::make_shared< data::ParticleStorage >(1);
+   data::ParticleAccessorWithBaseShape accessor(ps);
+
+   // Init spheres
+   real_t minDiameter_SI = diameter_SI * real_t(0.9);
+   real_t maxDiameter_SI = diameter_SI * real_t(1.1);
+
+   math::AABB generationDomain_SI(simulationDomain_SI.xMin(), simulationDomain_SI.yMin(),
+                                  simulationDomain_SI.zMin() + diameter_SI, simulationDomain_SI.xMax(),
+                                  simulationDomain_SI.yMax(), simulationDomain_SI.zMax());
+
+   for (auto pt :
+        grid_generator::SCGrid(generationDomain_SI, Vec3(generationSpacing_SI) * real_c(0.5), generationSpacing_SI))
+   {
+      auto diameter = math::realRandom< real_t >(minDiameter_SI, maxDiameter_SI);
+
+      if (!domain->isContainedInLocalSubdomain(pt, real_t(0))) continue;
+      auto p                       = ps->create();
+      p->getPositionRef()          = pt;
+      p->getInteractionRadiusRef() = diameter * real_t(0.5);
+      p->getBaseShapeRef()         = std::make_shared< data::Sphere >(p->getInteractionRadius());
+      p->getBaseShapeRef()->updateMassAndInertia(densityParticle_SI);
+
+      p->setLinearVelocity(Vec3(real_t(0.1) * math::realRandom(-initialVelocity_SI, initialVelocity_SI),
+                                real_t(0.1) * math::realRandom(-initialVelocity_SI, initialVelocity_SI),
+                                -initialVelocity_SI));
+      p->getOwnerRef() = walberla::mpi::MPIManager::instance()->rank();
+      p->getTypeRef()  = 0;
+   }
+
+   math::AABB bottomLayerDomain_SI(simulationDomain_SI.xMin(), simulationDomain_SI.yMin(), simulationDomain_SI.zMin(),
+                                   simulationDomain_SI.xMax(), simulationDomain_SI.yMax(), diameter_SI);
+
+   real_t bottomLayerSpacing = bottomLayerDomain_SI.xSize() / std::floor(bottomLayerDomain_SI.xSize() / diameter_SI);
+   real_t bottomLayerYStretchFactor =
+      real_t((bottomLayerDomain_SI.ySize() / (sqrt(3_r) * bottomLayerSpacing)) /
+             std::floor(bottomLayerDomain_SI.ySize() / (sqrt(3_r) * bottomLayerSpacing)));
+   if (denseBottomLayer)
+   {
+      bottomLayerYStretchFactor = real_t((bottomLayerDomain_SI.ySize() / (sqrt(3_r) * bottomLayerSpacing)) /
+                                         std::ceil(bottomLayerDomain_SI.ySize() / (sqrt(3_r) * bottomLayerSpacing)));
+   }
+   WALBERLA_LOG_INFO_ON_ROOT(bottomLayerSpacing << " " << bottomLayerYStretchFactor);
+   for (auto pt : grid_generator::HCPGrid(bottomLayerDomain_SI, Vec3(diameter_SI) * real_c(0.5), bottomLayerSpacing))
+   {
+      auto diameter = math::realRandom< real_t >(minDiameter_SI, maxDiameter_SI);
+      auto zCoord   = math::realRandom< real_t >(real_t(1e-10), diameter_SI * bottomLayerOffsetFactor);
+      Vec3 position{ pt[0], pt[1] * bottomLayerYStretchFactor, zCoord };
+
+      if (!domain->isContainedInLocalSubdomain(position, real_t(0))) continue;
+      auto p                       = ps->create();
+      p->getPositionRef()          = position;
+      p->getInteractionRadiusRef() = diameter * real_t(0.5);
+      p->getBaseShapeRef()         = std::make_shared< data::Sphere >(p->getInteractionRadius());
+      p->getBaseShapeRef()->updateMassAndInertia(densityParticle_SI);
+
+      p->getOwnerRef() = walberla::mpi::MPIManager::instance()->rank();
+      p->getTypeRef()  = 0;
+      mesa_pd::data::particle_flags::set(p->getFlagsRef(), mesa_pd::data::particle_flags::FIXED);
+   }
+
+   createPlane(*ps, simulationDomain_SI.minCorner(), Vec3(real_t(0), real_t(0), real_t(1)));
+   createPlane(*ps, simulationDomain_SI.maxCorner(), Vec3(real_t(0), real_t(0), real_t(-1)));
+
+   // VTK
+   auto vtkDomainOutput =
+      walberla::vtk::createVTKOutput_DomainDecomposition(forest, "domain_decomposition", 1, "vtk", "simulation_step");
+   vtkDomainOutput->write();
+
+   auto particleVtkOutput = make_shared< mesa_pd::vtk::ParticleVtkOutput >(ps);
+   particleVtkOutput->addOutput< mesa_pd::data::SelectParticleLinearVelocity >("velocity");
+   particleVtkOutput->addOutput< mesa_pd::data::SelectParticleInteractionRadius >("radius");
+   particleVtkOutput->setParticleSelector([](const data::ParticleStorage::iterator& pIt) {
+      using namespace walberla::mesa_pd::data::particle_flags;
+      return (pIt->getBaseShape()->getShapeType() == data::Sphere::SHAPE_TYPE) && !isSet(pIt->getFlags(), GHOST);
+   });
+   auto vtkWriter = walberla::vtk::createVTKOutput_PointData(particleVtkOutput, "Particles", 1, "vtk",
+                                                             "simulation_step", false, false);
+
+   // Init kernels
+   kernel::ExplicitEuler explicitEulerWithShape(dt_SI);
+   kernel::LinearSpringDashpot dem(1);
+   dem.setFrictionCoefficientDynamic(0, 0, frictionCoefficient);
+   real_t kappa = real_t(2) * (real_t(1) - poissonsRatio) / (real_t(2) - poissonsRatio); // from Thornton et al
+
+   kernel::AssocToBlock assoc(forest);
+   mesa_pd::mpi::ReduceProperty RP;
+   mesa_pd::mpi::SyncNextNeighborsBlockForest SNN;
+
+   ps->forEachParticle(false, kernel::SelectLocal(), accessor, assoc, accessor);
+
+   // initial sync
+   SNN(*ps, forest, domain);
+
+   real_t averageVelocity     = real_t(0);
+   uint_t currentNumParticles = 0;
+   real_t maxVelocity         = real_t(0);
+   real_t maxHeight           = real_t(0);
+
+   real_t linkedCellWidth = 1.01_r * maxDiameter_SI;
+   data::LinkedCells linkedCells(domain->getUnionOfLocalAABBs().getExtended(linkedCellWidth), linkedCellWidth);
+   kernel::InsertParticleIntoLinkedCells ipilc;
+
+   for (uint_t i = 0; i < timeSteps; ++i)
+   {
+      if (i % visSpacing == 0) { vtkWriter->write(); }
+
+      ps->forEachParticle(false, kernel::SelectLocal(), accessor, assoc, accessor);
+
+      SNN(*ps, forest, domain);
+
+      // gravity - buoyancy
+      ps->forEachParticle(
+         false, kernel::SelectLocal(), accessor,
+         [densityParticle_SI, densityFluid_SI, gravity_SI](const size_t idx, auto& ac) {
+            mesa_pd::addForceAtomic(
+               idx, ac, Vec3(0, 0, -(densityParticle_SI - densityFluid_SI) * ac.getVolume(idx) * gravity_SI));
+         },
+         accessor);
+
+      linkedCells.clear();
+      ps->forEachParticle(false, kernel::SelectAll(), accessor, ipilc, accessor, linkedCells);
+      linkedCells.forEachParticlePairHalf(
+         false, kernel::ExcludeInfiniteInfinite(), accessor,
+         [restitutionCoefficient, collisionTime_SI, kappa, domain, &dem, dt_SI](const size_t idx1, const size_t idx2,
+                                                                                auto& ac) {
+            kernel::DoubleCast double_cast;
+            mesa_pd::mpi::ContactFilter contact_filter;
+            collision_detection::AnalyticContactDetection acd;
+
+            if (double_cast(idx1, idx2, ac, acd, ac))
+            {
+               if (contact_filter(acd.getIdx1(), acd.getIdx2(), ac, acd.getContactPoint(), *domain))
+               {
+                  auto meff = real_t(1) / (ac.getInvMass(idx1) + ac.getInvMass(idx2));
+                  dem.setStiffnessAndDamping(0, 0, restitutionCoefficient, collisionTime_SI, kappa, meff);
+                  dem(acd.getIdx1(), acd.getIdx2(), ac, acd.getContactPoint(), acd.getContactNormal(),
+                      acd.getPenetrationDepth(), dt_SI);
+               }
+            }
+         },
+         accessor);
+
+      RP.operator()< ForceTorqueNotification >(*ps);
+
+      ps->forEachParticle(false, kernel::SelectLocal(), accessor, explicitEulerWithShape, accessor);
+
+      getAverageVelocity(accessor, averageVelocity, maxVelocity, currentNumParticles, maxHeight);
+
+      SNN(*ps, forest, domain);
+
+      if (i % 1000 == 0)
+      {
+         WALBERLA_LOG_INFO_ON_ROOT("Timestep " << i << " / " << timeSteps << ", average velocity = " << averageVelocity
+                                               << ", max velocity = " << maxVelocity << ", #particles = "
+                                               << currentNumParticles << ", max height = " << maxHeight);
+      }
+   }
+
+   writeSphereInformationToFile(outFileName, *ps, domainSize_SI);
+
+   return EXIT_SUCCESS;
+}
+} // namespace antidunes
+} // namespace walberla
+
+int main(int argc, char** argv) { return walberla::antidunes::main(argc, argv); }
diff --git a/apps/showcases/Antidunes/BedGeneration.prm b/apps/showcases/Antidunes/BedGeneration.prm
new file mode 100644
index 0000000000000000000000000000000000000000..af2ecf9645a560468350281c27319a140e2d5bc4
--- /dev/null
+++ b/apps/showcases/Antidunes/BedGeneration.prm
@@ -0,0 +1,20 @@
+BedGeneration{
+    domainSize_SI < 0.8, 0.015, 0.2 >;
+    blocks < 3, 3, 1 >;
+    diameter_SI 0.0029;
+    gravity_SI 9.81;
+    densityFluid_SI 1000;
+    densityParticle_SI 2550;
+    generationSpacing_SI 0.005;
+    initialVelocity_SI 1;
+    dt_SI 5e-5;
+    frictionCoefficient 0.5;
+    restitutionCoefficient 0.1;
+    collisionTime_SI 5e-4;
+    poissonsRatio 0.22;
+    timeSteps 10000;
+    visSpacing 100;
+    outFileName spheres_out.dat;
+    denseBottomLayer False;
+    bottomLayerOffsetFactor 1.0;
+}
diff --git a/apps/showcases/Antidunes/CMakeLists.txt b/apps/showcases/Antidunes/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..a0feea41cc2502dc568f9ee3aa2271e2932760e9
--- /dev/null
+++ b/apps/showcases/Antidunes/CMakeLists.txt
@@ -0,0 +1,16 @@
+waLBerla_link_files_to_builddir( *.prm )
+
+if( WALBERLA_BUILD_WITH_CODEGEN )
+   walberla_generate_target_from_python( NAME      AntidunesLatticeModelGeneration
+                                         FILE      AntidunesLatticeModelGeneration.py
+                                         OUT_FILES AntidunesLatticeModel.cpp AntidunesLatticeModel.h )
+
+   waLBerla_add_executable(NAME     Antidunes
+                           FILES    Antidunes.cpp PIDController.cpp
+                           DEPENDS  blockforest boundary core domain_decomposition field lbm mesa_pd lbm_mesapd_coupling
+                                    postprocessing timeloop vtk AntidunesLatticeModelGeneration)
+endif()
+
+waLBerla_add_executable(NAME     BedGeneration
+        FILES    BedGeneration.cpp
+        DEPENDS  blockforest core domain_decomposition mesa_pd vtk)
diff --git a/apps/showcases/Antidunes/PIDController.cpp b/apps/showcases/Antidunes/PIDController.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..83df9f470f30f259a4cdab8073ff4e67ff0f1f86
--- /dev/null
+++ b/apps/showcases/Antidunes/PIDController.cpp
@@ -0,0 +1,115 @@
+//======================================================================================================================
+/*!
+ *  \file   PIDController.cpp
+ */
+//======================================================================================================================
+
+#include "PIDController.h"
+
+#include <algorithm>
+#include <fstream>
+
+using namespace walberla;
+using walberla::real_t;
+
+PIDController::PIDController()
+   : commandVariable_(0), actuatingVariable_(0), proportionalGain_(0), derivateGain_(0), integralGain_(0), maxRamp_(0),
+     minActuatingVariable_(0), maxActuatingVariable_(0), errorIntegral_(0)
+{
+   std::fill(errorHistory_, errorHistory_ + sizeof(errorHistory_) / sizeof(real_t), real_t(0));
+}
+
+PIDController::PIDController(const real_t commandVariable, const real_t initialActuatingVariable,
+                             const real_t proportionalGain, const real_t derivateGain, const real_t integralGain,
+                             const real_t maxRamp, const real_t minActuatingVariable, const real_t maxActuatingVariable)
+   : commandVariable_(commandVariable), actuatingVariable_(initialActuatingVariable),
+     proportionalGain_(proportionalGain), derivateGain_(derivateGain), integralGain_(integralGain), maxRamp_(maxRamp),
+     minActuatingVariable_(minActuatingVariable), maxActuatingVariable_(maxActuatingVariable), errorIntegral_(0)
+{
+   std::fill(errorHistory_, errorHistory_ + sizeof(errorHistory_) / sizeof(real_t), real_t(0));
+
+   if (integralGain_ > real_t(0))
+      errorIntegral_ = initialActuatingVariable / integralGain_;
+   else
+      errorIntegral_ = real_t(0);
+}
+
+PIDController::PIDController(const real_t commandVariable, const real_t initialActuatingVariable,
+                             const real_t proportionalGain, const real_t derivateGain, const real_t integralGain)
+   : commandVariable_(commandVariable), actuatingVariable_(initialActuatingVariable),
+     proportionalGain_(proportionalGain), derivateGain_(derivateGain), integralGain_(integralGain),
+     maxRamp_(std::numeric_limits< real_t >::max()), minActuatingVariable_(-std::numeric_limits< real_t >::max()),
+     maxActuatingVariable_(std::numeric_limits< real_t >::max()), errorIntegral_(0)
+{
+   std::fill(errorHistory_, errorHistory_ + sizeof(errorHistory_) / sizeof(real_t), real_t(0));
+
+   if (integralGain_ > real_t(0))
+      errorIntegral_ = initialActuatingVariable / integralGain_;
+   else
+      errorIntegral_ = real_t(0);
+}
+
+real_t PIDController::update(const real_t controlledVariable)
+{
+   static const real_t ONE_OVER_SIX = real_t(1) / real_t(6);
+   const real_t error               = commandVariable_ - controlledVariable;
+
+   const real_t d =
+      (error + real_t(3) * errorHistory_[0] - real_t(3) * errorHistory_[1] - errorHistory_[2]) * ONE_OVER_SIX;
+   std::rotate(errorHistory_, errorHistory_ + 1, errorHistory_ + sizeof(errorHistory_) / sizeof(real_t));
+   errorHistory_[sizeof(errorHistory_) / sizeof(real_t) - size_t(1)] = error;
+
+   real_t newActuationVariable = proportionalGain_ * error + derivateGain_ * d + integralGain_ * errorIntegral_;
+
+   if (std::fabs(actuatingVariable_ - newActuationVariable) < maxRamp_)
+   {
+      errorIntegral_ += error;
+      newActuationVariable = proportionalGain_ * error + derivateGain_ * d + integralGain_ * errorIntegral_;
+   }
+
+   const real_t maxValue = std::min(actuatingVariable_ + maxRamp_, maxActuatingVariable_);
+   const real_t minValue = std::max(actuatingVariable_ - maxRamp_, minActuatingVariable_);
+
+   actuatingVariable_ = std::min(std::max(minValue, newActuationVariable), maxValue);
+
+   return actuatingVariable_;
+}
+
+void PIDController::writeStateToFile(std::string filename) const
+{
+   std::ofstream file;
+   file.open(filename);
+   file << std::setprecision(16);
+   file << commandVariable_ << std::endl;
+   file << actuatingVariable_ << std::endl;
+   file << proportionalGain_ << std::endl;
+   file << derivateGain_ << std::endl;
+   file << integralGain_ << std::endl;
+   file << maxRamp_ << std::endl;
+   file << minActuatingVariable_ << std::endl;
+   file << maxActuatingVariable_ << std::endl;
+   file << errorHistory_[0] << std::endl;
+   file << errorHistory_[1] << std::endl;
+   file << errorHistory_[2] << std::endl;
+   file << errorIntegral_ << std::endl;
+   file.close();
+}
+
+void PIDController::readStateFromFile(std::string filename)
+{
+   std::ifstream file;
+   file.open(filename);
+   file >> commandVariable_;
+   file >> actuatingVariable_;
+   file >> proportionalGain_;
+   file >> derivateGain_;
+   file >> integralGain_;
+   file >> maxRamp_;
+   file >> minActuatingVariable_;
+   file >> maxActuatingVariable_;
+   file >> errorHistory_[0];
+   file >> errorHistory_[1];
+   file >> errorHistory_[2];
+   file >> errorIntegral_;
+   file.close();
+}
diff --git a/apps/showcases/Antidunes/PIDController.h b/apps/showcases/Antidunes/PIDController.h
new file mode 100644
index 0000000000000000000000000000000000000000..aeae57260688bbeca012a240fac40209e94e2e55
--- /dev/null
+++ b/apps/showcases/Antidunes/PIDController.h
@@ -0,0 +1,51 @@
+//======================================================================================================================
+/*!
+ *  \file   PIDController.h
+ */
+//======================================================================================================================
+
+#pragma once
+
+#include <core/DataTypes.h>
+#include <core/logging/Logging.h>
+
+using namespace walberla;
+using walberla::real_t;
+
+class PIDController
+{
+ public:
+   PIDController();
+
+   PIDController(const real_t commandVariable, const real_t initialActuatingVariable, const real_t proportionalGain,
+                 const real_t derivateGain, const real_t integralGain, const real_t maxRamp,
+                 const real_t minActuatingVariable, const real_t maxActuatingVariable);
+
+   PIDController(const real_t commandVariable, const real_t initialActuatingVariable, const real_t proportionalGain,
+                 const real_t derivateGain, const real_t integralGain);
+
+   real_t update(const real_t controlledVariable);
+
+   real_t getProportionalGain() const { return proportionalGain_; }
+   real_t getDerivateGain() const { return derivateGain_; }
+   real_t getIntegralGain() const { return integralGain_; }
+
+   void writeStateToFile(std::string filename) const;
+
+   void readStateFromFile(std::string filename);
+
+ private:
+   real_t commandVariable_;
+   real_t actuatingVariable_;
+
+   real_t proportionalGain_;
+   real_t derivateGain_;
+   real_t integralGain_;
+
+   real_t maxRamp_;
+   real_t minActuatingVariable_;
+   real_t maxActuatingVariable_;
+
+   real_t errorHistory_[3];
+   real_t errorIntegral_;
+};
diff --git a/apps/showcases/Antidunes/Utility.h b/apps/showcases/Antidunes/Utility.h
new file mode 100644
index 0000000000000000000000000000000000000000..e3e0e2149a01f26ee097287494d2d982ef9025c3
--- /dev/null
+++ b/apps/showcases/Antidunes/Utility.h
@@ -0,0 +1,472 @@
+//======================================================================================================================
+//
+//  This file is part of waLBerla. waLBerla is free software: you can
+//  redistribute it and/or modify it under the terms of the GNU General Public
+//  License as published by the Free Software Foundation, either version 3 of
+//  the License, or (at your option) any later version.
+//
+//  waLBerla is distributed in the hope that it will be useful, but WITHOUT
+//  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+//  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+//  for more details.
+//
+//  You should have received a copy of the GNU General Public License along
+//  with waLBerla (see COPYING.txt). If not, see <http://www.gnu.org/licenses/>.
+//
+//! \file   Utility.h
+//! \author Samuel Kemmler <samuel.kemmler@fau.de>
+//! \author Christoph Rettinger <christoph.rettinger@fau.de>
+//
+//======================================================================================================================
+
+#pragma once
+
+#include "mesa_pd/data/ParticleStorage.h"
+#include "mesa_pd/data/shape/Sphere.h"
+
+#include <algorithm>
+#include <core/mpi/Broadcast.h>
+#include <core/mpi/MPITextFile.h>
+#include <core/mpi/Reduce.h>
+#include <functional>
+#include <iterator>
+
+namespace walberla
+{
+namespace antidunes
+{
+
+struct SphereSelector
+{
+   template< typename ParticleAccessor_T >
+   bool inline operator()(const size_t particleIdx, const ParticleAccessor_T& ac) const
+   {
+      static_assert(std::is_base_of< mesa_pd::data::IAccessor, ParticleAccessor_T >::value,
+                    "Provide a valid accessor as template");
+      return ac.getBaseShape(particleIdx)->getShapeType() == mesa_pd::data::Sphere::SHAPE_TYPE;
+   }
+};
+
+void renameFile(const std::string& oldName, const std::string& newName)
+{
+   int result = std::rename(oldName.c_str(), newName.c_str());
+   if (result != 0)
+      WALBERLA_LOG_WARNING_ON_ROOT("Could not rename file " << oldName << " to " << newName << " with error code "
+                                                            << result);
+}
+
+void write2DVectorToFile(const std::vector< real_t >& vec, uint_t len1, uint_t len2, std::string filename)
+{
+   std::ofstream file;
+   file.open(filename.c_str());
+   file.precision(5);
+
+   file << "# " << len1 << " " << len2 << "\n";
+
+   for (uint_t j = uint_t(0); j < len2; ++j)
+   {
+      for (uint_t i = uint_t(0); i < len1; ++i)
+      {
+         file << vec[i + j * len1] << "\n";
+      }
+   }
+   file.close();
+}
+
+template< typename ParticleAccessor_T >
+class BedloadTransportEvaluator
+{
+ public:
+   BedloadTransportEvaluator(const shared_ptr< ParticleAccessor_T >& ac, real_t normalizationFactor,
+                             uint_t numParticles)
+      : ac_(ac), normalizationFactor_(normalizationFactor), numParticles_(numParticles)
+   {}
+
+   void operator()()
+   {
+      real_t transportRate(real_t(0));
+      real_t velocity(real_t(0));
+
+      for (uint_t i = uint_t(0); i < ac_->size(); ++i)
+      {
+         if (!isSet(ac_->getFlags(i), mesa_pd::data::particle_flags::GHOST) &&
+             !isSet(ac_->getFlags(i), mesa_pd::data::particle_flags::GLOBAL))
+         {
+            auto velX = ac_->getLinearVelocity(i)[0];
+            transportRate += velX * ac_->getVolume(i);
+            velocity += velX;
+         }
+      }
+
+      // only reduce to root
+      WALBERLA_MPI_SECTION()
+      {
+         mpi::reduceInplace(transportRate, mpi::SUM);
+         mpi::reduceInplace(velocity, mpi::SUM);
+      }
+
+      avgTransportRate_ = transportRate * normalizationFactor_;
+      averageVelocity_  = velocity / real_c(numParticles_);
+   }
+
+   // sum_i V_p,i * u_p,i / (L*W)
+   real_t getTransportRate() const { return avgTransportRate_; }
+
+   real_t getAverageVelocity() const { return averageVelocity_; }
+
+ private:
+   shared_ptr< ParticleAccessor_T > ac_;
+   real_t normalizationFactor_;
+   uint_t numParticles_;
+   real_t averageVelocity_;
+   real_t avgTransportRate_;
+};
+
+template< typename ParticleAccessor_T >
+Vector3< real_t > getTotalHydrodynamicForceOnParticles(const shared_ptr< ParticleAccessor_T >& ac)
+{
+   Vector3< real_t > totalHydrodynamicForce(0_r);
+   for (uint_t i = uint_t(0); i < ac->size(); ++i)
+   {
+      if (!isSet(ac->getFlags(i), mesa_pd::data::particle_flags::GHOST) &&
+          !isSet(ac->getFlags(i), mesa_pd::data::particle_flags::GLOBAL))
+      {
+         totalHydrodynamicForce += ac->getHydrodynamicForce(i);
+      }
+   }
+   // only reduce to root
+   mpi::reduceInplace(totalHydrodynamicForce, mpi::SUM);
+
+   return totalHydrodynamicForce;
+}
+
+// evaluates slices of solid volume fraction and fill level
+template< typename PdfField_T, typename AntidunesBoundaryHandling_T, typename FlagField_T, typename ScalarField_T >
+class AverageDataSliceEvaluator
+{
+ public:
+   AverageDataSliceEvaluator(const shared_ptr< StructuredBlockStorage >& blocks, const ConstBlockDataID& flagFieldID,
+                             const ConstBlockDataID& fillFieldID, const ConstBlockDataID& pdfFieldID)
+      : blocks_(blocks), flagFieldID_(flagFieldID), fillFieldID_(fillFieldID), pdfFieldID_(pdfFieldID)
+   {
+      xlen_ = blocks_->getNumberOfXCells();
+      ylen_ = blocks_->getNumberOfYCells();
+      zlen_ = blocks_->getNumberOfZCells();
+
+      x_z_SolidVolumeFraction_ = std::vector< real_t >(xlen_ * zlen_, real_t(0));
+      x_z_FillLevel_           = std::vector< real_t >(xlen_ * zlen_, real_t(0));
+      x_z_VelocityX_           = std::vector< real_t >(xlen_ * zlen_, real_t(0));
+      x_z_FluidCellCount_      = std::vector< uint_t >(xlen_ * zlen_, 0);
+      maxFluidZPos_            = uint_t(0);
+   }
+
+   void operator()()
+   {
+      // erase data
+      std::fill(x_z_SolidVolumeFraction_.begin(), x_z_SolidVolumeFraction_.end(), real_t(0));
+      std::fill(x_z_FillLevel_.begin(), x_z_FillLevel_.end(), real_t(0));
+      std::fill(x_z_VelocityX_.begin(), x_z_VelocityX_.end(), real_t(0));
+      std::fill(x_z_FluidCellCount_.begin(), x_z_FluidCellCount_.end(), 0);
+
+      // fill contributions
+      for (auto block = blocks_->begin(); block != blocks_->end(); ++block)
+      {
+         const PdfField_T* const pdfField     = block->getData< const PdfField_T >(pdfFieldID_);
+         const FlagField_T* const flagField   = block->getData< const FlagField_T >(flagFieldID_);
+         const ScalarField_T* const fillField = block->getData< const ScalarField_T >(fillFieldID_);
+
+         const auto solidMO     = flagField->getFlag(AntidunesBoundaryHandling_T::movingObstacleFlagID);
+         const auto solidNoSlip = flagField->getFlag(AntidunesBoundaryHandling_T::noSlipFlagID);
+
+         CellInterval xyz = flagField->xyzSize();
+         Cell globalCell;
+
+         maxFluidZPos_ = uint_t(0);
+
+         // iterate all (inner) cells in the field
+         for (auto cell = xyz.begin(); cell != xyz.end(); ++cell)
+         {
+            blocks_->transformBlockLocalToGlobalCell(globalCell, *block, *cell);
+            auto entryIdx = uint_c(globalCell.x()) + uint_c(globalCell.z()) * xlen_;
+            if (flagField->isFlagSet(*cell, solidMO) || flagField->isFlagSet(*cell, solidNoSlip))
+            {
+               x_z_SolidVolumeFraction_[entryIdx] += real_t(1);
+               x_z_FillLevel_[entryIdx] += real_t(1);
+            }
+            else
+            {
+               auto fillLevel = fillField->get(*cell);
+               x_z_FillLevel_[entryIdx] += fillLevel;
+               if (fillLevel > 0_r)
+               {
+                  x_z_VelocityX_[entryIdx] += pdfField->getVelocity(*cell)[0];
+                  ++x_z_FluidCellCount_[entryIdx];
+
+                  maxFluidZPos_ = std::max(uint_t(globalCell.z()), maxFluidZPos_);
+               }
+            }
+         }
+      }
+
+      // reduce this information to the root process
+      mpi::reduceInplace(x_z_SolidVolumeFraction_, mpi::SUM);
+      mpi::reduceInplace(x_z_FillLevel_, mpi::SUM);
+      mpi::reduceInplace(x_z_VelocityX_, mpi::SUM);
+      mpi::reduceInplace(x_z_FluidCellCount_, mpi::SUM);
+      mpi::reduceInplace(maxFluidZPos_, mpi::MAX);
+
+      // normalize
+      for (uint_t i = 0; i < x_z_VelocityX_.size(); ++i)
+      {
+         if (x_z_FluidCellCount_[i] > 0) x_z_VelocityX_[i] /= real_c(x_z_FluidCellCount_[i]);
+      }
+      real_t invNumYCells = 1_r / real_c(ylen_);
+      std::for_each(x_z_SolidVolumeFraction_.begin(), x_z_SolidVolumeFraction_.end(),
+                    [invNumYCells](real_t& n) { n *= invNumYCells; });
+      std::for_each(x_z_FillLevel_.begin(), x_z_FillLevel_.end(), [invNumYCells](real_t& n) { n *= invNumYCells; });
+
+      // note: only root process has the complete information!
+   }
+
+   std::vector< real_t >& getSolidVolumeFractionVector() { return x_z_SolidVolumeFraction_; }
+   std::vector< real_t >& getFillLevelVector() { return x_z_FillLevel_; }
+   std::vector< real_t >& getVelocityXVector() { return x_z_VelocityX_; }
+   std::vector< uint_t >& getFluidCellCountVector() { return x_z_FluidCellCount_; }
+   uint_t getMaxFluidZPos() { return maxFluidZPos_; }
+
+   uint_t getXLen() const { return xlen_; }
+   uint_t getYLen() const { return ylen_; }
+   uint_t getZLen() const { return zlen_; }
+
+ private:
+   shared_ptr< StructuredBlockStorage > blocks_;
+   const ConstBlockDataID flagFieldID_;
+   const ConstBlockDataID fillFieldID_;
+   const ConstBlockDataID pdfFieldID_;
+
+   uint_t xlen_;
+   uint_t ylen_;
+   uint_t zlen_;
+   uint_t maxFluidZPos_;
+   std::vector< real_t > x_z_SolidVolumeFraction_;
+   std::vector< real_t > x_z_FillLevel_;
+   std::vector< real_t > x_z_VelocityX_;
+   std::vector< uint_t > x_z_FluidCellCount_;
+};
+
+void writeSphereInformationToFile(const std::string& filename, walberla::mesa_pd::data::ParticleStorage& ps,
+                                  Vector3< real_t >& domainSize, int precision = 12)
+{
+   std::ostringstream ossData;
+   ossData << std::setprecision(precision);
+
+   WALBERLA_ROOT_SECTION() { ossData << domainSize[0] << " " << domainSize[1] << " " << domainSize[2] << "\n"; }
+
+   for (auto pIt : ps)
+   {
+      using namespace walberla::mesa_pd::data;
+      if (pIt->getBaseShape()->getShapeType() != Sphere::SHAPE_TYPE) continue;
+      using namespace walberla::mesa_pd::data::particle_flags;
+      if (isSet(pIt->getFlags(), GHOST)) continue;
+      auto sp = static_cast< Sphere* >(pIt->getBaseShape().get());
+
+      auto position = pIt->getPosition();
+
+      ossData << pIt->getUid() << " " << position[0] << " " << position[1] << " " << position[2] << " "
+              << sp->getRadius() << '\n';
+   }
+
+   walberla::mpi::writeMPITextFile(filename, ossData.str());
+}
+
+void getAvgDiameterScalingFactor(const std::string& filename, const Vector3< uint_t >& domainSize,
+                                 const uint_t bedCopiesInX, const uint_t bedCopiesInY, real_t& avgDiameter,
+                                 real_t& scalingFactor)
+{
+   using namespace walberla;
+
+   std::string textFile;
+
+   WALBERLA_ROOT_SECTION()
+   {
+      std::ifstream t(filename.c_str());
+      if (!t) { WALBERLA_ABORT("Invalid input file " << filename << "\n"); }
+      std::stringstream buffer;
+      buffer << t.rdbuf();
+      textFile = buffer.str();
+
+      std::istringstream fileIss(textFile);
+      std::string line;
+
+      // first line contains generation domain sizes
+      std::getline(fileIss, line);
+      Vector3< real_t > generationDomainSize_SI(0_r);
+      std::istringstream firstLine(line);
+      firstLine >> generationDomainSize_SI[0] >> generationDomainSize_SI[1] >> generationDomainSize_SI[2];
+
+      real_t diameter_SI  = 0.0;
+      uint_t numParticles = 0;
+      while (std::getline(fileIss, line))
+      {
+         std::istringstream iss(line);
+
+         mesa_pd::data::ParticleStorage::uid_type uID;
+         mesa_pd::data::ParticleStorage::position_type pos(0_r);
+         walberla::real_t radius = 0;
+         iss >> uID >> pos[0] >> pos[1] >> pos[2] >> radius;
+         WALBERLA_CHECK_GREATER(radius, 0_r, "Invalid radius of " << radius << " found in input file!")
+
+         diameter_SI += 2_r * radius;
+
+         numParticles++;
+      }
+      diameter_SI /= real_t(numParticles);
+
+      scalingFactor = real_c(domainSize[0]) / (generationDomainSize_SI[0] * real_c(bedCopiesInX));
+      avgDiameter   = diameter_SI * scalingFactor;
+
+      WALBERLA_CHECK_EQUAL(uint_c(scalingFactor * generationDomainSize_SI[1] * real_c(bedCopiesInY)), domainSize[1],
+                           "Error: Generated bed with copies and simulation domain do not match!")
+   }
+
+   walberla::mpi::broadcastObject(scalingFactor);
+   walberla::mpi::broadcastObject(avgDiameter);
+}
+
+void initSpheresFromFile(const std::string& filename, walberla::mesa_pd::data::ParticleStorage& ps,
+                         const walberla::mesa_pd::domain::IDomain& domain, walberla::real_t density,
+                         const Vector3< uint_t >& domainSize,
+                         const std::function< bool(walberla::Vector3< real_t >) >& particleCreateFunction,
+                         math::AABB simulationDomain, uint_t bedCopiesInX, uint_t bedCopiesInY, uint_t& numParticles,
+                         real_t& maxParticleHeight, const real_t& scalingFactor)
+{
+   using namespace walberla;
+   using namespace walberla::mesa_pd;
+   using namespace walberla::mesa_pd::data;
+
+   auto rank = walberla::mpi::MPIManager::instance()->rank();
+
+   std::string textFile;
+
+   WALBERLA_ROOT_SECTION()
+   {
+      std::ifstream t(filename.c_str());
+      if (!t) { WALBERLA_ABORT("Invalid input file " << filename << "\n"); }
+      std::stringstream buffer;
+      buffer << t.rdbuf();
+      textFile = buffer.str();
+   }
+
+   walberla::mpi::broadcastObject(textFile);
+
+   std::istringstream fileIss(textFile);
+   std::string line;
+
+   // first line contains generation domain sizes
+   std::getline(fileIss, line);
+   Vector3< real_t > generationDomainSize_SI(0_r);
+   std::istringstream firstLine(line);
+   firstLine >> generationDomainSize_SI[0] >> generationDomainSize_SI[1] >> generationDomainSize_SI[2];
+   WALBERLA_LOG_DEVEL_VAR_ON_ROOT(generationDomainSize_SI)
+
+   WALBERLA_CHECK_EQUAL(uint_c(scalingFactor * generationDomainSize_SI[0] * real_c(bedCopiesInX)), domainSize[0],
+                        "Error: Generated bed with copies and simulation domain do not match in x!")
+   WALBERLA_CHECK_EQUAL(uint_c(scalingFactor * generationDomainSize_SI[1] * real_c(bedCopiesInY)), domainSize[1],
+                        "Error: Generated bed with copies and simulation domain do not match in y!")
+
+   numParticles      = 0;
+   maxParticleHeight = 0_r;
+
+   while (std::getline(fileIss, line))
+   {
+      std::istringstream iss(line);
+
+      data::ParticleStorage::uid_type uID;
+      data::ParticleStorage::position_type pos;
+      walberla::real_t radius;
+      iss >> uID >> pos[0] >> pos[1] >> pos[2] >> radius;
+      radius *= scalingFactor;
+
+      for (uint_t copyInYDir = 0; copyInYDir < bedCopiesInY; ++copyInYDir)
+      {
+         for (uint_t copyInXDir = 0; copyInXDir < bedCopiesInX; ++copyInXDir)
+         {
+            auto particlePos = pos;
+
+            particlePos[0] += real_c(copyInXDir) * generationDomainSize_SI[0];
+            particlePos[1] += real_c(copyInYDir) * generationDomainSize_SI[1];
+
+            particlePos *= scalingFactor;
+
+            maxParticleHeight = std::max(maxParticleHeight, particlePos[2] + radius);
+
+            if (!particleCreateFunction(particlePos)) continue;
+
+            WALBERLA_CHECK(simulationDomain.contains(particlePos),
+                           "Particle read from file is not contained in simulation domain");
+
+            if (!domain.isContainedInProcessSubdomain(uint_c(rank), particlePos)) continue;
+
+            auto pIt = ps.create();
+            pIt->setPosition(particlePos);
+            pIt->getBaseShapeRef() = std::make_shared< data::Sphere >(radius);
+            pIt->getBaseShapeRef()->updateMassAndInertia(density);
+            pIt->setInteractionRadius(radius);
+            pIt->setOwner(rank);
+            pIt->setType(0);
+
+            numParticles++;
+         }
+      }
+
+      WALBERLA_CHECK_EQUAL(iss.tellg(), -1);
+   }
+   walberla::mpi::allReduceInplace(maxParticleHeight, walberla::mpi::MAX);
+   walberla::mpi::allReduceInplace(numParticles, walberla::mpi::SUM);
+}
+
+void getAverageVelocity(const mesa_pd::data::ParticleAccessorWithBaseShape& ac, real_t& averageVelocity,
+                        real_t& maxVelocity, uint_t& numParticles, real_t& maxHeight)
+{
+   averageVelocity = real_t(0);
+   maxVelocity     = real_t(0);
+   numParticles    = uint_t(0);
+   maxHeight       = real_t(0);
+   for (uint_t i = 0; i < ac.size(); ++i)
+   {
+      if (isSet(ac.getFlags(i), walberla::mesa_pd::data::particle_flags::GHOST)) continue;
+      if (isSet(ac.getFlags(i), walberla::mesa_pd::data::particle_flags::GLOBAL)) continue;
+
+      ++numParticles;
+      real_t velMagnitude = ac.getLinearVelocity(i).length();
+      averageVelocity += velMagnitude;
+      maxVelocity = std::max(maxVelocity, velMagnitude);
+      maxHeight   = std::max(maxHeight, ac.getPosition(i)[2]);
+   }
+
+   walberla::mpi::allReduceInplace(numParticles, walberla::mpi::SUM);
+   walberla::mpi::allReduceInplace(averageVelocity, walberla::mpi::SUM);
+   walberla::mpi::allReduceInplace(maxVelocity, walberla::mpi::MAX);
+   walberla::mpi::allReduceInplace(maxHeight, walberla::mpi::MAX);
+
+   averageVelocity /= real_t(numParticles);
+}
+
+auto createPlane(mesa_pd::data::ParticleStorage& ps, const mesa_pd::Vec3& pos, const mesa_pd::Vec3& normal)
+{
+   auto p0 = ps.create(true);
+   p0->setPosition(pos);
+   p0->setBaseShape(std::make_shared< mesa_pd::data::HalfSpace >(normal));
+   p0->getBaseShapeRef()->updateMassAndInertia(real_t(1));
+   p0->setOwner(walberla::mpi::MPIManager::instance()->rank());
+   p0->setType(0);
+   p0->setInteractionRadius(std::numeric_limits< real_t >::infinity());
+   mesa_pd::data::particle_flags::set(p0->getFlagsRef(), mesa_pd::data::particle_flags::GLOBAL);
+   mesa_pd::data::particle_flags::set(p0->getFlagsRef(), mesa_pd::data::particle_flags::INFINITE);
+   mesa_pd::data::particle_flags::set(p0->getFlagsRef(), mesa_pd::data::particle_flags::FIXED);
+   mesa_pd::data::particle_flags::set(p0->getFlagsRef(), mesa_pd::data::particle_flags::NON_COMMUNICATING);
+   return p0;
+}
+
+} // namespace antidunes
+} // namespace walberla
diff --git a/apps/showcases/Antidunes/pyvista.ipynb b/apps/showcases/Antidunes/pyvista.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..6cb28649aca82ce6aa152f0b56a3a49552f6c00a
--- /dev/null
+++ b/apps/showcases/Antidunes/pyvista.ipynb
@@ -0,0 +1,827 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "0953882f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pyvista as pv\n",
+    "import numpy as np\n",
+    "\n",
+    "import os\n",
+    "import sys"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "81395353",
+   "metadata": {},
+   "source": [
+    "## Plot initial setup"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ddf1b644",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fluid_file = \"/simdata/on74yces/2386423/vtk-out/fluid_field/simulation_step_0.vtu\"\n",
+    "particle_file = \"/simdata/on74yces/2386423/vtk-out/particles/simulation_step_0.vtu\"\n",
+    "\n",
+    "# taken from job output file\n",
+    "dx = 0.00025 \n",
+    "dt = 1.38e-5\n",
+    "\n",
+    "fluid_reader = pv.get_reader(fluid_file)\n",
+    "fluid_mesh = fluid_reader.read()\n",
+    "print(\"fluid_mesh:\", fluid_mesh.array_names)\n",
+    "\n",
+    "particle_reader = pv.get_reader(particle_file)\n",
+    "particle_mesh = particle_reader.read()\n",
+    "print(\"particle_mesh:\", particle_mesh.array_names)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "2b7bb742",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# clip meshes\n",
+    "fluid_mesh = fluid_mesh.clip(normal='x', origin=(2700,59,41.5), invert=False)\n",
+    "particle_mesh = particle_mesh.clip(normal='x', origin=(2700,59,41.5), invert=False)\n",
+    "\n",
+    "# remove gas data from fluid_mesh\n",
+    "fluid_mesh = fluid_mesh.threshold(value=2, scalars=\"mapped_flag\", invert=True)\n",
+    "\n",
+    "# convert velocity to SI units\n",
+    "velocity_unit_conversion = dx / dt\n",
+    "velocity_si = fluid_mesh.get_array(\"velocity\") * velocity_unit_conversion\n",
+    "fluid_mesh.add_field_data(velocity_si, \"velocity_si\")\n",
+    "\n",
+    "# convert particle radius to diameter in SI units\n",
+    "diameter_si = particle_mesh.point_data[\"radius\"] * 2 * dx\n",
+    "particle_mesh.point_data[\"diameter_si\"] = diameter_si\n",
+    "\n",
+    "# create glyphs for particles\n",
+    "sphere_glyphs = particle_mesh.glyph(scale=\"radius\", factor=1, \n",
+    "                                    geom=pv.Sphere(radius=1, theta_resolution=150, phi_resolution=150))\n",
+    "\n",
+    "# add box for outlining the domain\n",
+    "domain_box = pv.Box(bounds=(2700, 3200, 0, 60, 0, 160))\n",
+    "\n",
+    "# add box for outlining fixed particles\n",
+    "particles_fixed_box = pv.Box(bounds=(2700, 3200, 0, 60, 0, 17.3943))\n",
+    "\n",
+    "# add box for outlining the bed height\n",
+    "particles_box = pv.Box(bounds=(2700, 3200, 0, 60, 0, 48.0002))\n",
+    "\n",
+    "# add box for outlining the liquid height\n",
+    "liquid_box = pv.Box(bounds=(2700, 3200, 0, 60, 0, 82.3888))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b8a6aff5",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pl = pv.Plotter(lighting='three lights')\n",
+    "pl.add_mesh(fluid_mesh, scalars=\"velocity_si\", component=0)\n",
+    "pl.add_mesh(sphere_glyphs, scalars=\"diameter_si\", cmap=\"Greys\")\n",
+    "pl.add_mesh(domain_box.outline(), line_width=3, color=\"black\")\n",
+    "pl.add_mesh(particles_fixed_box.outline(), line_width=1, color=\"black\")\n",
+    "pl.add_mesh(particles_box.outline(), line_width=1, color=\"black\")\n",
+    "pl.add_mesh(liquid_box.outline(), line_width=1, color=\"black\")\n",
+    "\n",
+    "pl.view_isometric()\n",
+    "pl.enable_parallel_projection()\n",
+    "#pl.camera.roll += 0\n",
+    "pl.camera.elevation -= 15\n",
+    "pl.camera.azimuth -= 90\n",
+    "pl.remove_scalar_bar(\"velocity_si\")\n",
+    "pl.remove_scalar_bar(\"diameter_si\")\n",
+    "pl.set_background('white')     \n",
+    "#pl.show_axes()\n",
+    "pl.camera.zoom(1.75)\n",
+    "#pl.screenshot(\"/home/rzlin/ca36xymo/setup.png\", transparent_background=False, window_size=(2560, 1440))\n",
+    "pl.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "5d1248ea",
+   "metadata": {},
+   "source": [
+    "## Plot velocity field (side view)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6ca340e6",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fluid_file = \"/simdata/on74yces/2519434/vtk-out/fluid_field/simulation_step_4488000.vtu\"\n",
+    "particle_file = \"/simdata/on74yces/2519434/vtk-out/particles/simulation_step_4488000.vtu\"\n",
+    "\n",
+    "# taken from job output file\n",
+    "dx = 0.00025 \n",
+    "dt = 1.38e-5\n",
+    "\n",
+    "fluid_reader = pv.get_reader(fluid_file)\n",
+    "fluid_mesh = fluid_reader.read()\n",
+    "print(\"fluid_mesh:\", fluid_mesh.array_names)\n",
+    "\n",
+    "particle_reader = pv.get_reader(particle_file)\n",
+    "particle_mesh = particle_reader.read()\n",
+    "print(\"particle_mesh:\", particle_mesh.array_names)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4f41650a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# clip meshes\n",
+    "fluid_mesh = fluid_mesh.clip(normal='x', origin=(2200,59,41.5), invert=False)\n",
+    "particle_mesh = particle_mesh.clip(normal='x', origin=(2200,59,41.5), invert=False)\n",
+    "fluid_mesh = fluid_mesh.clip(normal='x', origin=(3000,59,41.5), invert=True)\n",
+    "particle_mesh = particle_mesh.clip(normal='x', origin=(3000,59,41.5), invert=True)\n",
+    "\n",
+    "# remove gas data from fluid_mesh\n",
+    "fluid_mesh = fluid_mesh.threshold(value=2, scalars=\"mapped_flag\", invert=True)\n",
+    "\n",
+    "# convert velocity to SI units\n",
+    "velocity_unit_conversion = dx / dt\n",
+    "velocity_si = fluid_mesh.get_array(\"velocity\") * velocity_unit_conversion\n",
+    "fluid_mesh.add_field_data(velocity_si, \"velocity_si\")\n",
+    "\n",
+    "# convert particle radius to diameter in SI units\n",
+    "diameter_si = particle_mesh.point_data[\"radius\"] * 2 * dx\n",
+    "particle_mesh.point_data[\"diameter_si\"] = diameter_si\n",
+    "\n",
+    "# create glyphs for particles\n",
+    "sphere_glyphs = particle_mesh.glyph(scale=\"radius\", factor=1, \n",
+    "                                    geom=pv.Sphere(radius=1, theta_resolution=150, phi_resolution=150))\n",
+    "\n",
+    "# # add box for outlining the domain\n",
+    "# domain_box = pv.Box(bounds=(2700, 3200, 0, 60, 0, 160))\n",
+    "\n",
+    "# # add box for outlining fixed particles\n",
+    "# particles_fixed_box = pv.Box(bounds=(2700, 3200, 0, 60, 0, 17.3943))\n",
+    "\n",
+    "# # add box for outlining the bed height\n",
+    "# particles_box = pv.Box(bounds=(2700, 3200, 0, 60, 0, 48.0002))\n",
+    "\n",
+    "# # add box for outlining the liquid height\n",
+    "# liquid_box = pv.Box(bounds=(2700, 3200, 0, 60, 0, 82.3888))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "3647ceda",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pl = pv.Plotter(lighting='three lights')\n",
+    "pl.add_mesh(fluid_mesh, scalars=\"velocity_si\", component=0)\n",
+    "pl.add_mesh(sphere_glyphs, scalars=\"diameter_si\", cmap=\"Greys\")\n",
+    "# pl.add_mesh(domain_box.outline(), line_width=3, color=\"black\")\n",
+    "# pl.add_mesh(particles_fixed_box.outline(), line_width=1, color=\"black\")\n",
+    "# pl.add_mesh(particles_box.outline(), line_width=1, color=\"black\")\n",
+    "# pl.add_mesh(liquid_box.outline(), line_width=1, color=\"black\")\n",
+    "\n",
+    "#pl.view_isometric()\n",
+    "pl.view_xz()\n",
+    "pl.enable_parallel_projection()\n",
+    "#pl.camera.roll += 0\n",
+    "#pl.camera.elevation -= 15\n",
+    "#pl.camera.azimuth -= 90\n",
+    "#pl.remove_scalar_bar(\"velocity_si\")\n",
+    "#pl.remove_scalar_bar(\"diameter_si\")\n",
+    "#pl.set_background('white')     \n",
+    "#pl.show_axes()\n",
+    "pl.camera.zoom(3.3)\n",
+    "#pl.screenshot(\"/simdata/ca36xymo/velocity-field.png\", transparent_background=True, window_size=(2560, 720))\n",
+    "pl.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "8c1f1df5",
+   "metadata": {},
+   "source": [
+    "## Plot velocity field (3D view) for graphical abstract"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "070962c0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fluid_file = \"/simdata/on74yces/2593014/vtk-out/fluid_field/simulation_step_7550001.vtu\"\n",
+    "particle_file = \"/simdata/on74yces/2593014/vtk-out/particles/simulation_step_7550001.vtu\"\n",
+    "surface_file = \"/simdata/on74yces/2593014/mesh-out/simulation_step_0.obj\"\n",
+    "\n",
+    "# taken from job output file\n",
+    "dx = 0.00025 \n",
+    "dt = 1.38e-5\n",
+    "\n",
+    "fluid_reader = pv.get_reader(fluid_file)\n",
+    "fluid_mesh = fluid_reader.read()\n",
+    "print(\"fluid_mesh:\", fluid_mesh.array_names)\n",
+    "\n",
+    "particle_reader = pv.get_reader(particle_file)\n",
+    "particle_mesh = particle_reader.read()\n",
+    "print(\"particle_mesh:\", particle_mesh.array_names)\n",
+    "\n",
+    "surface_reader = pv.get_reader(surface_file)\n",
+    "surface_mesh = surface_reader.read()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "23597fc3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# remove gas data from fluid_mesh\n",
+    "fluid_mesh = fluid_mesh.threshold(value=2, scalars=\"mapped_flag\", invert=True)\n",
+    "\n",
+    "# remove 5 cells in y-direction so that we can see the particles more clearly \n",
+    "fluid_mesh = fluid_mesh.clip(normal='y', origin=(2200,5,41.5), invert=False)\n",
+    "\n",
+    "# remove front part of the so that the figure starts from a low point (looks better)\n",
+    "# 2958 in particle field so that particle holes are not visible in the fluid mesh\n",
+    "fluid_mesh_back = fluid_mesh.clip(normal='x', origin=(2950,5,41.5), invert=True)\n",
+    "particle_mesh_back = particle_mesh.clip(normal='x', origin=(2958,59,41.5), invert=True) \n",
+    "\n",
+    "# store front part for appending it at the back (to maintain periodicity) \n",
+    "fluid_mesh_front = fluid_mesh.clip(normal='x', origin=(2950,5,41.5), invert=False)\n",
+    "particle_mesh_front = particle_mesh.clip(normal='x', origin=(2950,59,41.5), invert=False) # 2958 not necessary here\n",
+    "\n",
+    "# move front part to the back\n",
+    "transform_matrix = np.array([[1, 0, 0, -3200],\n",
+    "                             [0, 1, 0, 0],\n",
+    "                             [0, 0, 1, 0],\n",
+    "                             [0, 0, 0, 1]])\n",
+    "fluid_mesh_front = fluid_mesh_front.transform(transform_matrix)\n",
+    "particle_mesh_front = particle_mesh_front.transform(transform_matrix)\n",
+    "\n",
+    "# create glyphs for particles\n",
+    "sphere_glyphs_back = particle_mesh_back.glyph(scale=\"radius\", factor=1, \n",
+    "                                    geom=pv.Sphere(radius=1, theta_resolution=150, phi_resolution=150))\n",
+    "sphere_glyphs_front = particle_mesh_front.glyph(scale=\"radius\", factor=1, \n",
+    "                                    geom=pv.Sphere(radius=1, theta_resolution=150, phi_resolution=150))\n",
+    "\n",
+    "# convert velocity to SI units\n",
+    "velocity_unit_conversion = dx / dt\n",
+    "# IMPORTANT: \n",
+    "# The new velocity field (velocity_si) is not clipped if it is part of fluid_mesh.\n",
+    "# Therefore, we have to do the unit conversion for every clipped mesh individually.\n",
+    "fluid_mesh_back.add_field_data(fluid_mesh_back.get_array(\"velocity\") * velocity_unit_conversion, \"velocity_si\")\n",
+    "fluid_mesh_front.add_field_data(fluid_mesh_front.get_array(\"velocity\") * velocity_unit_conversion, \"velocity_si\")\n",
+    "\n",
+    "# get data range of the x-velocity from both fields for using it in the color bar\n",
+    "velocity_field_back_x_si = (fluid_mesh_back.field_data[\"velocity_si\"])[:,0]\n",
+    "velocity_field_front_x_si = (fluid_mesh_front.field_data[\"velocity_si\"])[:,0]\n",
+    "range_velocity_x_si = [min(np.min(velocity_field_back_x_si), np.min(velocity_field_front_x_si)),\n",
+    "                       max(np.max(velocity_field_back_x_si), np.max(velocity_field_front_x_si))]\n",
+    "\n",
+    "# convert particle radius to diameter in SI units\n",
+    "sphere_glyphs_back.point_data[\"diameter_si\"] = sphere_glyphs_back.point_data[\"radius\"] * 2 * dx\n",
+    "sphere_glyphs_front.point_data[\"diameter_si\"] = sphere_glyphs_front.point_data[\"radius\"] * 2 * dx"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "d0a2b037",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pl = pv.Plotter(lighting='three lights')\n",
+    "pl.add_mesh(fluid_mesh_back, scalars=\"velocity_si\", component=0, show_scalar_bar=False)\n",
+    "pl.add_mesh(fluid_mesh_front, scalars=\"velocity_si\", component=0, show_scalar_bar=False)\n",
+    "pl.add_scalar_bar(title=\"Downstream fluid velocity / m*s\", vertical=False, width=0.15, height=0.05,\n",
+    "                 position_x=0.7, position_y=0.6, color='black', title_font_size=40, label_font_size=25)\n",
+    "pl.update_scalar_bar_range(clim=[range_velocity_x_si[0],range_velocity_x_si[1]])\n",
+    "\n",
+    "pl.add_mesh(sphere_glyphs_back, scalars=\"diameter_si\", cmap=\"Greys\", show_scalar_bar=False)\n",
+    "pl.add_mesh(sphere_glyphs_front, scalars=\"diameter_si\", cmap=\"Greys\", show_scalar_bar=False)\n",
+    "pl.add_scalar_bar(title=\"Particle diameter / m\", vertical=False, width=0.15, height=0.05,\n",
+    "                  position_x=0.3, position_y=0.2, color='black', title_font_size=40, label_font_size=25, \n",
+    "                  n_labels=3, fmt=\"%.4f\")\n",
+    "pl.update_scalar_bar_range(clim=[0.0026,0.0032])\n",
+    "\n",
+    "pl.view_isometric()\n",
+    "#pl.enable_parallel_projection()\n",
+    "pl.camera.roll -= -5\n",
+    "pl.camera.elevation -= 35\n",
+    "pl.camera.azimuth -= 50\n",
+    "pl.set_background('white')     \n",
+    "#pl.show_axes()\n",
+    "pl.camera.zoom(7)\n",
+    "#pl.screenshot(\"/home/rzlin/ca36xymo/velocity-3d.png\", transparent_background=False, window_size=(2560, 1440))\n",
+    "pl.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "adc126de",
+   "metadata": {},
+   "source": [
+    "## Plot full velocity field at each sampling point (for creating the animations)\n",
+    "\n",
+    "### Warning: there are severe bugs in pyvista that overcomplicate this script:\n",
+    "1. memory leak in pl.screenshot(); memory leak is supposed to be circumvented with pl.clear();\n",
+    "2. HOWEVER: when pl.clear() was called, pl.view_xz() somehow disrupts pl.camera.zoom() such that the latter command does not work anymore\n",
+    "\n",
+    "=> new strategy: call python from bash such that the kernel restarts for every file; this avoids the memory leak"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "e4b4d571",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def get_image_array(pl, fluid_file_path, particle_file_path, dx, dt, color_bar_limits_velocity):\n",
+    "    fluid_reader = pv.get_reader(fluid_file_path)\n",
+    "    fluid_mesh = fluid_reader.read()\n",
+    "    \n",
+    "    # remove gas data from fluid_mesh\n",
+    "    fluid_mesh = fluid_mesh.threshold(value=2, scalars=\"mapped_flag\", invert=True)\n",
+    "\n",
+    "    particle_reader = pv.get_reader(particle_file_path)\n",
+    "    particle_mesh = particle_reader.read()\n",
+    "    \n",
+    "    # convert velocity to SI units\n",
+    "    velocity_unit_conversion = dx / dt\n",
+    "    velocity_si = fluid_mesh.get_array(\"velocity\") * velocity_unit_conversion\n",
+    "    fluid_mesh.add_field_data(velocity_si, \"velocity_si\")\n",
+    "\n",
+    "    # convert particle radius to diameter in SI units\n",
+    "    diameter_si = particle_mesh.point_data[\"radius\"] * 2 * dx\n",
+    "    particle_mesh.point_data[\"diameter_si\"] = diameter_si\n",
+    "\n",
+    "    # create glyphs for particles\n",
+    "    sphere_glyphs = particle_mesh.glyph(scale=\"radius\", factor=1, \n",
+    "                                        geom=pv.Sphere(radius=1, theta_resolution=150, phi_resolution=150))\n",
+    "\n",
+    "    \n",
+    "    pl.add_mesh(fluid_mesh, scalars=\"velocity_si\", component=0, show_scalar_bar=False)\n",
+    "    pl.add_scalar_bar(title=\"Downstream fluid velocity / m*s\", vertical=False, width=0.15, height=0.15,\n",
+    "                      position_x=0.325, position_y=0.8, color='black', title_font_size=70, label_font_size=50)\n",
+    "    pl.update_scalar_bar_range(clim=color_bar_limits_velocity)\n",
+    "    pl.add_mesh(sphere_glyphs, scalars=\"diameter_si\", cmap=\"Greys\", show_scalar_bar=False)\n",
+    "    pl.add_scalar_bar(title=\"Particle diameter / m\", vertical=False, width=0.15, height=0.15,\n",
+    "                      position_x=0.525, position_y=0.8, color='black', title_font_size=70, label_font_size=50)\n",
+    "    pl.update_scalar_bar_range(clim=[0.0026,0.0032])\n",
+    "    \n",
+    "    fluid_mesh.clear_field_data()\n",
+    "    particle_mesh.clear_field_data()\n",
+    "    sphere_glyphs.clear_field_data()\n",
+    "\n",
+    "    pl.view_xz()\n",
+    "    pl.enable_parallel_projection()\n",
+    "    #pl.remove_scalar_bar(\"velocity\")\n",
+    "    #pl.remove_scalar_bar(\"radius\")\n",
+    "    pl.set_background('white')\n",
+    "    pl.camera.zoom(10)\n",
+    "    image_array = pl.screenshot(transparent_background=False, window_size=(10000, 1000))\n",
+    "    \n",
+    "    # returns image as numpy array\n",
+    "    return image_array"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "17d29056",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import os\n",
+    "from PIL import Image\n",
+    "import gc\n",
+    "\n",
+    "fluid_file_basepath = \"/simdata/on74yces/2386423/vtk-out/fluid_field\"\n",
+    "particle_file_basepath = \"/simdata/on74yces/2386423/vtk-out/particles\"\n",
+    "output_file_basepath = \"/simdata/ca36xymo/antidunes/test\"\n",
+    "\n",
+    "# taken from job output file\n",
+    "dx = 0.00025 \n",
+    "dt = 1.38e-5 # E1\n",
+    "#dt = 1.08125e-5 # E4\n",
+    "\n",
+    "# empirically set after viewing data in ParaView (in lattice units)\n",
+    "color_bar_limits_velocity = np.array([-0.3, 1]) # E1 with e_dry=0.97\n",
+    "color_bar_limits_velocity = np.array([-0.3, 1.4]) # E4 with e_dry=0.97\n",
+    "\n",
+    "fluid_file_directory = os.fsencode(fluid_file_basepath)\n",
+    "\n",
+    "# create plotter outside of loop to avoid memoryleak in pyvista.screenshot()\n",
+    "# (https://github.com/pyvista/pyvista/issues/2252#issuecomment-1241929793)\n",
+    "pl = pv.Plotter(lighting='three lights')\n",
+    "\n",
+    "for file in os.listdir(fluid_file_directory):\n",
+    "    filename = os.fsdecode(file)\n",
+    "    \n",
+    "    fluid_file_path = fluid_file_basepath + '/' + filename\n",
+    "    particle_file_path = particle_file_basepath + '/' + filename\n",
+    "    \n",
+    "    filenumber = filename.split('_')\n",
+    "    filenumber = filenumber[2].split('.')\n",
+    "    filenumber = filenumber[0]\n",
+    "    \n",
+    "    output_file_path = output_file_basepath + '/' + filenumber + '.jpg'\n",
+    "    \n",
+    "    image_array = get_image_array(pl, fluid_file_path, particle_file_path, dx, dt, color_bar_limits_velocity)\n",
+    "\n",
+    "    pl.clear_actors()\n",
+    "\n",
+    "    image = Image.fromarray(image_array)\n",
+    "    \n",
+    "    image = image.crop((0, 0, image.size[0], 700)) # coordinate systems starts from top left\n",
+    "    image.save(output_file_path)\n",
+    "    image.close()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b9a63e3b",
+   "metadata": {},
+   "source": [
+    "### Bash script solution to circumvent memory leak bug"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a5b66e66",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# this Python script (named create_image.py) must be called by the bash script\n",
+    "\n",
+    "#!/usr/bin/env python3\n",
+    "\n",
+    "import os\n",
+    "import sys\n",
+    "from PIL import Image\n",
+    "import pyvista as pv\n",
+    "import numpy as np\n",
+    "\n",
+    "pv.start_xvfb()\n",
+    "\n",
+    "# taken from job output file\n",
+    "dx = 0.00025\n",
+    "dt = 1.38e-5 # E1\n",
+    "#dt = 1.08125e-5 # E4\n",
+    "\n",
+    "# empirically set after viewing data in ParaView (in lattice units)\n",
+    "color_bar_limits_velocity = np.array([-0.3, 1]) # E1 with e_dry=0.97\n",
+    "#color_bar_limits_velocity = np.array([-0.3, 1.4]) # E4 with e_dry=0.97\n",
+    "\n",
+    "# path to fluid file must be given as first command line argument\n",
+    "fluid_file_path = str(sys.argv[1])\n",
+    "particle_file_path = fluid_file_path.replace(\"fluid_field\", \"particles\")\n",
+    "\n",
+    "filenumber = fluid_file_path.split('_')[-1]\n",
+    "filenumber = filenumber.split('.')[0]\n",
+    "filenumber = str(filenumber).rjust(10, '0')\n",
+    "output_file_path = \"/simdata/ca36xymo/antidunes/animation-e1/\" + filenumber + \".jpg\"\n",
+    "\n",
+    "fluid_reader = pv.get_reader(fluid_file_path)\n",
+    "fluid_mesh = fluid_reader.read()\n",
+    "\n",
+    "particle_reader = pv.get_reader(particle_file_path)\n",
+    "particle_mesh = particle_reader.read()\n",
+    "\n",
+    "# remove gas data from fluid_mesh\n",
+    "fluid_mesh = fluid_mesh.threshold(value=2, scalars=\"mapped_flag\", invert=True)\n",
+    "\n",
+    "# convert velocity to SI units\n",
+    "velocity_unit_conversion = dx / dt\n",
+    "velocity_si = fluid_mesh.get_array(\"velocity\") * velocity_unit_conversion\n",
+    "fluid_mesh.add_field_data(velocity_si, \"velocity_si\")\n",
+    "\n",
+    "# convert particle radius to diameter in SI units\n",
+    "diameter_si = particle_mesh.point_data[\"radius\"] * 2 * dx\n",
+    "particle_mesh.point_data[\"diameter_si\"] = diameter_si\n",
+    "\n",
+    "# create glyphs for particles\n",
+    "sphere_glyphs = particle_mesh.glyph(scale=\"radius\", factor=1,\n",
+    "                                    geom=pv.Sphere(radius=1, theta_resolution=150, phi_resolution=150))\n",
+    "\n",
+    "pl = pv.Plotter(lighting='three lights', off_screen=True)\n",
+    "\n",
+    "pl.add_mesh(fluid_mesh, scalars=\"velocity_si\", component=0, show_scalar_bar=False)\n",
+    "pl.add_scalar_bar(title=\"Downstream fluid velocity / m*s\", vertical=False, width=0.15, height=0.15,\n",
+    "                  position_x=0.325, position_y=0.8, color='black', title_font_size=70, label_font_size=50)\n",
+    "pl.update_scalar_bar_range(clim=color_bar_limits_velocity)\n",
+    "pl.add_mesh(sphere_glyphs, scalars=\"diameter_si\", cmap=\"Greys\", show_scalar_bar=False)\n",
+    "pl.add_scalar_bar(title=\"Particle diameter / m\", vertical=False, width=0.15, height=0.15,\n",
+    "                  position_x=0.525, position_y=0.8, color='black', title_font_size=70, label_font_size=50)\n",
+    "pl.update_scalar_bar_range(clim=[0.0026,0.0032])\n",
+    "\n",
+    "pl.view_xz()\n",
+    "#pl.enable_parallel_projection()\n",
+    "pl.set_background('white')\n",
+    "#pl.camera.zoom(10)\n",
+    "\n",
+    "# specify camera so that image stays at a fixed position\n",
+    "pl.camera.clipping_range = (5812.79, 6720.05)\n",
+    "pl.camera.distance = 6207.82\n",
+    "pl.camera.focal_point = (1600.44, 29.9889, 38.3558)\n",
+    "pl.camera.parallel_projection = True\n",
+    "pl.camera.parallel_scale = 160.67\n",
+    "pl.camera.thickness = 907.259\n",
+    "pl.camera.view_angle = 30\n",
+    "\n",
+    "pl.window_size = [10000, 1000]\n",
+    "pl.screenshot(output_file_path,transparent_background=False)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "537dc005",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#!/bin/bash -l\n",
+    "\n",
+    "conda activate\n",
+    "\n",
+    "fluid_file_basepath1=\"/simdata/on74yces/2502347/vtk-out/fluid_field/\"\n",
+    "# fluid_file_basepath2=\"/simdata/on74yces/2504475/vtk-out/fluid_field/\"\n",
+    "# fluid_file_basepath3=\"/simdata/on74yces/2509267/vtk-out/fluid_field/\"\n",
+    "# fluid_file_basepath4=\"/simdata/on74yces/2519434/vtk-out/fluid_field/\"\n",
+    "\n",
+    "declare -a PathList=($fluid_file_basepath1 $fluid_file_basepath2 $fluid_file_basepath3 $fluid_file_basepath4)\n",
+    "\n",
+    "for path in ${PathList[@]}; do\n",
+    "   for file in $path*.vtu; do\n",
+    "      python3 create-image.py $file\n",
+    "      # echo $file\n",
+    "   done\n",
+    "done"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ebe75f76",
+   "metadata": {},
+   "source": [
+    "## Plot full bed height elevation at each sampling point (for creating the animations)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9254f001",
+   "metadata": {
+    "scrolled": true
+   },
+   "outputs": [],
+   "source": [
+    "# this Python script (named create_image.py) must be called by the bash script\n",
+    "\n",
+    "#!/usr/bin/env python3\n",
+    "\n",
+    "import os\n",
+    "import sys\n",
+    "from PIL import Image\n",
+    "import pyvista as pv\n",
+    "import numpy as np\n",
+    "\n",
+    "pv.start_xvfb()\n",
+    "\n",
+    "# taken from job output file\n",
+    "dx = 0.00025\n",
+    "\n",
+    "# path to particle file must be given as first command line argument\n",
+    "particle_file_path = \"/simdata/on74yces/2509268/vtk-out/particles/simulation_step_3799000.vtu\" #str(sys.argv[1])\n",
+    "\n",
+    "filenumber = particle_file_path.split('_')[-1]\n",
+    "filenumber = filenumber.split('.')[0]\n",
+    "filenumber = str(filenumber).rjust(10, '0')\n",
+    "output_file_path = \"/simdata/ca36xymo/antidunes/animation-particles-e4/\" + filenumber + \".jpg\"\n",
+    "\n",
+    "particle_reader = pv.get_reader(particle_file_path)\n",
+    "particle_mesh = particle_reader.read()\n",
+    "\n",
+    "# add z-coordinate of particle center in SI units\n",
+    "particle_mesh.point_data[\"center_coordinate_z_si\"] = particle_mesh.points[:,2] * dx\n",
+    "\n",
+    "# create glyphs for particles\n",
+    "sphere_glyphs = particle_mesh.glyph(scale=\"radius\", factor=1, \n",
+    "                                    geom=pv.Sphere(radius=1, theta_resolution=30, phi_resolution=30))\n",
+    "\n",
+    "pl = pv.Plotter(lighting='three lights', off_screen=True)\n",
+    "\n",
+    "# add mesh at periodic sides to enlarge bed region\n",
+    "transform_matrix = np.array([[1, 0, 0, 0],\n",
+    "                                     [0, 1, 0, -60],\n",
+    "                                     [0, 0, 1, 0],\n",
+    "                                     [0, 0, 0, 1]])\n",
+    "particle_mesh_transformed_once = particle_mesh.transform(transform_matrix,inplace=False)\n",
+    "sphere_glyphs_transformed_once = particle_mesh_transformed_once.glyph(scale=\"radius\", factor=1, \n",
+    "                                    geom=pv.Sphere(radius=1, theta_resolution=30, phi_resolution=30))\n",
+    "particle_mesh_transformed_twice = particle_mesh_transformed_once.transform(transform_matrix,inplace=False)\n",
+    "sphere_glyphs_transformed_twice = particle_mesh_transformed_twice.glyph(scale=\"radius\", factor=1, \n",
+    "                                    geom=pv.Sphere(radius=1, theta_resolution=30, phi_resolution=30))\n",
+    "\n",
+    "pl.add_mesh(sphere_glyphs_transformed_once, scalars=\"center_coordinate_z_si\", show_scalar_bar=False)\n",
+    "pl.update_scalar_bar_range(clim=[0.008,0.012])\n",
+    "pl.add_mesh(sphere_glyphs, scalars=\"center_coordinate_z_si\", show_scalar_bar=False)\n",
+    "pl.update_scalar_bar_range(clim=[0.008,0.012])\n",
+    "pl.add_mesh(sphere_glyphs_transformed_twice, scalars=\"center_coordinate_z_si\", show_scalar_bar=False)\n",
+    "pl.add_scalar_bar(title=\"Bed height elevation / m\", vertical=False, width=0.15, height=0.15,\n",
+    "                  position_x=0.425, position_y=0.7, color='black', title_font_size=70, label_font_size=50,\n",
+    "                  fmt=\"%.3f\")\n",
+    "pl.update_scalar_bar_range(clim=[0.008,0.012])\n",
+    "\n",
+    "# pl.add_mesh(pv.Line((0,0,100), (3200,0,100)), color=\"lightgray\", line_width=10)\n",
+    "# pl.add_mesh(pv.Line((0,-60,100), (3200,-60,100)), color=\"lightgray\", line_width=10)\n",
+    "\n",
+    "pl.view_xy()\n",
+    "#pl.enable_parallel_projection()\n",
+    "pl.set_background('white')\n",
+    "#pl.camera.zoom(10)\n",
+    "\n",
+    "# specify camera so that image stays at a fixed position\n",
+    "pl.camera.clipping_range = (5812.79, 6720.05)\n",
+    "pl.camera.distance = 6207.82\n",
+    "pl.camera.focal_point = (1600.44, 29.9889, 38.3558)\n",
+    "pl.camera.parallel_projection = True\n",
+    "pl.camera.parallel_scale = 160.67\n",
+    "pl.camera.thickness = 907.259\n",
+    "pl.camera.view_angle = 30\n",
+    "\n",
+    "pl.window_size = [10000, 1000]\n",
+    "pl.screenshot(output_file_path,transparent_background=False)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "73d07351",
+   "metadata": {},
+   "source": [
+    "## Plot full free-surface height elevation at each sampling point (for creating the animations)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "95013d8f",
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [],
+   "source": [
+    "# this Python script (named create_image.py) must be called by the bash script\n",
+    "\n",
+    "#!/usr/bin/env python3\n",
+    "\n",
+    "import os\n",
+    "import sys\n",
+    "from PIL import Image\n",
+    "import pyvista as pv\n",
+    "import numpy as np\n",
+    "\n",
+    "pv.start_xvfb()\n",
+    "\n",
+    "# taken from job output file\n",
+    "dx = 0.00025\n",
+    "\n",
+    "# path to fluid file must be given as first command line argument\n",
+    "fluid_file_path = str(sys.argv[1]) #\"/simdata/on74yces/2517354/vtk-out/fluid_field/simulation_step_7551000.vtu\" \n",
+    "\n",
+    "filenumber = fluid_file_path.split('_')[-1]\n",
+    "filenumber = filenumber.split('.')[0]\n",
+    "filenumber = str(filenumber).rjust(10, '0')\n",
+    "output_file_path = \"/simdata/ca36xymo/antidunes/animation-free-surface-e4/\" + filenumber + \".jpg\"\n",
+    "\n",
+    "fluid_reader = pv.get_reader(fluid_file_path)\n",
+    "fluid_mesh = fluid_reader.read()\n",
+    "\n",
+    "# remove gas data from fluid_mesh\n",
+    "fluid_mesh = fluid_mesh.threshold(value=2, scalars=\"mapped_flag\", invert=True)\n",
+    "\n",
+    "# slice fluid mesh to get real 2D data\n",
+    "fluid_mesh = fluid_mesh.slice(normal=[0, 1, 0])\n",
+    "\n",
+    "# extract surface so we apply the extrusion\n",
+    "fluid_mesh = fluid_mesh.extract_surface()\n",
+    "\n",
+    "# extrude fluid_mesh to 3D\n",
+    "fluid_mesh = fluid_mesh.extrude([0, -60, 0], capping=False)\n",
+    "\n",
+    "# add z-coordinate of cell center in SI units\n",
+    "fluid_mesh.point_data[\"coordinate_z_si\"] = fluid_mesh.points[:,2] * dx\n",
+    "\n",
+    "pl = pv.Plotter(lighting='three lights', off_screen=True)\n",
+    "\n",
+    "pl.add_mesh(fluid_mesh, scalars=\"coordinate_z_si\", component=0, show_scalar_bar=False)\n",
+    "pl.add_scalar_bar(title=\"Free-surface elevation / m\", vertical=False, width=0.15, height=0.15,\n",
+    "                  position_x=0.425, position_y=0.7, color='black', title_font_size=70, label_font_size=50,\n",
+    "                  fmt=\"%.3f\")\n",
+    "#pl.update_scalar_bar_range(clim=[0.018,0.022]) # E1\n",
+    "pl.update_scalar_bar_range(clim=[0.020,0.024]) # E4\n",
+    "\n",
+    "pl.view_xy()\n",
+    "#pl.enable_parallel_projection()\n",
+    "pl.set_background('white')\n",
+    "#pl.camera.zoom(10)\n",
+    "\n",
+    "# specify camera so that image stays at a fixed position\n",
+    "pl.camera.clipping_range = (5812.79, 6720.05)\n",
+    "pl.camera.distance = 6207.82\n",
+    "pl.camera.focal_point = (1600.44, 29.9889, 38.3558)\n",
+    "pl.camera.parallel_projection = True\n",
+    "pl.camera.parallel_scale = 160.67\n",
+    "pl.camera.thickness = 907.259\n",
+    "pl.camera.view_angle = 30\n",
+    "\n",
+    "pl.window_size = [10000, 1000]\n",
+    "\n",
+    "pl.screenshot(output_file_path,transparent_background=False)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b516bfd5",
+   "metadata": {},
+   "source": [
+    "## Plot coordinate systems\n",
+    "This is very troublesome to do in the actual plots => add them afterwards using ffmpeg"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "e46cfd29",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pyvista as pv\n",
+    "\n",
+    "output_file_path = \"/simdata/ca36xymo/antidunes/animation-free-surface-e1/coordinate-system.jpg\"\n",
+    "\n",
+    "pl = pv.Plotter(lighting='three lights', off_screen=True)\n",
+    "pl.view_xz()\n",
+    "pl.add_axes(color=\"black\", x_color=\"black\", y_color=\"black\", z_color=\"black\", viewport=(0, 0, 1, 1))\n",
+    "pl.set_background('white')\n",
+    "\n",
+    "pl.screenshot(output_file_path,transparent_background=False)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "63082336",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/apps/showcases/Antidunes/slice_evaluation.ipynb b/apps/showcases/Antidunes/slice_evaluation.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..422762f92b8eceb90364660bb43a1c07f777a96c
--- /dev/null
+++ b/apps/showcases/Antidunes/slice_evaluation.ipynb
@@ -0,0 +1,1138 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4f094327",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import matplotlib.pyplot as plt\n",
+    "%matplotlib notebook\n",
+    "import matplotlib as mpl\n",
+    "import numpy as np\n",
+    "import seaborn as sns"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "904ccd9a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "baseFolder = \"/simdata/on74yces/merge_E1_flat_new\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a7782605",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def readSliceData(fileName):\n",
+    "    with open(fileName) as f:\n",
+    "        line = f.readline()[2:].split(\" \")\n",
+    "        nx = int(line[0])\n",
+    "        nz = int(line[1])\n",
+    "    svf = np.loadtxt(fileName)\n",
+    "    return svf.reshape((nz,nx)).transpose() # x: dim0, z: dim1\n",
+    "\n",
+    "def extractMaxAvailableTimeStep():\n",
+    "    import glob\n",
+    "\n",
+    "    baseFileName = \"/svfSlice_\"\n",
+    "    fileEnding = \".txt\"\n",
+    "    allFiles = glob.glob(baseFolder + baseFileName+\"*\"+fileEnding)\n",
+    "    maxTimeStep = np.max([int(f[(f.find(baseFileName)+len(baseFileName)):f.find(fileEnding)]) for f in allFiles])\n",
+    "    return maxTimeStep\n",
+    "\n",
+    "def getCurrentForcing(t):\n",
+    "    fileName = baseFolder+\"/fluidInfo.txt\"\n",
+    "    # columns: t, fx, uMean, ...\n",
+    "    content = np.loadtxt(fileName).transpose()\n",
+    "    idx = np.argwhere(content[0] == t)\n",
+    "    return content[1][idx]\n",
+    "        "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "114b2285",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# source: \n",
+    "# https://stackoverflow.com/questions/26563858/matplotlib-imshow-fixed-aspect-and-vertical-colorbar-matching-master-axis-height\n",
+    "def match_colorbar(cb, ax=None):\n",
+    "    \"\"\"\n",
+    "    Match the size of the colorbar with the size of the axes.\n",
+    "    \n",
+    "    Args:\n",
+    "        ax: Axes from which the colorbar \"stole\" space.\n",
+    "        cb: Colorbar to match to `ax`.\n",
+    "    \"\"\"\n",
+    "    ax = ax or plt.gca()\n",
+    "    bbox = ax.get_position()\n",
+    "    cb_bbox = cb.ax.get_position()\n",
+    "    if cb.orientation == \"vertical\":\n",
+    "        # Update bottom and height.\n",
+    "        left = cb_bbox.xmin\n",
+    "        width = cb_bbox.width\n",
+    "        bottom = bbox.ymin\n",
+    "        height = bbox.height\n",
+    "    else:\n",
+    "        # Update left and width.\n",
+    "        left = bbox.xmin\n",
+    "        width = bbox.width\n",
+    "        bottom = cb_bbox.ymin\n",
+    "        height = cb_bbox.height\n",
+    "    pos = [left, bottom, width, height]\n",
+    "    cb.ax.set_position(pos)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4d487479",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def evalTimeStep(t):\n",
+    "    \n",
+    "    bedHeightThreshold = 0.3\n",
+    "    \n",
+    "    timestep = str(t)\n",
+    "    fileName = baseFolder+\"/svfSlice_\"+timestep+\".txt\"\n",
+    "    svf = readSliceData(fileName)\n",
+    "    fileName = baseFolder+\"/fillSlice_\"+timestep+\".txt\"\n",
+    "    fill = readSliceData(fileName)\n",
+    "    fileName = baseFolder+\"/velXSlice_\"+timestep+\".txt\"\n",
+    "    vel = readSliceData(fileName)\n",
+    "    \n",
+    "    fx = getCurrentForcing(t)\n",
+    "    \n",
+    "    #print(\"Total water volume: \" , np.sum(fill))\n",
+    "    \n",
+    "    #plt.figure()\n",
+    "    #plt.imshow(svf<bedHeightThreshold, interpolation='none')\n",
+    "    #plt.imshow(vel, interpolation='none')\n",
+    "    #plt.colorbar()\n",
+    "    #plt.show()\n",
+    "    \n",
+    "    xlen = svf.shape[0]\n",
+    "    zlen = svf.shape[1]\n",
+    "\n",
+    "\n",
+    "    xPos = np.linspace(0.5, xlen - 0.5, num = xlen)\n",
+    "    zPos = np.linspace(0.5, zlen - 0.5, num = zlen)\n",
+    "\n",
+    "    waterHeightOverX = np.zeros(xlen)\n",
+    "    bedHeightOverX = np.zeros(xlen)\n",
+    "    froudeOverX = np.zeros(xlen)\n",
+    "    shieldsOverX = np.zeros(xlen)\n",
+    "    ReFricOverX = np.zeros(xlen)\n",
+    "    \n",
+    "    #plt.figure()\n",
+    "    \n",
+    "    avgUMean = 0\n",
+    "    avgWaterHeight = 0\n",
+    "\n",
+    "    for x in range(xlen):\n",
+    "        svfX = svf[x,:]\n",
+    "        fillX = fill[x,:]\n",
+    "        velX = vel[x,:]\n",
+    "\n",
+    "        waterElevation = np.sum(fillX)\n",
+    "        idxWaterElevation = int(waterElevation)\n",
+    "        \n",
+    "        idxBedHeight = zlen - np.argmax(svfX[::-1]>bedHeightThreshold) - 1\n",
+    "        bedHeight = zPos[idxBedHeight] + (bedHeightThreshold - svfX[idxBedHeight]) / (svfX[idxBedHeight+1] - svfX[idxBedHeight]) * 1\n",
+    "\n",
+    "        waterHeight = waterElevation - bedHeight\n",
+    "        \n",
+    "        #print(idxWaterElevation, idxBedHeight, svfX[idxBedHeight+1], svfX[idxBedHeight], svfX[idxBedHeight-1])\n",
+    "\n",
+    "        uMean = np.average(velX[idxBedHeight:idxWaterElevation+1])\n",
+    "        \n",
+    "        #velDerivative = np.gradient(velX)\n",
+    "        # velocity derivative is very sensitive to the exact location -> where to take it?\n",
+    "        # here: take average over some cells right above the bed\n",
+    "        #velDerivativeAtBed = np.average(velDerivative[idxBedHeight+1:idxBedHeight+5]) # TODO check this!!!!\n",
+    "        #wallShearStress = dynViscosity * velDerivativeAtBed \n",
+    "        # note: this does only get a small part of the total stress on the particle -> different approach necessary\n",
+    "        \n",
+    "        # integrate current forcing over water height to the position of the bed (see discussion with Bernhard)\n",
+    "        wallShearStress = waterHeight * fx\n",
+    "        \n",
+    "        frictionVelocity = np.sqrt(wallShearStress / fluidDensity)\n",
+    "\n",
+    "        waterHeightOverX[x] = waterHeight\n",
+    "        bedHeightOverX[x] = bedHeight\n",
+    "        froudeOverX[x] = uMean / np.sqrt(waterHeight * gravAcceleration)\n",
+    "        shieldsOverX[x] = wallShearStress / ((densityRatio - 1) * gravAcceleration * avgDiameter)\n",
+    "        ReFricOverX[x] = frictionVelocity * waterHeight / kinematicViscosity\n",
+    "        \n",
+    "        \n",
+    "    \n",
+    "        #plt.plot(velDerivative[idxBedHeight:idxWaterElevation-1])\n",
+    "        #plt.plot(velX[idxBedHeight:idxWaterElevation+1])\n",
+    "        \n",
+    "        avgWaterHeight += waterHeight\n",
+    "        avgUMean += uMean\n",
+    "        \n",
+    "    #plt.xlabel(\"height\")\n",
+    "    #plt.ylabel(\"velX\")\n",
+    "    #plt.show()\n",
+    "    \n",
+    "    avgWaterHeight /= float(xlen)\n",
+    "    avgUMean /= float(xlen)\n",
+    "    \n",
+    "    ReBulk = avgUMean * avgWaterHeight / kinematicViscosity\n",
+    "    \n",
+    "    print(\"Re_b = \", ReBulk)\n",
+    "        \n",
+    "    return (xPos,waterHeightOverX, bedHeightOverX, froudeOverX, shieldsOverX, ReFricOverX)\n",
+    "\n",
+    "\n",
+    "def plotResult(result):\n",
+    "    fig, axes = plt.subplots(nrows=3, ncols=2,sharex=True)\n",
+    "    \n",
+    "    labels = [\"$h_f$\", \"$h_b$\", \"$Fr$\", \"$Sh$\", \"$Re_\\\\tau$\"]\n",
+    "    for i, ax in enumerate(axes.flatten()):\n",
+    "        if i >= len(labels):\n",
+    "            break\n",
+    "        ax.plot(result[0], result[i+1],'-')\n",
+    "        ax.set_ylabel(labels[i])\n",
+    "        ax.set_xlabel(\"$x$\")\n",
+    "        \n",
+    "    fig.tight_layout()\n",
+    "    plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "eeb92b70",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "with open(baseFolder+'/info.txt') as f:\n",
+    "    lines = f.readlines()\n",
+    "    evalFrequency = int(lines[0])\n",
+    "    gravAcceleration = float(lines[1])\n",
+    "    dynViscosity =  float(lines[2])\n",
+    "    densityRatio = float(lines[3])\n",
+    "    avgDiameter = float(lines[4])\n",
+    "    xSize = int(lines[5])\n",
+    "    ySize = int(lines[6])\n",
+    "    zSize = int(lines[7])\n",
+    "    numParticles = int(lines[8])\n",
+    "\n",
+    "print(\"Eval infos:\")\n",
+    "print(evalFrequency, gravAcceleration, dynViscosity, densityRatio, avgDiameter,numParticles)\n",
+    "maxTimeStep = extractMaxAvailableTimeStep()\n",
+    "\n",
+    "averageVelocity = 0.02\n",
+    "fluidDensity = 1\n",
+    "kinematicViscosity = dynViscosity / fluidDensity\n",
+    "#timeStepSpacing = 50*evalFrequency\n",
+    "timeStepSpacing = 10*evalFrequency\n",
+    "evalTimeSteps = np.arange(0,maxTimeStep,timeStepSpacing)\n",
+    "\n",
+    "lengthRef = avgDiameter\n",
+    "timeRef = avgDiameter / averageVelocity\n",
+    "\n",
+    "print(\"Will evaluate\", len(evalTimeSteps), \"slices, from 0 to\", maxTimeStep, \"with spacing\", timeStepSpacing)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5bdec6b4",
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [],
+   "source": [
+    "hbOverTime = np.zeros((len(evalTimeSteps), xSize))\n",
+    "hfOverTime = np.zeros((len(evalTimeSteps), xSize))\n",
+    "for i,t in enumerate(evalTimeSteps):\n",
+    "    result = evalTimeStep(t)\n",
+    "    \n",
+    "    # uncomment if plot per time step required\n",
+    "    #plotResult(result)\n",
+    "    \n",
+    "    hfAvg = np.average(result[1])\n",
+    "    hbAvg = np.average(result[2])\n",
+    "    FrAvg = np.average(result[3])\n",
+    "    ShAvg = np.average(result[4])\n",
+    "    print(f\"{i}, t = {t}: hf = {hfAvg:.2f}, hb = {hbAvg:.2f}, Fr = {FrAvg:.3f}, Sh = {ShAvg:.3e}\")\n",
+    "    hfOverTime[i] = result[1]\n",
+    "    hbOverTime[i] = result[2]\n",
+    "    "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "3587551b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# plot 2D x-t-Data evaluation in lattice units\n",
+    "\n",
+    "xPlotLimits = np.array([0,xSize]) / lengthRef\n",
+    "tPlotLimits = np.array([min(evalTimeSteps), max(evalTimeSteps)]) / timeRef\n",
+    "\n",
+    "fluidSurfaceOverTime = hbOverTime+hfOverTime\n",
+    "plt.figure()\n",
+    "plt.imshow(hbOverTime, interpolation='none', origin='lower',\n",
+    "           aspect='auto',extent=(*xPlotLimits,*tPlotLimits))\n",
+    "#plt.imshow(fluidSurfaceOverTime[:,:], interpolation='none', origin='lower',aspect='auto')\n",
+    "plt.colorbar()\n",
+    "plt.xlabel(r\"$x / D$\")\n",
+    "plt.ylabel(r\"$t / t_{ref}$\")\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ccfa2b70",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# values taken from job output file\n",
+    "dx = 0.00025\n",
+    "\n",
+    "# E1\n",
+    "dt = 1.38442e-05\n",
+    "\n",
+    "# E4\n",
+    "#dt = 1.08125e-5"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c1b25e53",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# plot 2D x-t-Data evaluation in SI units\n",
+    "\n",
+    "xPlotLimits = np.array([0,xSize]) * dx\n",
+    "tPlotLimits = np.array([min(evalTimeSteps), max(evalTimeSteps)]) * dt\n",
+    "averageHb = np.average(hbOverTime) * dx\n",
+    "\n",
+    "#fluidSurfaceOverTime = (hbOverTime+hfOverTime) * 0.00025\n",
+    "plt.figure()\n",
+    "plt.imshow(hbOverTime[:] * dx - averageHb, interpolation='none', origin='lower',\n",
+    "           aspect='auto', extent=(*xPlotLimits,*tPlotLimits), vmin=-5e-3, vmax=5e-3)\n",
+    "#plt.imshow(fluidSurfaceOverTime[:,:], interpolation='none', origin='lower',aspect='auto')\n",
+    "plt.colorbar()\n",
+    "plt.xlabel(r\"$x$ / m\")\n",
+    "plt.ylabel(r\"$t$ / s\")\n",
+    "plt.show()\n",
+    "\n",
+    "print(tPlotLimits)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4223c1cc",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# plot bed height evaluation in SI units with\n",
+    "    # time restricted to 30 - 75 s\n",
+    "    # x-domain restricted to 0 - 0.75 m\n",
+    "\n",
+    "timestep_length = evalTimeSteps[1] - evalTimeSteps[0]\n",
+    "\n",
+    "t_min = 30\n",
+    "timestep_index_min = int((t_min / dt) // timestep_length)\n",
+    "\n",
+    "t_max = 75\n",
+    "timestep_index_max = int((t_max / dt) // timestep_length)\n",
+    "\n",
+    "x_min = 0\n",
+    "x_index_min = int(x_min // dx)\n",
+    "\n",
+    "x_max = 0.75\n",
+    "x_index_max = int(x_max // dx)\n",
+    "\n",
+    "xPlotLimits = np.array([x_min,x_max])\n",
+    "tPlotLimits = np.array([t_min, t_max])\n",
+    "averageHb = np.average(hbOverTime[timestep_index_min:timestep_index_max,x_index_min:x_index_max]) * dx\n",
+    "\n",
+    "plt.figure()\n",
+    "plt.imshow(hbOverTime[timestep_index_min:timestep_index_max,x_index_min:x_index_max] * dx - averageHb, interpolation='none', origin='lower',\n",
+    "           aspect='auto', extent=(*xPlotLimits,*tPlotLimits), vmin=-5e-3, vmax=5e-3)\n",
+    "\n",
+    "cb = plt.colorbar(label=r\"$h$ / m\")\n",
+    "match_colorbar(cb)\n",
+    "#cb.ax.tick_params(labelsize=8) \n",
+    "plt.xlabel(r\"$x$ / m\")\n",
+    "plt.ylabel(r\"$t$ / s\")\n",
+    "\n",
+    "import tikzplotlib\n",
+    "tikzplotlib.clean_figure()\n",
+    "tikzplotlib.save(\n",
+    "    '/home/rzlin/ca36xymo/tikz/bed-elevation-simulation.tex',\n",
+    "    axis_height = '\\\\figureheight',\n",
+    "    axis_width = '\\\\figurewidth'\n",
+    "    )\n",
+    "\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "bed63b06",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(np.average(hfOverTime[timestep_index_min:timestep_index_max,x_index_min:x_index_max]))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b8c8fc0e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import scipy.fft\n",
+    "\n",
+    "# plot PSD with\n",
+    "    # time restricted to 30 - 75 s\n",
+    "    # x-domain restricted to 0 - 0.75 m\n",
+    "\n",
+    "timestep_length = evalTimeSteps[1] - evalTimeSteps[0]\n",
+    "\n",
+    "t_min = 30\n",
+    "timestep_index_min = int((t_min / dt) // timestep_length)\n",
+    "\n",
+    "t_max = 75\n",
+    "timestep_index_max = int((t_max / dt) // timestep_length)\n",
+    "\n",
+    "x_min = 0\n",
+    "x_index_min = int(x_min // dx)\n",
+    "\n",
+    "x_max = 0.75\n",
+    "x_index_max = int(x_max // dx)\n",
+    "\n",
+    "averageHb = np.average(hbOverTime[timestep_index_min:timestep_index_max,x_index_min:x_index_max]) * dx\n",
+    "\n",
+    "hbOverTimeShort = hbOverTime[timestep_index_min:timestep_index_max,x_index_min:x_index_max] * dx - averageHb\n",
+    "\n",
+    "# power spectral density (PSD) on period-wavelength (PW) plane\n",
+    "psd_pw = scipy.fft.fft2(hbOverTimeShort)\n",
+    "psd_pw = np.abs(psd_pw)**2 / (hbOverTimeShort.shape[0]*hbOverTimeShort.shape[1])\n",
+    "\n",
+    "ft_period = np.abs(1 / scipy.fft.fftfreq(np.transpose(hbOverTimeShort).shape[-1],timestep_length)[1:] * dt)\n",
+    "ft_wavelength = np.abs(1 / scipy.fft.fftfreq(hbOverTimeShort.shape[-1],1)[1:] * dx)\n",
+    "\n",
+    "plt.figure()\n",
+    "cs = plt.contour(ft_period, ft_wavelength, np.transpose(np.abs(psd_pw[1:,1:])), levels=[0.01], colors='tab:orange',linewidths=6)\n",
+    "plt.xlabel(r\"$T$ / s\")\n",
+    "plt.ylabel(r\"$\\lambda$ / m\")\n",
+    "ax = plt.gca()\n",
+    "ax.set_xlim([5, 70])\n",
+    "ax.set_ylim([0.05, 0.2])\n",
+    "#cb = plt.colorbar(label=r\"PSD / m$^2$\")\n",
+    "\n",
+    "\n",
+    "## uncomment the following things to only store the png (no axes etc.)\n",
+    "## => tikz image must be created manually later\n",
+    "# plt.subplots_adjust(bottom = 0)\n",
+    "# plt.subplots_adjust(top = 1)\n",
+    "# plt.subplots_adjust(right = 1)\n",
+    "# plt.subplots_adjust(left = 0)\n",
+    "# plt.axis('off')\n",
+    "# plt.savefig('/home/rzlin/ca36xymo/tikz/spectral-density-simulation.png',bbox_inches='tight',transparent=True, pad_inches=0)\n",
+    "\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "afd23ef9",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "psd_pw_new = np.transpose(np.abs(psd_pw[1:,1:]))\n",
+    "\n",
+    "# compute celerity (for each point in wavelength-period PSD-array)\n",
+    "# => identify all possible y-axis values for new celerity-wavelength PSD-array\n",
+    "c = []\n",
+    "x = 0\n",
+    "for wavelength in ft_wavelength:\n",
+    "    y = 0\n",
+    "    for period in ft_period:\n",
+    "        if psd_pw_new[x,y] > 0.005:\n",
+    "            c.append(wavelength / period)\n",
+    "        y += 1\n",
+    "    x += 1\n",
+    "        \n",
+    "c = list(set(c))    # remove duplicate entries from list\n",
+    "c.sort()            # sort in ascending order\n",
+    "# list 'c' contains all possible celerities in ascending order without duplicates\n",
+    "# => this will be the x-axis of the celerity-wavelength PSD-array \n",
+    "\n",
+    "# create celerity-wavelength PSD-array \n",
+    "psd_cw = np.zeros((ft_wavelength.shape[0], len(c)))\n",
+    "\n",
+    "# fill this array with PSD at correct position\n",
+    "for i_wavelength in range(0, ft_wavelength.shape[0]):\n",
+    "    for i_period in range(0, ft_period.shape[0]):\n",
+    "        if psd_pw_new[i_wavelength,i_period] > 0.005:\n",
+    "            celerity = ft_wavelength[i_wavelength] / ft_period[i_period] # compute celerity\n",
+    "            i_celerity = c.index(celerity) # map celerity to it's index on the x-axis of the new array\n",
+    "            psd_cw[i_wavelength,i_celerity] = abs(psd_pw_new[i_wavelength,i_period]) # store PSD at correct position in new array\n",
+    "\n",
+    "plt.figure()\n",
+    "plt.contourf(ft_wavelength, np.asarray(c), np.abs(np.transpose(psd_cw)),colors='tab:orange')\n",
+    "#plt.colorbar()\n",
+    "plt.xlabel(r\"$\\lambda$ / m\")\n",
+    "plt.ylabel(r\"$c$ / m/s\")\n",
+    "ax = plt.gca()\n",
+    "ax.set_xlim([0.05, 0.2])\n",
+    "ax.set_ylim([0, 0.015])\n",
+    "\n",
+    "# # uncomment the following things to only store the png (no axes etc.)\n",
+    "# # => tikz image must be created manually later\n",
+    "plt.subplots_adjust(bottom = 0)\n",
+    "plt.subplots_adjust(top = 1)\n",
+    "plt.subplots_adjust(right = 1)\n",
+    "plt.subplots_adjust(left = 0)\n",
+    "plt.axis('off')\n",
+    "plt.savefig('/home/rzlin/ca36xymo/tikz/celerity-simulation-e1.png',bbox_inches='tight',transparent=True, pad_inches=0)\n",
+    "\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "e8146b19",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# FFT along x per time frame -> find wavelength\n",
+    "\n",
+    "import scipy.fft\n",
+    "\n",
+    "N = hbOverTime.shape[-1] # = number of samples, here xSize\n",
+    "colors = sns.color_palette(\"crest\",hbOverTime.shape[0] )\n",
+    "plt.figure()\n",
+    "\n",
+    "for i,t in enumerate(evalTimeSteps):\n",
+    "\n",
+    "    yf = scipy.fft.rfft(hbOverTime[i])\n",
+    "    sampleSpacing = 1 # = dx\n",
+    "    \n",
+    "    # discard first element (is 0 frequency -> offset of sinus)\n",
+    "    xf = scipy.fft.rfftfreq(N,sampleSpacing)[1:]\n",
+    "    yfMod = (2.0 / N * np.abs(yf))[1:]\n",
+    "    plt.plot(1/xf, yfMod, color=colors[i])\n",
+    "\n",
+    "    print(t, \"Dominant wavelength / D = \", (1/xf[np.argmax(yfMod)])/avgDiameter )\n",
+    "\n",
+    "plt.xlabel(\"wave length (LU)\")\n",
+    "plt.ylabel(\"amplitude (LU)\")\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4c441c91",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# FFT along y per dx -> find period length (same as above with transposed array)\n",
+    "\n",
+    "import scipy.fft\n",
+    "\n",
+    "averageHb = np.average(hbOverTime) * dx\n",
+    "# use only data from some later time step to avoid including the influence of the initial condition\n",
+    "hbOverTimeShort = hbOverTime[:250,:] * dx - averageHb\n",
+    "\n",
+    "hbOverTimeTransposed = np.transpose(hbOverTimeShort)\n",
+    "\n",
+    "N = hbOverTimeTransposed.shape[-1] # = number of samples, here xSize\n",
+    "colors = sns.color_palette(\"crest\", hbOverTimeTransposed.shape[0] )\n",
+    "plt.figure()\n",
+    "\n",
+    "for i,t in enumerate(np.arange(0,hbOverTime.shape[-1])):\n",
+    "\n",
+    "    yf = scipy.fft.rfft(hbOverTimeTransposed[i])\n",
+    "    sampleSpacing = evalTimeSteps[1] - evalTimeSteps[0]\n",
+    "    \n",
+    "    # discard first element (is 0 frequency -> offset of sinus)\n",
+    "    xf = scipy.fft.rfftfreq(N,sampleSpacing)[1:]\n",
+    "    yfMod = (2.0 / N * np.abs(yf))[1:]\n",
+    "    plt.plot(1/xf, yfMod, color=colors[i])\n",
+    "\n",
+    "    print(t, \"Dominant period length / s = \", (1/xf[np.argmax(yfMod)]) * dt )\n",
+    "\n",
+    "plt.xlabel(\"Period length T / s\")\n",
+    "plt.ylabel(\"amplitude (LU)\")\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "19469336",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# # transport rate\n",
+    "\n",
+    "# def readValueOverTimeFromFile(fileName, columnIndex):\n",
+    "#     return np.transpose(np.loadtxt(fileName, usecols=(0,columnIndex)))\n",
+    "\n",
+    "# sedimentTransportRateData = readValueOverTimeFromFile(baseFolder+'/bedload.txt',1)\n",
+    "\n",
+    "\n",
+    "# transportRateRef = avgDiameter * averageVelocity # = l_ref^2 / t_ref,  m^2 / s\n",
+    "\n",
+    "# plt.figure()\n",
+    "# plt.plot(sedimentTransportRateData[0] / timeRef, sedimentTransportRateData[1] / transportRateRef, '-',label=\"simulation\")\n",
+    "\n",
+    "# plt.axhline(2.0e-5 / (2.9e-3 * 0.37), linestyle='--', color='k', label=\"E1 (experiment)\")\n",
+    "# plt.axhline(6.1e-5 / (2.9e-3 * 0.46), linestyle='-.', color='k', label=\"E4 (experiment)\")\n",
+    "# plt.xlabel(r\"$t / t_{ref}$\")\n",
+    "# plt.ylabel(r\"$q_s / q_{ref}$\")\n",
+    "# plt.grid()\n",
+    "# plt.legend()\n",
+    "# plt.show()\n",
+    "\n",
+    "# # import tikzplotlib\n",
+    "# # tikzplotlib.clean_figure()\n",
+    "# # tikzplotlib.save(\n",
+    "# #     'bedload-transport-rate.tex',\n",
+    "# #     axis_height = '\\\\figureheight',\n",
+    "# #     axis_width = '\\\\figurewidth'\n",
+    "# #     )\n",
+    "\n",
+    "# # in Pascal Paper:\n",
+    "# # q_s,out approx q_s,in (Fig. 5)\n",
+    "# # -> take values from Tab. 1 as reference\n",
+    "# # E1: 2.0e-5 , E4: 6.1e-5 m^2/s\n",
+    "# # normalize by averageDiameter ( = 2.9mm) * averageVelocity (U)\n",
+    "# # -> E1: q_normalized = 0.0186, E4: q_normalized = 0.0457"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b43b04f7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# transport rate in SI units\n",
+    "\n",
+    "def readValueOverTimeFromFile(fileName, columnIndex):\n",
+    "    return np.transpose(np.loadtxt(fileName, usecols=(0,columnIndex)))\n",
+    "\n",
+    "sedimentTransportRateData = readValueOverTimeFromFile(baseFolder+'/bedload.txt',1)\n",
+    "\n",
+    "timestep_length = sedimentTransportRateData[0,1] - sedimentTransportRateData[0,0]\n",
+    "\n",
+    "t_min = 30\n",
+    "timestep_index_min = int((t_min / dt) // timestep_length)\n",
+    "\n",
+    "t_max = 75\n",
+    "timestep_index_max = int((t_max / dt) // timestep_length)\n",
+    "\n",
+    "plt.figure()\n",
+    "\n",
+    "## E1\n",
+    "#filepath = \"/simdata/on74yces/experiment-bedload-rate/qs_E1.csv\"\n",
+    "#exp_mean = np.array([[t_min, t_min + 10, t_max], [2.0e-5, 2.0e-5, 2.0e-5]])\n",
+    "\n",
+    "# E4\n",
+    "filepath = \"/simdata/on74yces/experiment-bedload-rate/qs_E4.csv\"\n",
+    "exp_mean = np.array([[t_min, t_min + 10, t_max], [6.1e-5, 6.1e-5, 6.1e-5]])\n",
+    "\n",
+    "data = np.transpose(np.loadtxt(filepath, delimiter=';',skiprows=1))\n",
+    "plt.plot(data[0,int(t_min//0.2):int(t_max//0.2)], data[1,int(t_min//0.2):int(t_max//0.2)], '-',label=\"Experiment\")\n",
+    "#plt.plot(exp_mean[0], exp_mean[1], linestyle='--', color='black', label=\"Experiment (mean)\")\n",
+    "\n",
+    "plt.plot(sedimentTransportRateData[0,timestep_index_min:timestep_index_max] * dt, \n",
+    "         sedimentTransportRateData[1,timestep_index_min:timestep_index_max] * dx**2 / dt, '-',label=\"Simulation\")\n",
+    "mean = np.mean(sedimentTransportRateData[1,timestep_index_min:timestep_index_max] * dx**2 / dt)\n",
+    "mean_array = np.array([[t_min, t_min + 10, t_max], [mean, mean, mean]])\n",
+    "#plt.plot(mean_array[0], mean_array[1], linestyle='-', color='black', label=\"Simulation (mean)\")\n",
+    "\n",
+    "plt.xlabel(r\"$t$ / s\")\n",
+    "plt.ylabel(r\"$q_s$ / (m$^{2}$/s)\")\n",
+    "\n",
+    "ax = plt.gca()\n",
+    "ax.set_xlim([t_min, t_max])\n",
+    "\n",
+    "plt.grid()\n",
+    "#plt.legend()\n",
+    "plt.show()\n",
+    "\n",
+    "print(np.mean(sedimentTransportRateData[1,timestep_index_min:timestep_index_max] * dx**2 / dt))\n",
+    "\n",
+    "# For tikzplotlib export:\n",
+    "# - remove legend\n",
+    "# - remove mean plots\n",
+    "import tikzplotlib\n",
+    "tikzplotlib.clean_figure()\n",
+    "tikzplotlib.save(\n",
+    "    '/home/rzlin/ca36xymo/tikz/bedload-rate.tex',\n",
+    "    axis_height = '\\\\figureheight',\n",
+    "    axis_width = '\\\\figurewidth'\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "40c316a1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# compute median particle diameter\n",
+    "\n",
+    "import numpy as  np\n",
+    "\n",
+    "filepath = \"/simdata/on74yces/2395774/spheres_out.dat\"\n",
+    "\n",
+    "particles = np.loadtxt(filepath, delimiter=' ', skiprows=1)\n",
+    "\n",
+    "# 50th percentile diameter\n",
+    "diameterMedian = np.median(particles[:,4]*2)\n",
+    "print(\"50th percentile, i.e., median particle diameter: \", diameterMedian, \"m\")\n",
+    "print(\"50th percentile, i.e., median particle diameter: \", diameterMedian / dx, \"cells\")\n",
+    "\n",
+    "# 16th percentile diameter\n",
+    "diameter16th = np.percentile(particles[:,4]*2, 16)\n",
+    "print(\"16th percentile particle diameter: \", diameter16th, \"m\")\n",
+    "print(\"16th percentile particle diameter: \", diameter16th / dx, \"cells\")\n",
+    "\n",
+    "# 84th percentile diameter\n",
+    "diameter84th = np.percentile(particles[:,4]*2, 84)\n",
+    "print(\"84th percentile particle diameter: \", diameter84th, \"m\")\n",
+    "print(\"84th percentile particle diameter: \", diameter84th / dx, \"cells\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5f7f03b1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# plot data from experiment of Pascal et al.\n",
+    "import math\n",
+    "\n",
+    "filepath = \"/simdata/on74yces/experiment-bed-elevation/bed_surface_in_time_E4.csv\"\n",
+    "data = np.loadtxt(filepath, delimiter=';')\n",
+    "\n",
+    "# crop data to relevant time steps\n",
+    "data = data[1200:1450,:]\n",
+    "\n",
+    "# remove inclination from data\n",
+    "sine3degree = math.sin(3.0*math.pi/180)\n",
+    "for x in range(0, data.shape[1]):\n",
+    "    data[:,x] = data[:,x] - abs(x-1280)*0.75/data.shape[1]*sine3degree\n",
+    "\n",
+    "xPlotLimitsN = np.array([0, data.shape[1]]) * 0.75/data.shape[1]\n",
+    "tPlotLimitsN = np.array([1200, 1200+data.shape[0]])\n",
+    "averageHbN = np.average(data)\n",
+    "\n",
+    "plt.figure()\n",
+    "plt.imshow(data - averageHbN, interpolation='none', origin='lower',\n",
+    "           aspect='auto', extent=(*xPlotLimitsN,*tPlotLimitsN), vmin=-5e-3, vmax=5e-3)\n",
+    "plt.colorbar()\n",
+    "plt.xlabel(r\"$x$ / m\")\n",
+    "plt.ylabel(r\"$t$ / s\")\n",
+    "plt.show()\n",
+    "\n",
+    "print(data.shape)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "45779e36",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import math\n",
+    "\n",
+    "# plot bed height evaluation of experiment\n",
+    "    # time restricted to 1200 - 1245 s\n",
+    "    # x-domain restricted to 0 - 0.75 m\n",
+    "    \n",
+    "filepath = \"/simdata/on74yces/experiment-bed-elevation/bed_surface_in_time_E1.csv\"\n",
+    "hbOverTimeExp = np.loadtxt(filepath, delimiter=';')\n",
+    "\n",
+    "dxExp = 0.75 / 1280\n",
+    "\n",
+    "# remove inclination from data\n",
+    "sine3degree = math.sin(3*math.pi/180)\n",
+    "for x in range(0, hbOverTimeExp.shape[1]):\n",
+    "    hbOverTimeExp[:,x] = hbOverTimeExp[:,x] - abs(x-1280)*0.75/hbOverTimeExp.shape[1]*sine3degree\n",
+    "\n",
+    "t_min = 1200\n",
+    "timestep_index_min = t_min\n",
+    "\n",
+    "t_max = 1245\n",
+    "timestep_index_max = t_max\n",
+    "\n",
+    "x_min = 0\n",
+    "x_index_min = int(x_min // dxExp)\n",
+    "\n",
+    "x_max = 0.75\n",
+    "x_index_max = int(x_max // dxExp)\n",
+    "\n",
+    "xPlotLimits = np.array([x_min,x_max])\n",
+    "tPlotLimits = np.array([t_min, t_max])\n",
+    "averageHbExp = np.average(hbOverTimeExp)\n",
+    "\n",
+    "plt.figure()\n",
+    "plt.imshow(hbOverTimeExp[timestep_index_min:timestep_index_max,x_index_min:x_index_max] - averageHbExp, \n",
+    "           interpolation='none', origin='lower', aspect='auto', extent=(*xPlotLimits,*tPlotLimits), \n",
+    "           vmin=-5e-3, vmax=5e-3)\n",
+    "\n",
+    "cb = plt.colorbar(label=r\"$h$ / m\")\n",
+    "#match_colorbar(cb)\n",
+    "#cb.ax.tick_params(labelsize=8) \n",
+    "plt.xlabel(r\"$x$ / m\")\n",
+    "plt.ylabel(r\"$t$ / s\")\n",
+    "\n",
+    "import tikzplotlib\n",
+    "tikzplotlib.clean_figure()\n",
+    "tikzplotlib.save(\n",
+    "    '/home/rzlin/ca36xymo/tikz/bed-elevation-experiment.tex',\n",
+    "    axis_height = '\\\\figureheight',\n",
+    "    axis_width = '\\\\figurewidth'\n",
+    "    )\n",
+    "\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "dec9d4b2",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import scipy.fft\n",
+    "\n",
+    "# plot spectral density of bed height in experiment\n",
+    "\n",
+    "filepath = \"/simdata/on74yces/experiment-bed-elevation/bed_surface_in_time_E1.csv\"\n",
+    "hbOverTimeExp = np.loadtxt(filepath, delimiter=';')\n",
+    "\n",
+    "dxExp = 0.75 / 1280\n",
+    "\n",
+    "# remove inclination from data\n",
+    "sine3degree = math.sin(2.9*math.pi/180)\n",
+    "for x in range(0, hbOverTimeExp.shape[1]):\n",
+    "    hbOverTimeExp[:,x] = hbOverTimeExp[:,x] - abs(x-1280)*0.75/hbOverTimeExp.shape[1]*sine3degree\n",
+    "\n",
+    "averageHbExp = np.average(hbOverTimeExp)\n",
+    "\n",
+    "# power spectral density (PSD) on period-wavelength (PW) plane\n",
+    "psd_pw_exp = scipy.fft.fft2(hbOverTimeExp - averageHbExp)\n",
+    "psd_pw_exp = np.abs(psd_pw_exp)**2 / (hbOverTimeExp.shape[0] * hbOverTimeExp.shape[1])\n",
+    "\n",
+    "ft_period_exp = np.abs(1 / scipy.fft.fftfreq(np.transpose(hbOverTimeExp).shape[-1],1)[1:])\n",
+    "ft_wavelength_exp = np.abs(1 / scipy.fft.fftfreq(hbOverTimeExp.shape[-1],0.75/1280)[1:])\n",
+    "\n",
+    "plt.figure()\n",
+    "\n",
+    "plt.contourf(ft_period_exp, ft_wavelength_exp, np.transpose(np.abs(psd_pw_exp[1:,1:])))\n",
+    "#cb = plt.colorbar(label=r\"PSD / m$^2$\")\n",
+    "#match_colorbar(cb)\n",
+    "plt.xlabel(r\"$T$ / s\")\n",
+    "plt.ylabel(r\"$\\lambda$ / m\")\n",
+    "ax = plt.gca()\n",
+    "ax.set_xlim([5, 70])\n",
+    "ax.set_ylim([0.05, 0.2])\n",
+    "\n",
+    "# uncomment the following things to only store the png (no axes etc.)\n",
+    "# => tikz image must be created manually later\n",
+    "plt.subplots_adjust(bottom = 0)\n",
+    "plt.subplots_adjust(top = 1)\n",
+    "plt.subplots_adjust(right = 1)\n",
+    "plt.subplots_adjust(left = 0)\n",
+    "plt.axis('off')\n",
+    "plt.savefig('/home/rzlin/ca36xymo/tikz/spectral-density-experiment.png',bbox_inches='tight',transparent=True, pad_inches=0)\n",
+    "\n",
+    "\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "cf86063d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "psd_pw_exp_new = np.transpose(np.abs(psd_pw_exp[1:,1:]))\n",
+    "\n",
+    "# compute celerity (for each point in wavelength-period PSD-array)\n",
+    "# => identify all possible y-axis values for new celerity-wavelength PSD-array\n",
+    "c_exp = []\n",
+    "x = 0\n",
+    "for wavelength in ft_wavelength_exp:\n",
+    "    y = 0\n",
+    "    for period in ft_period_exp:\n",
+    "        if psd_pw_exp_new[x,y] > 0.0001:\n",
+    "            c_exp.append(wavelength / period)\n",
+    "        y += 1\n",
+    "    x += 1\n",
+    "\n",
+    "c_exp = list(set(c_exp))    # remove duplicate entries from list\n",
+    "c_exp.sort()                # sort in ascending order\n",
+    "# list 'c' contains all possible celerities in ascending order without duplicates\n",
+    "# => this will be the x-axis of the celerity-wavelength PSD-array\n",
+    "\n",
+    "# create celerity-wavelength PSD-array \n",
+    "psd_cw = np.zeros((ft_wavelength_exp.shape[0], len(c_exp)))\n",
+    "\n",
+    "# fill this array with PSD at correct position\n",
+    "for i_wavelength in range(0, ft_wavelength_exp.shape[0]):\n",
+    "    for i_period in range(0, ft_period_exp.shape[0]):\n",
+    "        if psd_pw_exp_new[i_wavelength,i_period] > 0.0001:\n",
+    "            celerity = ft_wavelength_exp[i_wavelength] / ft_period_exp[i_period] # compute celerity\n",
+    "            i_celerity = c_exp.index(celerity) # map celerity to it's index on the x-axis of the new array\n",
+    "            psd_cw[i_wavelength,i_celerity] = abs(psd_pw_exp_new[i_wavelength,i_period]) # store PSD at correct position in new array\n",
+    "\n",
+    "plt.figure()\n",
+    "plt.contourf(ft_wavelength_exp, np.asarray(c_exp), np.abs(np.transpose(psd_cw)),colors='tab:blue')\n",
+    "#plt.colorbar()\n",
+    "plt.xlabel(r\"$\\lambda$ / m\")\n",
+    "plt.ylabel(r\"$c$ / m/s\")\n",
+    "ax = plt.gca()\n",
+    "ax.set_xlim([0.05, 0.2])\n",
+    "ax.set_ylim([0, 0.015])\n",
+    "\n",
+    "# uncomment the following things to only store the png (no axes etc.)\n",
+    "# => tikz image must be created manually later\n",
+    "plt.subplots_adjust(bottom = 0)\n",
+    "plt.subplots_adjust(top = 1)\n",
+    "plt.subplots_adjust(right = 1)\n",
+    "plt.subplots_adjust(left = 0)\n",
+    "plt.axis('off')\n",
+    "plt.savefig('/home/rzlin/ca36xymo/tikz/celerity-experiment-e1.png',bbox_inches='tight',transparent=True, pad_inches=0)\n",
+    "\n",
+    "\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7168b78e",
+   "metadata": {
+    "scrolled": true
+   },
+   "outputs": [],
+   "source": [
+    "# grid refinement study: transport rate in SI units\n",
+    "\n",
+    "def readValueOverTimeFromFile(fileName, columnIndex):\n",
+    "    return np.transpose(np.loadtxt(fileName, usecols=(0,columnIndex)))\n",
+    "\n",
+    "t_min = 0\n",
+    "t_max = 50\n",
+    "t_min_for_mean = 10\n",
+    "\n",
+    "dx_coarse = 0.0005\n",
+    "dt_coarse = 2.16294e-05\n",
+    "sedimentTransportRateData_coarse = readValueOverTimeFromFile(\"/simdata/on74yces/resolution5\"+'/bedload.txt',1)\n",
+    "timestep_length_coarse = sedimentTransportRateData_coarse[0,1] - sedimentTransportRateData_coarse[0,0]\n",
+    "timestep_index_min_coarse = int((t_min / dt_coarse) // timestep_length_coarse)\n",
+    "timestep_index_max_coarse = int((t_max / dt_coarse) // timestep_length_coarse)\n",
+    "timestep_index_min_mean_coarse = int((35 / dt_coarse) // timestep_length_coarse)\n",
+    "mean_coarse = np.mean(sedimentTransportRateData_coarse[1,timestep_index_min_mean_coarse:timestep_index_max_coarse] * dx_coarse**2 / dt_coarse)\n",
+    "mean_array_coarse = np.array([[35, 35+1, t_max], [mean_coarse, mean_coarse, mean_coarse]])\n",
+    "\n",
+    "print(sedimentTransportRateData_coarse[0,-1])\n",
+    "print(sedimentTransportRateData_coarse[0,-1] * dt_coarse)\n",
+    "print(mean_coarse)\n",
+    "\n",
+    "dx_medium = 0.00025\n",
+    "dt_medium = 1.08147e-05\n",
+    "sedimentTransportRateData_medium = readValueOverTimeFromFile(\"/simdata/on74yces/resolution10\"+'/bedload.txt',1)\n",
+    "timestep_length_medium = sedimentTransportRateData_medium[0,1] - sedimentTransportRateData_medium[0,0]\n",
+    "timestep_index_min_medium = int((t_min / dt_medium) // timestep_length_medium)\n",
+    "timestep_index_max_medium = int((t_max / dt_medium) // timestep_length_medium)\n",
+    "timestep_index_min_mean_medium = int((t_min_for_mean / dt_medium) // timestep_length_medium)\n",
+    "mean_medium = np.mean(sedimentTransportRateData_medium[1,timestep_index_min_mean_medium:timestep_index_max_medium] * dx_medium**2 / dt_medium)\n",
+    "mean_array_medium = np.array([[t_min_for_mean, t_min_for_mean+1, t_max], [mean_medium, mean_medium, mean_medium]])\n",
+    "\n",
+    "print(sedimentTransportRateData_medium[0,-1])\n",
+    "print(sedimentTransportRateData_medium[0,-1] * dt_medium)\n",
+    "print(mean_medium)\n",
+    "\n",
+    "dx_fine = 0.000125\n",
+    "dt_fine = 5.40734e-06\n",
+    "sedimentTransportRateData_fine = readValueOverTimeFromFile(\"/simdata/on74yces/resolution20\"+'/bedload.txt',1)\n",
+    "timestep_length_fine = sedimentTransportRateData_fine[0,1] - sedimentTransportRateData_fine[0,0]\n",
+    "timestep_index_min_fine = int((t_min / dt_fine) // timestep_length_fine)\n",
+    "timestep_index_max_fine = int((t_max / dt_fine) // timestep_length_fine)\n",
+    "timestep_index_min_mean_fine = int((t_min_for_mean / dt_fine) // timestep_length_fine)\n",
+    "mean_fine = np.mean(sedimentTransportRateData_fine[1,timestep_index_min_mean_fine:timestep_index_max_fine] * dx_fine**2 / dt_fine)\n",
+    "mean_array_fine = np.array([[t_min_for_mean, t_min_for_mean+1, t_max], [mean_fine, mean_fine, mean_fine]])\n",
+    "\n",
+    "print(sedimentTransportRateData_fine[0,-1])\n",
+    "print(sedimentTransportRateData_fine[0,-1] * dt_fine)\n",
+    "print(mean_fine)\n",
+    "\n",
+    "plt.figure()\n",
+    "plt.plot(sedimentTransportRateData_coarse[0,timestep_index_min_coarse:timestep_index_max_coarse:4] * dt_coarse,\n",
+    "         sedimentTransportRateData_coarse[1,timestep_index_min_coarse:timestep_index_max_coarse:4] * dx_coarse**2 / dt_coarse,\n",
+    "         '-',label=\"Coarse\",color=\"tab:purple\")\n",
+    "\n",
+    "plt.plot(sedimentTransportRateData_medium[0,timestep_index_min_medium:timestep_index_max_medium:8] * dt_medium,\n",
+    "         sedimentTransportRateData_medium[1,timestep_index_min_medium:timestep_index_max_medium:8] * dx_medium**2 / dt_medium,\n",
+    "         '-',label=\"Medium\")\n",
+    "\n",
+    "plt.plot(sedimentTransportRateData_fine[0,timestep_index_min_fine:timestep_index_max_fine:16] * dt_fine,\n",
+    "         sedimentTransportRateData_fine[1,timestep_index_min_fine:timestep_index_max_fine:16] * dx_fine**2 / dt_fine,\n",
+    "         '-',label=\"Fine\")\n",
+    "\n",
+    "plt.plot(mean_array_coarse[0], mean_array_coarse[1], linestyle=':', color='black', label=\"Coarse (mean)\")\n",
+    "plt.plot(mean_array_medium[0], mean_array_medium[1], linestyle='-', color='black', label=\"Medium (mean)\")\n",
+    "plt.plot(mean_array_fine[0], mean_array_fine[1], linestyle='--', color='black', label=\"Fine (mean)\")\n",
+    "\n",
+    "plt.xlabel(r\"$t$ / s\")\n",
+    "plt.ylabel(r\"$q_s$ / (m$^{2}$/s)\")\n",
+    "\n",
+    "ax = plt.gca()\n",
+    "ax.set_xlim([t_min, t_max])\n",
+    "\n",
+    "plt.grid()\n",
+    "plt.legend()\n",
+    "plt.show()\n",
+    "\n",
+    "# # For tikzplotlib export:\n",
+    "# # - remove legend\n",
+    "# # - remove mean plots\n",
+    "# import tikzplotlib\n",
+    "# tikzplotlib.clean_figure()\n",
+    "# tikzplotlib.save(\n",
+    "#     '/home/rzlin/ca36xymo/tikz/resolution-study.tex',\n",
+    "#     axis_height = '\\\\figureheight',\n",
+    "#     axis_width = '\\\\figurewidth'\n",
+    "#     )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "75ff7fe7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def readValueOverTimeFromFile(fileName, columnIndex):\n",
+    "    return np.loadtxt(fileName, usecols=(0,columnIndex))\n",
+    "\n",
+    "dx = 0.00025\n",
+    "dt = 1.08147e-05\n",
+    "velocity = readValueOverTimeFromFile(\"/simdata/on74yces/merge_E4_flat_new\"+'/fluidInfo.txt',2)\n",
+    "t = velocity[:,0] * dt\n",
+    "velocity = velocity[:,1] * dx/dt\n",
+    "plt.plot(t, velocity)\n",
+    "\n",
+    "plt.ylabel(r\"$U_{x,\\text{l}}$ / (m/s)\")\n",
+    "plt.xlabel(r\"$t$ / s\")\n",
+    "\n",
+    "import tikzplotlib\n",
+    "tikzplotlib.clean_figure()\n",
+    "tikzplotlib.save(\n",
+    "    '/home/rzlin/ca36xymo/tikz/bed-elevation-simulation.tex',\n",
+    "    axis_height = '\\\\figureheight',\n",
+    "    axis_width = '\\\\figurewidth'\n",
+    "    )\n",
+    "\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "0605ba06",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# old stuff below"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "40b9bb68",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(3*0.37 / 0.0083 * 1e-6)/((2.55-1)*9.81*2.9e-3)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1f88f760",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(np.max(hbOverTime[0,:]),np.min(hbOverTime[0,:]))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "345263f0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import seaborn as sns\n",
+    "plt.figure()\n",
+    "sns.distplot(x=fluidSurfaceOverTime[0,:])\n",
+    "plt.show()"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/apps/showcases/CMakeLists.txt b/apps/showcases/CMakeLists.txt
index c6f0534cc6679f996777742823e5901aca8233f4..0b655239106e3c076b00e1c071e89d9cffb7d4d7 100644
--- a/apps/showcases/CMakeLists.txt
+++ b/apps/showcases/CMakeLists.txt
@@ -8,9 +8,12 @@ add_subdirectory( Mixer )
 add_subdirectory( ParticlePacking )
 add_subdirectory( PegIntoSphereBed )
 if ( WALBERLA_BUILD_WITH_CODEGEN AND WALBERLA_BUILD_WITH_PYTHON )
-add_subdirectory( PhaseFieldAllenCahn )
+   add_subdirectory( PhaseFieldAllenCahn )
 endif()
 if ( WALBERLA_BUILD_WITH_CODEGEN AND NOT WALBERLA_BUILD_WITH_OPENMP)
    add_subdirectory( PorousMedia )
 endif()
+if ( WALBERLA_BUILD_WITH_CODEGEN )
+   add_subdirectory( Antidunes )
+endif()
 
diff --git a/src/lbm/blockforest/communication/SimpleCommunication.h b/src/lbm/blockforest/communication/SimpleCommunication.h
index 764152da65f081b164b102a41f3d639db5ae9065..7e256f92977f04a2c7e3d7d1321b409b3eb883fb 100644
--- a/src/lbm/blockforest/communication/SimpleCommunication.h
+++ b/src/lbm/blockforest/communication/SimpleCommunication.h
@@ -47,6 +47,7 @@ class SimpleCommunication : public communication::UniformBufferedScheme< Stencil
    using VectorFieldFlattened_T = GhostLayerField< real_t, 3 >;
    using PdfField_T             = GhostLayerField< real_t, Stencil_T::Size >;
    using UintScalarField_T      = GhostLayerField< uint_t, 1 >;
+   using IDScalarField_T        = walberla::GhostLayerField< walberla::id_t, 1 >;
 
    using FlagField16_T = FlagField< uint16_t >;
    using FlagField32_T = FlagField< uint32_t >;
@@ -148,17 +149,24 @@ class SimpleCommunication : public communication::UniformBufferedScheme< Stencil
                      }
                      else
                      {
-                        if (firstBlock.isDataClassOrSubclassOf< UintScalarField_T >(fieldId))
+                        if (firstBlock.isDataClassOrSubclassOf< IDScalarField_T >(fieldId))
                         {
-                           this->addPackInfo(make_shared< PackInfo< UintScalarField_T > >(fieldId));
+                           this->addPackInfo(make_shared< PackInfo< IDScalarField_T > >(fieldId));
                         }
                         else
                         {
-                           if (firstBlock.isDataClassOrSubclassOf< VectorFieldFlattened_T >(fieldId))
+                           if (firstBlock.isDataClassOrSubclassOf< UintScalarField_T >(fieldId))
                            {
-                              this->addPackInfo(make_shared< PackInfo< VectorFieldFlattened_T > >(fieldId));
+                              this->addPackInfo(make_shared< PackInfo< UintScalarField_T > >(fieldId));
+                           }
+                           else
+                           {
+                              if (firstBlock.isDataClassOrSubclassOf< VectorFieldFlattened_T >(fieldId))
+                              {
+                                 this->addPackInfo(make_shared< PackInfo< VectorFieldFlattened_T > >(fieldId));
+                              }
+                              else { WALBERLA_ABORT("Problem with UID"); }
                            }
-                           else { WALBERLA_ABORT("Problem with UID"); }
                         }
                      }
                   }
diff --git a/src/lbm/free_surface/InitFunctions.h b/src/lbm/free_surface/InitFunctions.h
index 22cd09c62c794260668a70c30b07f52495c3a780..0dd2a0bbb25938f82ebc4a0957238e7852a086f6 100644
--- a/src/lbm/free_surface/InitFunctions.h
+++ b/src/lbm/free_surface/InitFunctions.h
@@ -85,7 +85,7 @@ void initFlagsFromFillLevels(const std::weak_ptr< StructuredBlockForest >& block
          // set flags only in non-boundary and non-obstacle cells
          if (!handling->isBoundary(fillFieldIt.x(), fillFieldIt.y(), fillFieldIt.z()))
          {
-            if (*fillFieldIt <= real_c(0))
+            if (floatIsEqual(*fillFieldIt, real_c(0), real_c(1e-14)))
             {
                // set gas flag
                handling->forceFlag(flagInfo.gasFlag, fillFieldIt.x(), fillFieldIt.y(), fillFieldIt.z());
diff --git a/src/lbm/free_surface/InterfaceFromFillLevel.h b/src/lbm/free_surface/InterfaceFromFillLevel.h
index ef113e327032695d73df64109f0581c3b03cc4bd..f50c16cdd046f6bea042e163bdec5880f5172945 100644
--- a/src/lbm/free_surface/InterfaceFromFillLevel.h
+++ b/src/lbm/free_surface/InterfaceFromFillLevel.h
@@ -44,7 +44,7 @@ inline bool isInterfaceFromFillLevel(const ScalarField_T& fillField, cell_idx_t
    real_t fillLevel = fillField.get(x, y, z);
 
    // this cell is regular gas cell
-   if (fillLevel <= real_c(0.0)) { return false; }
+   if (floatIsEqual(fillLevel, real_c(0.0), real_c(1e-14))) { return false; }
 
    // this cell is regular interface cell
    if (fillLevel < real_c(1.0)) { return true; }
diff --git a/src/lbm/free_surface/VtkWriter.h b/src/lbm/free_surface/VtkWriter.h
index 726e0ac735ef4495d234532a7e3eb3e52c0372a8..f8d3b453d79edc572e8d697ebc921b615a2f2486 100644
--- a/src/lbm/free_surface/VtkWriter.h
+++ b/src/lbm/free_surface/VtkWriter.h
@@ -87,6 +87,7 @@ void addVTKOutput(const std::weak_ptr< StructuredBlockForest >& blockForestPtr,
       writers.push_back(std::make_shared< VTKWriter< VectorField_T, float > >(normalFieldID, "normal"));
       writers.push_back(
          std::make_shared< VTKWriter< VectorField_T, float > >(obstacleNormalFieldID, "obstacle_normal"));
+
       if constexpr (useCodegen)
       {
          if (forceDensityFieldID != BlockDataID())
diff --git a/src/lbm_mesapd_coupling/momentum_exchange_method/boundary/CurvedLinear.h b/src/lbm_mesapd_coupling/momentum_exchange_method/boundary/CurvedLinear.h
index eeba79e77a31c0989ea397c859babb05c6eca8a0..215c03f3a543ea81545b5e291187bfce15e66734 100644
--- a/src/lbm_mesapd_coupling/momentum_exchange_method/boundary/CurvedLinear.h
+++ b/src/lbm_mesapd_coupling/momentum_exchange_method/boundary/CurvedLinear.h
@@ -80,7 +80,8 @@ public:
 
    inline CurvedLinear( const BoundaryUID & boundaryUID, const FlagUID & uid, PDFField_T * const pdfField, const FlagField_T * const flagField,
                         ParticleField_T * const particleField, const shared_ptr<ParticleAccessor_T>& ac,
-                        const flag_t domain, const StructuredBlockStorage & blockStorage, const IBlock & block );
+                        const flag_t domain, const StructuredBlockStorage & blockStorage, const IBlock & block,
+                        std::function<real_t(const Vector3<real_t>&)> hydrostaticDensityFct = nullptr );
 
    void pushFlags( std::vector< FlagUID >& uids ) const { uids.push_back( uid_ ); }
 
@@ -119,6 +120,8 @@ private:
    const StructuredBlockStorage & blockStorage_;
    const IBlock & block_;
 
+   std::function<real_t(const Vector3<real_t>&)> hydrostaticDensityFct_;
+
    real_t lengthScalingFactor_;
    real_t forceScalingFactor_;
 
@@ -128,8 +131,10 @@ private:
 template< typename LatticeModel_T, typename FlagField_T, typename ParticleAccessor_T >
 inline CurvedLinear< LatticeModel_T, FlagField_T, ParticleAccessor_T >::CurvedLinear( const BoundaryUID & boundaryUID, const FlagUID & uid, PDFField_T * const pdfField, const FlagField_T * const flagField,
                                                                                       ParticleField_T * const particleField, const shared_ptr<ParticleAccessor_T>& ac,
-                                                                                      const flag_t domain, const StructuredBlockStorage & blockStorage, const IBlock & block ):
-Boundary<flag_t>( boundaryUID ), uid_( uid ), pdfField_( pdfField ), flagField_( flagField ), particleField_( particleField ), ac_( ac ), domainMask_(domain), blockStorage_( blockStorage ), block_( block )
+                                                                                      const flag_t domain, const StructuredBlockStorage & blockStorage, const IBlock & block,
+                                                                                      std::function<real_t(const Vector3<real_t>&)> hydrostaticDensityFct ):
+Boundary<flag_t>( boundaryUID ), uid_( uid ), pdfField_( pdfField ), flagField_( flagField ), particleField_( particleField ),
+ac_( ac ), domainMask_(domain), blockStorage_( blockStorage ), block_( block ), hydrostaticDensityFct_(hydrostaticDensityFct)
 {
    WALBERLA_ASSERT_NOT_NULLPTR( pdfField_ );
    WALBERLA_ASSERT_NOT_NULLPTR( flagField_ );
@@ -159,12 +164,12 @@ inline void CurvedLinear< LatticeModel_T, FlagField_T, ParticleAccessor_T >::tre
    WALBERLA_ASSERT_UNEQUAL( mask & this->mask_, numeric_cast<flag_t>(0) );
    WALBERLA_ASSERT_EQUAL  ( mask & this->mask_, this->mask_ ); // only true if "this->mask_" only contains one single flag, which is the case for the
                                                                // current implementation of this boundary condition
-   WALBERLA_ASSERT_UNEQUAL( particleField_->get(nx,ny,nz), ac_->getInvalidUid() );
 
    // determine distance to real boundary, i.e. delta value
    // cell center of the near-boundary fluid cell
    Cell nearBoundaryCell(x,y,z);
    Vector3< real_t > cellCenter = blockStorage_.getBlockLocalCellCenter(block_, nearBoundaryCell);
+   WALBERLA_ASSERT_UNEQUAL( particleField_->get(nx,ny,nz), ac_->getInvalidUid(), x << " " << y << " " << z  << " -> " << nx << " " << ny << " " << nz << " " << cellCenter);
 
    // direction of the ray (from the fluid cell center to the boundary cell)
    Vector3< real_t > direction( lengthScalingFactor_ * real_c( stencil::cx[ dir ] ),
@@ -173,7 +178,7 @@ inline void CurvedLinear< LatticeModel_T, FlagField_T, ParticleAccessor_T >::tre
 
    //get particle index
    auto particleIdx = ac_->uidToIdx(particleField_->get( nx, ny, nz ));
-   WALBERLA_ASSERT_UNEQUAL( particleIdx, ac_->getInvalidIdx(), "Index of particle is invalid!" );
+   WALBERLA_ASSERT_UNEQUAL( particleIdx, ac_->getInvalidIdx(), "Index of particle is invalid! " << x << " " << y << " " << z  << " -> " << nx << " " << ny << " " << nz << " " << cellCenter << " UID:" << particleField_->get( nx, ny, nz ) );
 
    real_t delta = real_t(0);
    real_t pdf_new = real_t(0);
@@ -268,8 +273,17 @@ inline void CurvedLinear< LatticeModel_T, FlagField_T, ParticleAccessor_T >::tre
       // as a consequence, some (non-zero) PDF contributions would be missing after summing up the force contributions
       // those would need to be added artificially, see e.g. Ernst, Dietzel, Sommerfeld - A lattice Boltzmann method for simulating transport and agglomeration of resolved particles, Acta Mech, 2013
       // instead, we use the trick there that we just require the deviations from the equilibrium to get the correct force as it is already used for the incompressible case
-      pdf_old -= LatticeModel_T::w[ Stencil_T::idx[dir] ];
-      pdf_new -= LatticeModel_T::w[ Stencil_T::idx[dir] ];
+      if (hydrostaticDensityFct_ == nullptr)
+      {
+         pdf_old -= LatticeModel_T::w[Stencil_T::idx[dir]];
+         pdf_new -= LatticeModel_T::w[Stencil_T::idx[dir]];
+      }
+      else
+      {
+         const real_t rhoHydStat = hydrostaticDensityFct_(cellCenter);
+         pdf_old -= rhoHydStat * LatticeModel_T::w[Stencil_T::idx[dir]];
+         pdf_new -= rhoHydStat * LatticeModel_T::w[Stencil_T::idx[dir]];
+      }
    }
 
    // MEM: F = pdf_old + pdf_new - common
diff --git a/src/lbm_mesapd_coupling/momentum_exchange_method/boundary/SimpleBB.h b/src/lbm_mesapd_coupling/momentum_exchange_method/boundary/SimpleBB.h
index aeafb2b255fd965b248c71ee71c56b3c27972348..7d5c528489cae9578b34586238f282ba2dd0df7a 100644
--- a/src/lbm_mesapd_coupling/momentum_exchange_method/boundary/SimpleBB.h
+++ b/src/lbm_mesapd_coupling/momentum_exchange_method/boundary/SimpleBB.h
@@ -72,7 +72,8 @@ public:
 
    inline SimpleBB( const BoundaryUID & boundaryUID, const FlagUID & uid, PDFField_T * const pdfField, const FlagField_T * const flagField,
                     ParticleField_T * const particleField, const shared_ptr<ParticleAccessor_T>& ac,
-                    const flag_t domain, const StructuredBlockStorage & blockStorage, const IBlock & block );
+                    const flag_t domain, const StructuredBlockStorage & blockStorage, const IBlock & block,
+                    std::function<real_t(const Vector3<real_t>&)> hydrostaticDensityFct = nullptr );
 
    void pushFlags( std::vector< FlagUID >& uids ) const { uids.push_back( uid_ ); }
 
@@ -110,6 +111,8 @@ private:
    const StructuredBlockStorage & blockStorage_;
    const IBlock & block_;
 
+   std::function<real_t(const Vector3<real_t>&)> hydrostaticDensityFct_;
+
    real_t lengthScalingFactor_;
    real_t forceScalingFactor_;
 
@@ -119,8 +122,10 @@ private:
 template< typename LatticeModel_T, typename FlagField_T, typename ParticleAccessor_T >
 inline SimpleBB< LatticeModel_T, FlagField_T, ParticleAccessor_T >::SimpleBB( const BoundaryUID & boundaryUID, const FlagUID & uid, PDFField_T * const pdfField, const FlagField_T * const flagField,
                                                                               ParticleField_T * const particleField, const shared_ptr<ParticleAccessor_T>& ac,
-                                                                              const flag_t domain, const StructuredBlockStorage & blockStorage, const IBlock & block ):
-Boundary<flag_t>( boundaryUID ), uid_( uid ), pdfField_( pdfField ), flagField_( flagField ), particleField_( particleField ), ac_( ac ), domainMask_(domain), blockStorage_( blockStorage ), block_( block )
+                                                                              const flag_t domain, const StructuredBlockStorage & blockStorage, const IBlock & block,
+                                                                              std::function<real_t(const Vector3<real_t>&)> hydrostaticDensityFct):
+Boundary<flag_t>( boundaryUID ), uid_( uid ), pdfField_( pdfField ), flagField_( flagField ), particleField_( particleField ), ac_( ac ), domainMask_(domain),
+blockStorage_( blockStorage ), block_( block ), hydrostaticDensityFct_(hydrostaticDensityFct)
 {
    WALBERLA_ASSERT_NOT_NULLPTR( pdfField_ );
    WALBERLA_ASSERT_NOT_NULLPTR( flagField_ );
@@ -213,8 +218,17 @@ inline void SimpleBB< LatticeModel_T, FlagField_T, ParticleAccessor_T >::treatDi
       // as a consequence, some (non-zero) PDF contributions would be missing after summing up the force contributions
       // those would need to be added artificially, see e.g. Ernst, Dietzel, Sommerfeld - A lattice Boltzmann method for simulating transport and agglomeration of resolved particles, Acta Mech, 2013
       // instead, we use the trick there that we just require the deviations from the equilibrium to get the correct force as it is already used for the incompressible case
-      pdf_old -= LatticeModel_T::w[ Stencil_T::idx[dir] ];
-      pdf_new -= LatticeModel_T::w[ Stencil_T::idx[dir] ];
+       if (hydrostaticDensityFct_ == nullptr)
+       {
+          pdf_old -= LatticeModel_T::w[Stencil_T::idx[dir]];
+          pdf_new -= LatticeModel_T::w[Stencil_T::idx[dir]];
+       }
+       else
+       {
+          const real_t rhoHydStat = hydrostaticDensityFct_(cellCenter);
+          pdf_old -= rhoHydStat * LatticeModel_T::w[Stencil_T::idx[dir]];
+          pdf_new -= rhoHydStat * LatticeModel_T::w[Stencil_T::idx[dir]];
+       }
    }
 
    // MEM: F = pdf_old + pdf_new - common