Blog : Simulating Von Karman vortex street

Motivation

Once upon a time, I simulated the von Karman vortex street; the mesh was generated with Gmsh, I played with different discretization schemes and residual controls, and plotted VTK post-processing output with Matplotlib. This time I have decided to create a benchmark case, which uses all new convenience features introduced in OpenFOAM since then.

Geometry & mesh

Geometry is a simple 2D straight rectangular channel 16D × 8D, where D is our cylinder diameter (see figure below). The cylinder is located 4D downstream of the inlet. This corresponds to the geometry in the article (https://onlinelibrary.wiley.com/doi/abs/10.1002/fld.1683), where we can also find the values of force coefficients. Though the purpose of these simulations is to show the capabilities of OpenFOAM dictionaries, having reference values is a nice addition.

Channel geometry

Meshing a cylinder in a channel

To mesh a geometry with the blockMesh utility, we first need to split it into hexahedral blocks. Then we put vertices of the blocks into the vertices list, put blocks in the blocks list, add curved edges description in the edges list, and finally put the mesh boundary patches in the boundary list.

Function entries in OpenFOAM dictionaries

Dictionaries in OpenFOAM use C-like syntax; as a consequence, there are function entries, which start with # to imitate the C preprocessor. The first function entry, which we will use in our case, is include, which simply includes the content of another dictionary in the place of the invocation. To simplify case management, we move all user-modifiable variables to a settings dictionary in the root folder of the case, which is then included with the #include "<case>/settings" entry.

In addition to the variable storage, we need to perform certain calculations (mesh density, number of cells in the graded zones, etc.). Earlier, OpenFOAM provided a calc entry for the purpose. It creates a C++ file with the expression, compiles it, and inserts the output of the code invocation in the dictionary. In modern versions, an eval entry evaluates expressions directly without compiling an external dynamic library.

And finally, there are conditional entries: if and ifeq. The first checks if the expression evaluates to true (in terms of OpenFOAM, so the expression can be yes or on). The second compares two expressions. In both cases, if the evaluation is true, the part after if is inserted in the dictionary; otherwise, the optional else part is used.

Settings dictionary

In the settings dictionary, we keep cylinder diameter, mesh cell sizes, and flow conditions. The first defines the geometry of the channel, the second defines the density of the mesh, and the last defines properties of the fluid. Since vortex shedding depends on the Reynolds number, we calculate fluid viscosity from cylinder diameter, the inlet velocity, and the Reynolds number.

We use a 2D mesh in the simulation, so we have a vertical size of the mesh (dz) and two average cell sizes: in the free flow zone (dx) and near the cylinder (dx_fine). To reduce the number of cells, we can use the mesh grading (grading).

blockMeshDict dictionary

Before writing the dictionary, we need to split our geometry into hexahedral blocks and define skeleton vertices, which are then used to construct the mesh.

blockMesh geometry split

The mesh blocks scheme is shown in figure above. To have uniform normal cell size at the cylinder patch, we introduce additional vertices around it, so, in our mesh, we have 20 blocks: 4 near the inlet, 4 near the outlet, and 12 in the cylinder area.

The blockMeshDict starts with the inclusion of the settings dictionary, and then we calculate convenience variables using the eval function entry.

#include "<case>/settings"

R #eval "0.5*$D";

a #eval "$R/sqrt(2)";
b #eval "$D/sqrt(2)";
c #eval "4*$D";
d #eval "12*$D";


z0 -$dz;
z1  $dz;

Then mesh density and grading factors are calculated. Here, we use conditional entry #if and an analytical solution for the number of cells in the graded area. Since we have different grading directions in different areas, we calculate two grading factors, one for the mesh refinement and another for the mesh coarsening.

#if $grading

q #eval "$dx_fine/$dx";
p #eval "$dx/$dx_fine";

Ng #eval "ceil(log($q)/log(($b - $dx)/($b - $dx_fine)))";

#else

q 1;
p 1;

Ng #eval "ceil($b/$dx)";

#endif

Then we define the number of cells in different parts of the mesh; since we have 4 subdivisions in the X and Y directions, there are 4 values defined.

Nx1 #eval "ceil(($c - $b)/$dx)";
Nx2 $Ng;
Nx3 $Ng;
Nx4 #eval "ceil(($d - $b)/$dx)";


Ny1 #eval "ceil(($c - $b)/$dx)";
Ny2 $Ng;
Ny3 $Ng;
Ny4 #eval "ceil(($c - $b)/$dx)";

We need curved edges in our mesh: the edges of the cylinder and the edges of the blocks around the cylinder. In earlier OpenFOAM versions, defining edges required two endpoints and a third midpoint along the arc (defining the arc by three points). Now we can project a straight edge on the curved geometry. For this we first need to define the geometry, which is done in the geometry dictionary. There are two cylinders defined, the first one with the real cylinder geometry and the second, which is twice as large, for the “uniformization” blocks.

geometry
{
    cylinder1
    {
        type cylinder;
        point1 (0 0 -100);
        point2 (0 0 100);
        radius $R;
    }

    cylinder2
    {
        type cylinder;
        point1 (0 0 -100);
        point2 (0 0 100);
        radius $D;
    }
}

For the vertices definition, we use named vertices. In the earlier OpenFOAM versions, it was necessary to use vertex index in the block definition (starting with 0). This manual indexing approach is tedious and error-prone. So, for the vertices in this simulation, we use named vertices. And to simplify the mesh blocks description, we use mnemonics for the vertex names:

v<shift in the X direction><shift in the Y direction><shift in the Z direction>

Shift numbers correspond to the values on the scheme above. Utilization of the named vertices allows us to avoid tracking vertex index, and the shifts convention simplifies the construction of the blocks.

vertices
(
    name v000 (-$c -$c $z0)
    name v100 (-$b -$c $z0)
    name v300 (  0 -$c $z0)
    ...
);

Block description uses named vertices and precalculated variables (block densities, grading factors).

blocks
(
    hex (v000 v100 v110 v010 v001 v101 v111 v011) ($Nx1 $Ny1 1) simpleGrading ( 1  1 1)
    hex (v100 v300 v310 v110 v101 v301 v311 v111) ($Nx2 $Ny1 1) simpleGrading ($q  1 1)
    hex (v300 v500 v510 v310 v301 v501 v511 v311) ($Nx3 $Ny1 1) simpleGrading ($p  1 1)
    hex (v500 v600 v610 v510 v501 v601 v611 v511) ($Nx4 $Ny1 1) simpleGrading ( 1  1 1)
    ...
);

Curved edges of the mesh are described through the projection of the straight edges on the defined geometry.

edges
(
    project v110 v310 (cylinder2)
    project v310 v510 (cylinder2)
    project v510 v530 (cylinder2)
    project v530 v550 (cylinder2)
    project v550 v350 (cylinder2)
    project v350 v150 (cylinder2)
    project v130 v110 (cylinder2)
    project v130 v150 (cylinder2)
    ...
);

After defining blocks and edges, we can proceed with the boundary patches. blockMesh has a special patch named defaultFaces, where all non-attributed faces are added. We can configure the behavior of the patch through the defaultFaces dictionary.

defaultPatch
{
    name cylinder;
    type wall;
}

The rest of the patches are inlet, outlet, walls, and frontAndBack patch of type empty, which is standard for 2D simulation.

inlet
{
    type patch;
    faces
    (
        (v000 v010 v011 v001)
        (v010 v030 v031 v011)
        (v030 v050 v051 v031)
        (v050 v060 v061 v051)
    );
}

So, to generate the mesh, we set the cylinder diameter and desired cell size and run blockMesh (see figure below for the example of the generated mesh).

Mesh with grading (5k cells)

The resulting mesh has fine resolution in the area of interest and has decent non-orthogonality (around 40 degrees).

Checking geometry...
    Overall domain bounding box (-4 -4 -0.01) (12 4 0.01)
    Mesh has 2 geometric (non-empty/wedge) directions (1 1 0)
    Mesh has 2 solution (non-empty) directions (1 1 0)
    All edges aligned with or perpendicular to non-empty directions.
    Boundary openness (-6.7961e-19 3.04482e-18 -1.66941e-13) OK.
    Max cell openness = 4.34617e-16 OK.
    Max aspect ratio = 9.60439 OK.
    Minimum face area = 2.52457e-06. Maximum face area = 0.000632222.  Face area magnitudes OK.
    Min volume = 5.04914e-08. Max volume = 1.26444e-05.  Total volume = 2.54429.  Cell volumes OK.
    Mesh non-orthogonality Max: 44.1274 average: 7.02609
    Non-orthogonality check OK.
    Face pyramids OK.
    Max skewness = 0.433471 OK.
    Coupled point location match (average 0) OK.

Schemes, linear solvers, convergence, and time step controls

Discretization schemes are configured in the fvSchemes file in the system sub-folder of the case. Because the mesh inhibits non-orthogonality, we use the leastSquares gradient reconstruction scheme. The second-order upwind scheme is used for the convection term, and linear corrected schemes are used for the Laplacian and interpolation.

gradSchemes
{
    default         leastSquares;
}


divSchemes
{
    default         none;
    div(phi,U)      Gauss linearUpwind grad(U);

    div((nuEff*dev2(T(grad(U))))) Gauss linear;
}

For the pimpleFoam solver, we need two sets of linear solvers: one for the outer iterations before convergence and the other for the final iteration, which is performed after the convergence is reached.

For the pressure, we use the Geometric Algebraic Multi-Grid (GAMG) solver. Linear solver convergence is controlled by two tolerances: relative and absolute. The first is a measure of the residual reduction; in our case, if the initial residual of the pressure equation is reduced 10 times, we assume that the solver is converged. For the final iteration, we switch to the Preconditioned Conjugate Gradient (PCG) solver with GAMG as a preconditioner. This time we set relative tolerance to 0, so we use absolute tolerance as a convergence criterion for the linear system solver. GAMG uses mesh coarsening to solve a smaller linear system and then propagates the solution on the dense mesh. Since our mesh is static, we can cache agglomeration using cacheAgglomeration flag.

p
{
    solver          GAMG;
    smoother        GaussSeidel;
    tolerance       1E-8;
    relTol          0.1;
    cacheAgglomeration true;
}


pFinal
{
    solver          PCG;
    preconditioner
    {
        preconditioner  GAMG;
        smoother        GaussSeidel;
        tolerance       1E-8;
        relTol          1E-2;
        cacheAgglomeration true;
    }
    tolerance       1E-8;
    relTol          0;
}

Usage of the relative tolerance during initial convergence could accelerate the solution. As we do pressure-velocity coupling, we do not use small tolerances either for the pressure or for the velocity field, since both fields are just an intermediate solution. As we have converged to a solution, we use absolute tolerance to ensure a well-converged solution.

To control the solution convergence residualControl sub-dictionary of the PIMPLE dictionary is used. There we provide field names, relative tolerance, and absolute tolerance.

residualControl
{
    "(U|p)"
    {
        relTol 0;
        tolerance 1e-6;
    }
}

On each outer iteration, the solver checks the initial residual of the linear system for each configured field (in our case, we used a regular expression to match velocity and pressure fields, but they can be configured in separate sub-dictionaries). If the initial residual of the system goes below absolute tolerance or the ratio between current and initial residuals goes below relative tolerance, we assume that the solution has converged.

Simulation stability depends on the time step value. One way to keep the simulation stable is to use a constant small time step. But it is painfully slow, so it is much more convenient to have the time step adapted to the flow field. For the purpose pimpleFoam has the following settings in the controlDict file:

adjustTimeStep yes;

maxCo 0.5;

A rather conservative value of 0.5 for the maximum Courant number is chosen since we use second-order discretization schemes; with the upwind we can use larger value.

Run-time post-processing

We need not only to calculate the flow field but also to extract certain data from the simulation. This is achieved through the run-time post-processing with the function objects. They are configured in the functions sub-dictionary in the controlDict file. To simplify management of the function objects, each description is moved to a separate file; all of them are then included in the corresponding sub-dictionary.

functions
{
    #include "<case>/system/forceCoeffs"
    #include "<case>/system/surfaces"
    #include "<case>/system/timeInfo"
}

Force coefficients

forces
{
    type            forceCoeffs;
    libs            (forces);
    writeControl    adjustableRunTime;
    writeInterval   0.1;

    patches         (cylinder);
    rho             rhoInf;
    log             true;
    rhoInf          1;
    liftDir         (0 1 0);
    dragDir         (1 0 0);
    CofR            (0 0 0);
    pitchAxis       (0 0 1);
    magUInf         $Uin;
    lRef            $D;
    Aref            #eval "2*$dz*$D";
}

To compare the results of the simulations with the experimental data, we need to extract drag and lift forces acting on the cylinder. For this purpose, we use the forceCoeffs function object. Configuration specific to the function object starts from the patches keyword. We configure the patches, which are affected by the forces (in this case, it is a cylinder boundary patch). We set the density of the fluid with the rho keyword. As our simulation is incompressible, we use constant rhoInf as the density. For the calculation of the forces, we need to define drag and lift directions, center of rotation, pitch axis, freestream velocity, reference length scale, and reference area. In our case, drag direction is X, lift direction is Y, and the center of rotation is the center of the cylinder, pitch axis direction is Z, and freestream velocity is the inlet velocity. The reference length scale is the cylinder diameter, and the reference area is the reference length scale multiplied by the thickness of the mesh in the Z direction.

The function object writes in the file postProcessing/forces/0/coefficients.dat values of the coefficients (drag (Cd), lift (Cl), side-force (Cs), and roll (CmRoll), pitch (CmPitch), and yaw (CmYaw) moments). The function object also outputs the coefficients in terms of their front and rear axle constituents ((f) and (r) suffixes).

Sampling

velocity
{
    type            surfaces;
    libs            (sampling);
    writeControl    writeTime;
    surfaceFormat   raw;

    fields
    (
        U
    );

    interpolationScheme cellPoint;

    surfaces
    {
        centerPlane
        {
            type        cuttingPlane;
            point       (0 0 0);
            normal      (0 0 1);
            interpolate true;
            triangulate true;
        }
    }
}

We also extract velocity values along the center plane of the channel. The cuttingPlane function object is used for the purpose. The function object can output the sampled data in different formats; for plotting, we have chosen VTK as the cylinder is a hole in the data, and it needs to be correctly triangulated.

Solver performance information

timeInfo
{
    type            timeInfo;
    libs            (utilityFunctionObjects);
    perTimeStep     yes;
    writeControl    timeStep;
    writeInterval   1;
}

To have a bit of data on the solver performance, we use the timeInfo function object. For each time step, it outputs the current time, total CPU time used, total wall clock time used, CPU time per time step, and wall clock time per time step. Clock time has a resolution of 1 second, so it is not quite useful.

Results

To add a benchmark nature to the simulations, we have decided to compare different OpenFOAM version outputs. There are v2306, v2312, v2412, and v2606 versions installed on the cluster, so the simulations were executed with all these versions and with different mesh sizes: 5k, 30k, and 300k cells.

Dependence of the output on the mesh density is compared for version v2606. Performance of the solver is compared for different versions of OpenFOAM.

Drag and lift coefficient

Good news: all tested OpenFOAM versions calculate the same value for the drag and lift coefficients:

Drag coefficient evolution

Drag coefficient evolution (zoom)

Lift coefficient evolution

The difference in the drag and lift coefficient values from the cited article is mainly caused by the different boundary conditions on the channel walls. If we make channel walls symmetry planes instead (effectively simulating an array of cylinders, like in the article), values of Cd and Cl become closer to the published ones.

Drag coefficient evolution

Lift coefficient evolution

Solver performance

To compare the solver performance difference between versions, the solver was run from a point of 150 s to 160 s with a constant time step (0.002 s) on a 30k-cell mesh.

CPU time for the version v2606 was chosen as a base, and the difference in CPU time was plotted. And it turned out that version v2606 is the slowest. The fastest is v2306, which was 14 seconds faster than v2606. The whole simulation took 1157 seconds of CPU time, so v2306 is 1% faster. This can be attributed to the measurement technique and may be ignored.

CPU time difference with v2606

Case files

The case files can be found in the show cases repository:

https://code.o2m.solutions/o2m/show-cases/src/branch/trunk/von-karman-vortex-street