-
Notifications
You must be signed in to change notification settings - Fork 5
Creating a Computational Fluid Dynamics (CFD) problem
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:
- Spiral 2D microfluidic simulation (NS)
- Spiral 3D microfluidic simulation (NS)
- Microfluidic flow around cylinder 2D (NS/PF)
- Flow around cylinder 2D (NS/PF)
- Flow focusing 2D microdroplet formation (NS/PF)
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
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()
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.
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
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)
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
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()
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)
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))
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")