Skip to content

Creating a Computational Fluid Dynamics (CFD) problem

Matthew Hockley edited this page Nov 29, 2020 · 14 revisions

Many Computational Fluid Dynamics (CFD) problems examples exist in the problems folder from droplet generation (Navier-Stokes (NS)/ Phase Field (PF)), electrochemical manipulation (NS/PF/Electrochemistry (EC)) (TBC) and microfluidic simulations (NS). When creating a new problem, it is recommended you adapt one of the pre-existing problems available.

A short list of problems are detailed here:

Setting up a CFD problem

All scripts must have import dolfin as df as this imports the FEniCS backend. The packages after are highly recommended but might differ between problems. Most scripts begin with importing numpy, various MPI related parallelisation functions and pre-defined conditions such as "Pressure" and "NoSlip". This is page is aimed towards a Navier-Stokes problem, see Adding Phase Field and Adding Electrochemistry (TBC) for problem specific parameters.

import dolfin as df
import numpy as np
import os
from . import *
from common.io import mpi_is_root, load_mesh, mpi_barrier, mpi_comm, mpi_bcast, mpi_gather
from common.bcs import Fixed, Pressure, NoSlip

Problem Functions

All problems have functions to initialise or change the problem over time. Depending on the problem, functions may need to be changed. The most important function is problem() which defines parameters of the problem.

Function Description
problem() Creates dictionary of all problem parameters
mesh() Creates or loads mesh
initialize() Setting initial states/assumptions for EC/PF/NS
create_bcs() Creating boundary conditions for the problem
velocity_init() Defining velocity expression for the problem
FaceLength() Calculates length and minimum of a defined subdomain
tstep_hook() Ran at start of the problem
start_hook() Ran at start of the problem

problem()

problem() is the most important function of the simulation as it setups as it defines all parameters. Typically this is the only part of the code users will change and tailor to a desired problem.

A dictionary called parameters defined the CFD problem. The example is below from the problem Spiral2D.

    parameters = dict(
        solver="basic", # Type of problem solver
        folder="results_spiral2D", # Save folder
        import_mesh = True, # If importing XDMF mesh files
        scale_factor = scaling_factor, # Change mesh dimension (Use if mesh not in metres)
        mesh_file = "meshes/mesh_Spiral2D.xdmf", # Mesh filepath
        subdomains_file = "meshes/mf_Spiral2D.xdmf", # Subdomains filepath
        name_Facet = "inlet", # Name of inlet within "boundaries_Facet" for Hmin/H
        restart_folder=False, # Use if restarting from different folder
        enable_NS=True, # Enable Navier Stokes (NS)
        enable_PF=False, # Enable Phase Field (PF)
        enable_EC=False, # Enable Electrochem (EC)
        save_intv=5, # Export data time point interval
        stats_intv=5, # Export stats interval
        checkpoint_intv=50, # Export checkpoint for restart
        tstep=0, # Unsure
        dt=0.0015/factor, # s Time steps
        t_0=0., # s Start time
        T=8., # s Total time
        solutes=solutes, # I believe are electrochemistry (EC) related
        base_elements=base_elements, # Basic "CG"/"Lagrange" function space
        #
        H=0.41, # Length of inlet (Updated in "faceLength")
        Hmin=0, # Minimum of inlet (Updated in "faceLength")
        dim = 2, # Dimensions
        XInflow = True, # Direction of flow along X axis
        #
        # Simulation parameters
        grav_const=0.0, # 0 gravity as microfluidic
        inlet_velocity=-1.5, # m/s (Negative due to -x inflow direction)
        V_0=0., # Initial electric field state - EC related
        #
        # Fluid parameters (Water at 22C)
        density=[998.2, 998.2], # Kg/m3
        viscosity=[1.003e-3, 1.003e-3], # Pa.s dynamic viscosity
        permittivity=[1., 1.], # EC?
        #
        # Solver parameters
        use_iterative_solvers=True, # if False, might have memory issues
        use_pressure_stabilization=False, # Seems to be a type of SUPG, unsure (see solver)
        #
        # Boundary related physical labels (Numbers within mf_subdomains.xdmf)
        #   Typically created within GMSH/Netgen and converted by Meshio
        boundaries_Facet = {'inlet': 10,
                            'outletL': 6,
                            'outletR': 3,
                            'wall': [2,4,5,7,8,9],
                            }

Some parameters such as import_mesh and scale_factor are problem specific. In this example, they relate to the "XDMF" import. Additional parameters and or classes can be found in other examples for defining meshes and mesh boundaries to other problem specific parameters. It is best practise to adapt a pre-existing problem to avoid missing parameters important to enforce realistic physics. These parameters are explored further in mesh() for FEniCS or "XDMF" mesh import and problem specific values in Adding Phase Field and Adding Electrochemistry (TBC) problems.

mesh()

The mesh function has two versions, the FEniCS mesh generation using the Dolfin engine and the "XDMF" importing a mesh converted by Meshio. The difference is discussed in "Why use a CAD mesh?" and implementation explored below.

FEniCS mesh

FEniCS has some pre-built meshes which can be used to to create rectangles and circles as well as adjusting mesh density. A full list of available meshes can be found in the FEniCS demos. The example below was from "Flow around cylinder" problem which demonstrates how individual meshes can be removed to make more complex meshes. Note the parameters used by mesh() function differ between examples but all parameters used by mesh() must be defined in problem() dictionary.

Parameter Description
H Height of 2D rectangle
L Length of 2D rectangle
R Radius of 2D circle
X0 Position circle centre on X axis
Y0 Position circle centre on Y axis
res Resolution of mesh
def mesh(H, L, x0, y0, R, res, **namespace):
    # Create mesh
    channel = Rectangle(df.Point(0, 0), df.Point(L, H))
    cylinder = Circle(df.Point(x0, y0), R)
    domain = channel - cylinder
    mesh = generate_mesh(domain, res)
    return mesh

"XDMF" mesh

"XDMF" meshes have been converted from CAD designs using Meshio. More about Why use a CAD design and converting 2D meshes/3D meshes are available in the Wiki. The example below was from "Flow around cylinder" which imports a 2D mesh and uses custom functions to define inflow direction. Note the parameters used by mesh() function differ between examples but all parameters used by mesh() must be defined in problem() dictionary.

Parameter Description
mesh_file Filepath to mesh file
subdomains_file Filepath to subdomains file
XInflow Direction of inflow along X or Y axis
boundaries_Facet Dictionary of boundaries names associated to physical labels
name_Facet Name of the boundary to obtain minimum co-ordinates and length of boundary face
scale_factor Factor to convert mesh to SI units (mm to m)
import_mesh Variable to export boundary face details or mesh only
def mesh(mesh_file, subdomains_file, XInflow,
            boundaries_Facet, name_Facet, scale_factor,
            import_mesh, **namespace):

The mesh() function imports the "XDMF" mesh and uses scale_factor to adjust the mesh to SI units. Additionally, the mesh is moved to the origin along all axes to avoid issues with negative minimums used to calculate velocity_init().

    # Load mesh from file (NETGEN mesh as .grid to .xml using DOLFIN)
    
    mesh = df.Mesh()
    with df.XDMFFile(mpi_comm(),mesh_file) as infile:
        infile.read(mesh)

    # # Scale mesh from mm to m
    x = mesh.coordinates()
    x[:, :] *= scale_factor
    # # Move mesh so co-ords always positive
    #
    xymin = x.min(axis=0) 
    mpi_barrier()
    xymin = np.min(mpi_gather(xymin, 0))
    mpi_barrier()
    xymin = mpi_bcast(xymin, 0)
    mpi_barrier()

    x[:, :] = x[:, :] - xymin
    # Apply to mesh
    mesh.bounding_box_tree().build(mesh) 

    # Define boundary conditions
    dim = mesh.topology().dim()
    if mpi_is_root():   
        print('Dim:',dim)

    # Ensure all processes have completed
    mpi_barrier()

The mesh() import has two exports using the variable import_mesh. If import_mesh is true, only the "mesh" is returned and if false, "mesh" and "parameters" important for assigning H and Hmin. The parameters for H and Hmin are obtained using the FaceLength() function which exports both X and Y length and minimum of a defined boundary face.

    if import_mesh: #Mesh import only if true
        return mesh
    else: #Otherwise generating length and min of boundary facet assuming line

        # Retrieve length and min of boundary facet (inlet in most cases)
        [X, Y, Xmin, Ymin] = FaceLength(boundaries_Facet[name_Facet], mesh,
            subdomains_file, dim)

        # Save length/min to dictionary
        #   This will not overwrite prior dictionary
        #    as this is in an independent function
        parameters = dict()
        parameters["dim"] = dim
        if XInflow == True:
            parameters["H"] = Y
            parameters["Hmin"] = Ymin
        else:
            parameters["H"] = X
            parameters["Hmin"] = Xmin

        # Ensure all processes have completed (Might be redundant)
        mpi_barrier()

        return mesh, parameters

initialize()

The initialize() function allows for assignment of a pre-defined assumption for the problem. A common example is an initial state velocity for the system which are initialised at time step 0. This function can be used to assign initial assumptions for phase field and electrochemistry problems. Note that only specific initial states that are nonzero need to be stated and some meshes complicated struggle with the pre-definition. Below are the parameters from "Flow around cylinder" which focuses on Navier-Stokes flow where it is encouraged to view the example problems before implementing a new problem.

Parameter Description
H Length of boundary face
Hmin Minimum co-ordinate of face
solutes Unsure
subdomains_file XDMF mesh containing subdomains
restart_folder If user is continuing a problem from a previous simulation
field_to_subspace Where the assumption is assigned to before entering a dictionary
inlet_velocity Speed of inflow in metres per second
enable_NS Enable Navier-Stokes study
enable_PF Enable Phase Field study
enable_EC Enable Electrochemistry study

"Flow around cylinder" is an example of a complex mesh which does not work well with the pre-defined assumption. The assumption fails due to the spiral nature where flow reaches unrealistic flow rates exceeding the boundary face due to the velocity expression defined in velocity_init(). The assumption can be changed but the simulation worked adequately without.

    w_init_field = dict()
     if not restart_folder:
         if enable_NS:
             try:
                 subspace = field_to_subspace["u"].collapse()
             except:
                 subspace = field_to_subspace["u"]
             u_init = velocity_init(H, inlet_velocity, 0, 1, Hmin)
             w_init_field["u"] = df.interpolate(u_init, subspace)

create_bcs()

Creating boundaries is important in Navier-Stokes problems to define no slip boundaries, pressure at inlets or outlets as well as flow velocity at the inlet. For phase Field problems, the main boundary condition is the interface_thickness. For electrochemistry, the solutes are important in defining the diffusion and convection profiles under an electric field.

Parameter Description
dim Length of boundary face
H Length of boundary face
Hmin Minimum co-ordinate of face.
inlet_velocity Speed of inflow in metres per second
V_0 Unsure
solutes Unsure
subdomains_file XDMF mesh containing subdomains
enable_NS Enable Navier-Stokes study
enable_PF Enable Phase Field study
enable_EC Enable Electrochemistry study
mesh XDMF mesh file
boundaries_Facet Dictionary of boundaries names associated to physical labels

The code first imports all boundary faces to create boundary objects. This converts the boundaries to the same format as the BERANISE FEniCS-derived meshes where the boundaries can be treated the same. Additionally this allows for legacy, non-XDMF meshes to run without major changes to the source code.

    """ The boundaries and boundary conditions are defined here. """
    mvc = df.MeshValueCollection("size_t", mesh, dim-1) 
    with df.XDMFFile(subdomains_file) as infile:
        infile.read(mvc, "name_to_read")
    facet_domains = df.cpp.mesh.MeshFunctionSizet(mesh, mvc)

    # Re-create boundaries with facet_domain for mesh relevance

    boundaries = dict(
        inlet = [facet_domains, boundaries_Facet["inlet"]],
        outletL = [facet_domains, boundaries_Facet["outletL"]],
        outletR = [facet_domains, boundaries_Facet["outletR"]],
        wall = [facet_domains, boundaries_Facet["wall"]],
    )

     # Alocating the boundary dicts
    bcs = dict()
    bcs_pointwise = dict()
    for boundary in boundaries:
        bcs[boundary] = dict()

The next part assigns the body with an expression depending on the problem. The example below explores Navier Stokes flow assigning walls as no slip, outlets as pressure 0 and inlets with a parabolic flow equation defined in velocity_init().

    ## Velocity Phase Field In (Retrieve expression)
    #
    #length inlet, water inflow, X/Y, Positive/neg flow along axis
    velocity_expr = velocity_init(H, inlet_velocity, 0, 1, Hmin) 
    velocity_in = Fixed(velocity_expr)

    # Pressure set to 0 at outlet
    pressure_out = Pressure(0.0)
    # Create NoSlip function for walls
    noslip = NoSlip() # Fixed((0., 0.)) no difference using either.

    ## Define boundaries
    #       Note we have two outlets
    if enable_NS:
        bcs["inlet"]["u"] = velocity_in # Velocity expression for inflow
        bcs["outletL"]["p"] = pressure_out # 0 pressure expression for outflow
        bcs["outletR"]["p"] = pressure_out # 0 pressure expression for outflow
        bcs["wall"]["u"] = noslip # No slip for walls

velocity_init()

This function define the velocity inflow. The parabolic inflow is used to model realistic inflow with high velocity at the centre and dropping off to the boundaries of the intlet. More information is available on the FEniCS project tutorial.

Parameter Description
H Length of boundary face
inlet_velocity Speed of inflow in metres per second
XY If flow is along the X axis or Y axis
Pos Positive or negative along the axis
XYmin Minimum co-ordinate of face on opposite XY axis. Also known as Hmin.

The code is relatively simple where the direction of flow, XY, defines the axis which flow enters the mesh on the face. Each co-ordinate along the boundary face is given an initial velocity speed with a 4 times drop-off from the centre reaching 0 m/s at the boundaries of the channel.

    if XY == 0:
        return df.Expression(
            ("Pos*4*U*(x[1] - xyMin)*(H-(x[1] - xyMin))/pow(H, 2)", "0.0"),
            Pos=Pos, H=H, U=inlet_velocity, xyMin = xyMin, degree=degree)
    else: # if XY == 1
        return df.Expression(
            ("0.0", "Pos*4*U*(x[0] - xyMin)*(H-(x[0] - xyMin))/pow(H, 2)"),
            Pos=Pos, H=H, U=inlet_velocity, xyMin = xyMin, degree=degree)

To achieve constant flow across an inlet, the expression needs to have a constant inlet velocity, U, set for either the X or Y axis. An example is below for the X axis.

return df.Expression(("U","0.0"), U=inlet_velocity, degree=degree)

FaceLength()

FaceLength() is a function to obtain the length of and the minimum co-ordinates of a boundary face. This is important when importing a mesh from a CAD and having the script automatically import the dimensions without specifying. Additionally the length and minimum of a face are important to calculate 2D or 3D parabolic flows for modelling realistic inflow (see velocity_init()). This compares to FEniCS mesh (see mesh() function) where the mesh is created and boundaries labelled using a class function (see flow_around_cylinder.py).

Parameter Description
faceNum Physical label from subdomain file (See 2D meshes/3D meshes
mesh Imported mesh from XDMF using mesh()
subdomains_file XDMF mesh containing subdomains
dim Dimensions of the mesh

The subdomains file is imported in a similar method in mesh() where the boundary faces are extracted into a facet_domains.

    # Import subdomains
    mvc = df.MeshValueCollection("size_t", mesh, dim-1)
    with df.XDMFFile(mpi_comm(), subdomains_file) as infile:
        infile.read(mvc, "name_to_read")
    facet_domains = df.cpp.mesh.MeshFunctionSizet(mesh, mvc)

The desired boundary face co-ordinates are extracted from all face boundaries imported. Each axis undergoes a length and minimum measure. The "MPI" nomenclature relates to parallelisation of the process but otherwise has no effect on the code.

    for facet_domains in It_facet:
        for v in df.vertices(facet_domains):
            X.append(v.point().x())
            Y.append(v.point().y())

    # Ensure all processes collect co-ords for desired face
    mpi_barrier()

    # Gather all co-ords to calc length/min
    X = mpi_gather(X, 0)
    Y = mpi_gather(Y, 0)

    # Sync all parallel processes for length/min calc
    mpi_barrier()
    
    if mpi_is_root():
        # Remove empty and combine all arrays
        X = np.concatenate(X)
        Y = np.concatenate(Y)
        # Calculate length and min values
        xInflowRange = np.ptp(X,axis=0)
        yInflowRange = np.ptp(Y,axis=0)
        xInflowMin = np.amin(X)
        yInflowMin = np.amin(Y)

    # END: Sync all parallel processes for length/min calc
    mpi_barrier()

    # Broadcast all length/min calc to all nodes used
    xInflowRange = mpi_bcast(xInflowRange, 0)
    yInflowRange = mpi_bcast(yInflowRange, 0)
    xInflowMin = mpi_bcast(xInflowMin, 0)
    yInflowMin = mpi_bcast(yInflowMin, 0)

tstep_hook()

Ran every step of the problem to provide feedback to the user such as current time step as well as allows the user to define time-dependent problems such as changes in pressure over time.

Parameter Description
t Step of current simulation (n of 200)
tstep Time step of current simulation (2 milliseconds)
stats_intv Statistics of the intervals
statsfile Filepath where 'stats_intv' is written to
field_to_subspace Dictionary containing subspaces for subproblems
field_to_problem Dictionary containing subproblems
subproblems Dictionary containing subproblems
w_ Current function space in FEniCS for problem

Example code below reports the time step every step of the simulation in blue text.

info_blue("Timestep = {}".format(tstep))

start_hook()

Due to the other pre-determined assumptions created in initialize() and the parameters set in the problem() dictionary, 'start_hook()' is primarily used to write all statistics to file before the start of the simulation. This is the last function before the problem begins to be solved.

Parameter Description
newfolder Filepath of the new folder where results are saved

statsfile = os.path.join(newfolder, "Statistics/stats.dat")