Computational Fluid Dynamics using Python: Modeling Laminar Flow | by Gaurav Deshmukh | Towards Data Science

This is the first article in a series on Fluid Mechanics of a Crystallizer. The series will cover modeling of fluid dynamics, heat transfer, mass transfer and crystallization in a chemical reactor. To simplify the tutorial, the model building will be performed in stages, starting with the creation of a laminar fluid flow solver (article 1), addition of heat and mass transfer components (article 2) and finally, solving a population balance model to model crystallization (article 3).
This code has been written with the help of two incredibly informative references — “12 Steps to Navier Stokes” by Prof. Lorena Barba and “A guide to writing your first CFD solver” by Prof. Mark Owkes. If you are already familiar with the theory and mathematics behind fluid mechanics and want to go through the code, you can skip to section 5 of this article.
Fluid flow can be observed in many natural phenomena, from a soothing waterfall to an exasperating coffee splash on your computer keyboard. Your first thought after looking at the coffee on your ruined keyboard may not be “How interesting!” but on the other hand, the serene yet dynamic nature of the flow of water from a cliff can certainly evoke a sense of wonder. Can we understand anything about the fluid flow in these cases? Can we predict how a fluid would move under certain conditions? And more importantly, can we prevent future coffee splashes?
One way to go about answering these questions involves performing experiments in the lab with actual fluids and studying properties of their flow using various imaging instruments. This is the experimental approach. Another method involves writing a set of equations that can describe fluid flow, applying a set of simplifying assumptions and conditions, performing some mathematical magic, and deriving a governing equation which — after you plug in it the right values — can provide you with the capability to predict the dynamics of the flow. This is the analytical approach.
However, with the advent of increased computing power, there emerged a third approach to answer these questions — the numerical approach. While the set of equations that describe fluid flow are not analytically solvable (yet) for any arbitrary set of conditions, their outputs can certainly be computed if you have a powerful enough computer. Studying the dynamics of fluids flows on a computer using such an approach is commonly termed as Computational Fluid Dynamics (CFD).
So, what is this set of equations that can completely describe how a fluid flows and where do they come from? Before answering the former question, let’s discuss the latter.
Consider a 2D box having a fixed volume in space. This is what we term the control volume.
First, we will apply the principle of conservation of mass to the fluid in the control volume. For an incompressible fluid (most liquids), this means that whatever fluid enters the box must exit it. This is referred to as the equation of continuity in fluid mechanics.
Second, we will apply the principle of conservation of momentum to the control volume. This is slightly more abstract and complex compared to the previous case but eventually, this reduces to the incompressible Navier-Stokes equations.
If we can solve these partial differential equations (PDEs) simultaneously after applying requisite boundary conditions, we will obtain the instantaneous velocities and pressure as a function of time, allowing us to predict how the fluid will flow. However, there is no analytical method to solve these equations (in their complete forms) without applying simplifying assumptions. Therefore, we resort to numerical techniques for solving these equations.
There exist a variety of different numerical methods for solving PDEs, each with its own set of caveats. The simplest method is the Finite Difference method wherein a low-order Taylor series approximation is used to convert the PDEs to a set of algebraic equations. An example is given below that shows how to convert first and second order derivatives to their finite difference approximations.
While this is not the best method to model fluid flow in all cases, we will proceed with it since it simplifies other aspects of modeling a crystallizer, which is the ultimate objective of this series of articles. For more rigorous numerical treatments, you may want to use the the Finite Volume or Finite Element methods.
The code is organized into three different files or scripts. The first — “FlowPy.py” — contains the code for the solution of the PDEs using the finite difference method for a general set of inputs. The inputs are provided to this script using the “FlowPy_Input.py” script which acts as a user interface. Finally, the “FlowPy_Visualizer.py” script is used to animate the dynamics of the flow after running the simulation.
An advantage of writing the code in Python is that we can make use of Object Oriented Programming (OOP) to organize and simplify the code. This will also make adding the heat and mass transfer extensions to this quite straightforward. Therefore, the code is organized into various classes and functions that operate on objects of these classes. Save the code in section 5 to a file named FlowPy.py
The following modules are required — numpy and os. That’s it!
import numpy as npimport os
We start by making classes for specific properties of the problem, starting with the boundary condition. The PDEs are solved by applying certain boundary conditions which indicate how the fluid will behave at the boundaries of the domain. For example, fluid flowing through a pipe will have a wall with zero fluid velocity and an entry as well as exit with some specified flow velocity.
Mathematically, boundary conditions can be expressed in two forms — Dirichlet and Neumann boundaries. The former specifies a value of the dependent variable at the boundary whereas the latter specifies a value for the derivative of the dependent variable at the boundary.
Therefore, we make a Boundary class that has two properties — type and value.
class Boundary:    def __init__(self,boundary_type,boundary_value):        self.DefineBoundary(boundary_type,boundary_value)            def DefineBoundary(self,boundary_type,boundary_value):        self.type=boundary_type        self.value=boundary_value
Next, the domain enclosed by the boundary (like the inside of a pipe) is represented using a 2D mesh or grid and the values of dependent variables are calculated at the center of boxes in the grid (for pressure) or at the faces of the boxes (for velocities). This is referred to as a staggered grid approach. To represent the mesh, we create a class called Space. The method CreateMesh creates a matrix of given size for the dependent variables and the SetDeltas method calculates the values of the differential lengths based on the specified length and breadth of the domain.
class Space:    def __init__(self):        pass        def CreateMesh(self,rowpts,colpts):        #Domain gridpoints        self.rowpts=rowpts        self.colpts=colpts        #Velocity matrices        self.u=np.zeros((self.rowpts+2,self.colpts+2))        self.v=np.zeros((self.rowpts+2,self.colpts+2))        self.u_star=np.zeros((self.rowpts+2,self.colpts+2))        self.v_star=np.zeros((self.rowpts+2,self.colpts+2))        self.u_next=np.zeros((self.rowpts+2,self.colpts+2))        self.v_next=np.zeros((self.rowpts+2,self.colpts+2))        self.u_c=np.zeros((self.rowpts,self.colpts))        self.v_c=np.zeros((self.rowpts,self.colpts)))        #Pressure matrices        self.p=np.zeros((self.rowpts+2,self.colpts+2))        self.p_c=np.zeros((self.rowpts,self.colpts))        #Set default source term        self.SetSourceTerm()                    def SetDeltas(self,breadth,length):        self.dx=length/(self.colpts-1)        self.dy=breadth/(self.rowpts-1)    def SetInitialU(self,U):        self.u=U*self.u            def SetInitialV(self,V):        self.v=V*self.v            def SetInitialP(self,P):        self.p=P*self.p    def SetSourceTerm(self,S_x=0,S_y=0):        self.S_x=S_x        self.S_y=S_y
Lastly, we create a class Fluid to represent the properties of the fluid — like density (rho) and viscosity (mu).
class Fluid:    def __init__(self,rho,mu):        self.SetFluidProperties(rho,mu)        def SetFluidProperties(self,rho,mu):        self.rho=rho        self.mu=mu
As in the previous section, we start by writing functions to implement boundary conditions for the horizontal velocity (u), vertical velocity (v) and pressure (p) at the left, right, top and bottom boundaries of the 2D domain. This function will accept the objects of the Space and Boundary classes and set boundary conditions according to the attributes of those objects. For example, if a Boundary object with type Dirichlet and value 0 is passed as the left boundary object, the function will set that condition on the left boundary.
#Note: The arguments to the function are all objects of our defined classes#Set boundary conditions for horizontal velocitydef SetUBoundary(space,left,right,top,bottom):    if(left.type=="D"):        space.u[:,0]=left.value    elif(left.type=="N"):        space.u[:,0]=-left.value*space.dx+space.u[:,1]        if(right.type=="D"):        space.u[:,-1]=right.value    elif(right.type=="N"):        space.u[:,-1]=right.value*space.dx+space.u[:,-2]            if(top.type=="D"):        space.u[-1,:]=2*top.value-space.u[-2,:]    elif(top.type=="N"):        space.u[-1,:]=-top.value*space.dy+space.u[-2,:]         if(bottom.type=="D"):        space.u[0,:]=2*bottom.value-space.u[1,:]    elif(bottom.type=="N"):        space.u[0,:]=bottom.value*space.dy+space.u[1,:]#Set boundary conditions for vertical velocitydef SetVBoundary(space,left,right,top,bottom):    if(left.type=="D"):        space.v[:,0]=2*left.value-space.v[:,1]    elif(left.type=="N"):        space.v[:,0]=-left.value*space.dx+space.v[:,1]        if(right.type=="D"):        space.v[:,-1]=2*right.value-space.v[:,-2]    elif(right.type=="N"):        space.v[:,-1]=right.value*space.dx+space.v[:,-2]            if(top.type=="D"):        space.v[-1,:]=top.value    elif(top.type=="N"):        space.v[-1,:]=-top.value*space.dy+space.v[-2,:]         if(bottom.type=="D"):        space.v[0,:]=bottom.value    elif(bottom.type=="N"):        space.v[0,:]=bottom.value*space.dy+space.v[1,:]#Set boundary conditions for pressuredef SetPBoundary(space,left,right,top,bottom):    if(left.type=="D"):        space.p[:,0]=left.value    elif(left.type=="N"):        space.p[:,0]=-left.value*space.dx+space.p[:,1]        if(right.type=="D"):        space.p[1,-1]=right.value    elif(right.type=="N"):        space.p[:,-1]=right.value*space.dx+space.p[:,-2]            if(top.type=="D"):        space.p[-1,:]=top.value    elif(top.type=="N"):        space.p[-1,:]=-top.value*space.dy+space.p[-2,:]         if(bottom.type=="D"):        space.p[0,:]=bottom.value    elif(bottom.type=="N"):        space.p[0,:]=bottom.value*space.dy+space.p[1,:]
Before we write the finite difference functions, we need to determine a time-step to advance the simulation by. To ensure the convergence of finite difference methods, an upper bound on the time-step is provided by the Courant–Friedrichs–Lewy (CFL) criterion which is set as the time-step for the simulation using the SetTimeStep function. Adhering to the CFL criterion ensures that information propagated in a time-step is not farther than the distance between two mesh elements.
def SetTimeStep(CFL,space,fluid):    with np.errstate(divide='ignore'):        dt=CFL/np.sum([np.amax(space.u)/space.dx,\                           np.amax(space.v)/space.dy])    #Escape condition if dt is infinity due to zero velocity initially    if np.isinf(dt):        dt=CFL*(space.dx+space.dy)    space.dt=dt
Having determined the time-step, we are now ready to implement the finite difference scheme. To solve the equation of continuity and the Navier-Stokes equations simultaneously, we use a predictor-corrector scheme involving the following steps (for more information refer to this guide):
Calculate starred velocities (u* and v*) from initial velocities without the effect of pressure.
Iteratively solve the pressure Poisson equation using the starred velocities.
Calculate the velocities for the next time-step from the pressure and starred velocities.
We define three different functions to carry out each of these three steps.
Further, a convenience function is defined to save the velocities and pressures inside the boundaries to new variables, which can then be written to text files.
def SetCentrePUV(space):    space.p_c=space.p[1:-1,1:-1]    space.u_c=space.u[1:-1,1:-1]    space.v_c=space.v[1:-1,1:-1]
Finally, we define two functions for I/O purposes — MakeResultDirectory to make a directory called “Result” to store the text files and WriteToFile to save the values of the variables to a text file every few iterations (specified using the interval argument).
def MakeResultDirectory(wipe=False):    #Get path to the Result directory    cwdir=os.getcwd()    dir_path=os.path.join(cwdir,"Result")    #If directory does not exist, make it    if not os.path.isdir(dir_path):        os.makedirs(dir_path,exist_ok=True)    else:        #If wipe is True, remove files present in the directory        if wipe:            os.chdir(dir_path)            filelist=os.listdir()            for file in filelist:                os.remove(file)        os.chdir(cwdir)                def WriteToFile(space,iteration,interval):    if(iteration%interval==0):        dir_path=os.path.join(os.getcwd(),"Result")        filename="PUV{0}.txt".format(iteration)        path=os.path.join(dir_path,filename)        with open(path,"w") as f:            for i in range(space.rowpts):                for j in range(space.colpts):                    f.write("{}\t{}\t{}\n".format(space.p_c[i,j],space.u_c[i,j],space.v_c[i,j]))
That brings to an end the section on the simulation code. Next, we need to write a user interface — that is, a script where the user can provide various inputs like boundary conditions, initial conditions and fluid properties. This script will also call the functions defined in the FlowPy.py file and run the simulation.
This section is shorter than the previous one — most of the heavy lifting has been done, we just need to make use of all the defined classes and functions to run the simulation now! Save the code in section 6 to a file named FlowPy_Input.py
First, we import required modules and this now includes all the things that we have defined in FlowPy.py
import sysimport numpy as npimport matplotlib.pyplot as pltimport matplotlib.cm as cmfrom FlowPy import *
As an example, inputs relevant to the Lid Cavity Test (at Reynolds Number=400) are entered in this tutorial. In this test, fluid is kept in a 2D box with three rigid walls and the fourth wall (or the lid) is moved at a uniform velocity. Once steady state is reached, statistics of the developed flow field can be compared to a benchmark.
We begin by specifying input variables describing the domain and then creating a Space object with these variables.
#### SPATIAL AND TEMPORAL INPUTSlength=4 #Length of computational domain in the x-directionbreadth=4 #Breadth of computational domain in the y-directioncolpts=257 #Number of grid points in the x-direction #KEEP ODDrowpts=257 #Number of grid points in the y-direction #KEEP ODD#Create an object of the class Space called cavitycavity=Space()cavity.CreateMesh(rowpts,colpts)cavity.SetDeltas(breadth,length)
Next, the density and viscosity of the fluid are specified, and an object of the class Fluid is created.
#### FLUID PROPERTIESrho=1 #Density of fluidmu=0.01 #Viscosity of fluid#Create an object of the class Fluid called waterwater=Fluid(rho,mu)
Third, we create Boundary objects to set velocity and pressure boundary conditions.
#### BOUNDARY SPECIFICATIONSu_in=1 #Lid velocityv_wall=0 #Velocity of fluid at the wallsp_out=0 #Gauge pressure at the boundaries#Create objects of the class Boundary having either Dirichlet ("D") or Neumann ("N") type boundariesflow=Boundary("D",u_in)noslip=Boundary("D",v_wall)zeroflux=Boundary("N",0)pressureatm=Boundary("D",p_out)
Finally, simulation parameters and flags are specified that control simulation time, saving text files, and so forth.
#### SIMULATION PARAMETERStime=150 #Simulation timeCFL_number=0.8 #Reduce this if solution divergesfile_flag=1 #Keep 1 to print results to fileinterval=100 #Record values in file per interval number of iterations
Now, we can write the loop to run the simulation. The general procedure is as follows. Until the simulation time is completed, do the following in every iteration:
Set the time-step according to the CFL number criterion
Set boundary conditions
Calculate starred velocities
Solve the pressure Poisson equation to get the pressure field
Determine velocities at the next time-step
Write results to file (if file flag is 1)
Advance time by a value equal to the time-step
#### RUN SIMULATION# Print general simulation informationprint("######## Beginning FlowPy Simulation ########")print("#############################################")print("# Simulation time: {0:.2f}".format(time))print("# Mesh: {0} x {1}".format(colpts,rowpts))print("# Re/u: {0:.2f}\tRe/v:{1:.2f}".format(rho*length/mu,rho*breadth/mu))print("# Save outputs to text file: {0}".format(bool(file_flag)))## Initialization# Make directory to store resultsMakeResultDirectory(wipe=True)# Initialize counterst=0i=0## Runwhile(t<time):    #Print time left    sys.stdout.write("\rSimulation time left: {0:.2f}".format(time-t))    sys.stdout.flush()    #Set the time-step    SetTimeStep(CFL_number,cavity,water)    timestep=cavity.dt        #Set boundary conditions    SetUBoundary(cavity,noslip,noslip,flow,noslip)    SetVBoundary(cavity,noslip,noslip,noslip,noslip)    SetPBoundary(cavity,zeroflux,zeroflux,pressureatm,zeroflux)        #Calculate starred velocities    GetStarredVelocities(cavity,water)        #Solve the pressure Poisson equation    SolvePressurePoisson(cavity,water,zeroflux,zeroflux,\pressureatm,zeroflux)    #Solve the momentum equation    SolveMomentumEquation(cavity,water)    #Save variables and write to file    SetCentrePUV(cavity)    if(file_flag==1):        WriteToFile(cavity,i,interval)    #Advance time-step and counter    t+=timestep    i+=1
Having reached here, we are now ready to run the simulation for any generalized set of inputs. There’s just one piece of the puzzle left — a visualization tool.
The text files that are generated after running the simulation contain raw numbers that may not provide a physical picture of the fluid flow by themselves. However, a simple, animated contour plot can be used to combine the three variables — horizontal velocity, vertical velocity and pressure — and show their time evolution in an intuitive manner. Save this code in a separate file titled “FlowPy_Visualizer.py”.
As before, first import the required modules. Particularly, we will require the matplotlib.animation module to record the animation.
import numpy as npimport sysimport osimport matplotlib.pyplot as pltimport matplotlib.animation as animation
To ensure that arrays of appropriate sizes are created, simulation inputs pertaining to the computational domain need to be entered.
#### Simulation inputsrowpts=257colpts=257length=4breadth=4
Before moving to plotting, the text files that were saved during the simulation have to be imported as arrays. To do so, we first go through the Result directory, store all the filenames, and determine the total number of files as well as the printing interval.
#Go to the Result directorycwdir=os.getcwd()dir_path=os.path.join(cwdir,"Result")os.chdir(dir_path)#Go through files in the directory and store filenamesfilenames=[]iterations=[]for root,dirs,files in os.walk(dir_path):    for datafile in files:        if "PUV" in datafile:            filenames.append(datafile)            no_ext_file=datafile.replace(".txt","").strip()            iter_no=int(no_ext_file.split("V")[-1])            iterations.append(iter_no)#Discern the final iteration and intervalinitial_iter=np.amin(iterations)            final_iter=np.amax(iterations)inter=(final_iter - initial_iter)/(len(iterations)-1)number_of_frames=len(iterations)sorted_iterations=np.sort(iterations)
Next, we define a function that can import a text file — based on a provided iteration — into an array using the loadtxt function in numpy.
def read_datafile(iteration):    #Set filename and path according to given iteration    filename="PUV{0}.txt".format(iteration)    filepath=os.path.join(dir_path,filename)    #Load text file as numpy array    arr=np.loadtxt(filepath,delimiter="\t")    rows,cols=arr.shape    #Define empty arrays for pressure and velocities    p_p=np.zeros((rowpts,colpts))    u_p=np.zeros((rowpts,colpts))    v_p=np.zeros((rowpts,colpts))    #Organize imported array into variables    p_arr=arr[:,0]    u_arr=arr[:,1]    v_arr=arr[:,2]        #Reshape 1D data into 2D    p_p=p_arr.reshape((rowpts,colpts))    u_p=u_arr.reshape((rowpts,colpts))    v_p=v_arr.reshape((rowpts,colpts))        return p_p,u_p,v_p
It’s time to start making the plot! Before animating the figure, it’s a good idea to make an initial plot (for the zeroth iteration) so that the figure dimensions, axes, color bar and so on can be fixed. Also, it’s a good idea to make the stream plot with fewer grid points (in this article, 10) to make the arrows distinguishable.
#Create mesh for X and Y inputs to the figurex=np.linspace(0,length,colpts)y=np.linspace(0,breadth,rowpts)[X,Y]=np.meshgrid(x,y)#Determine indexing for stream plot (10 points only)index_cut_x=int(colpts/10)index_cut_y=int(rowpts/10)#Create blank figurefig=plt.figure(figsize=(16,8))ax=plt.axes(xlim=(0,length),ylim=(0,breadth))#Create initial contour and stream plot as well as color barp_p,u_p,v_p=read_datafile(0)ax.set_xlim([0,length])ax.set_ylim([0,breadth])ax.set_xlabel("$x$",fontsize=12)ax.set_ylabel("$y$",fontsize=12)ax.set_title("Frame No: 0")cont=ax.contourf(X,Y,p_p)stream=ax.streamplot(X[::index_cut_y,::index_cut_x],Y[::index_cut_y,::index_cut_x],u_p[::index_cut_y,::index_cut_x],v_p[::index_cut_y,::index_cut_x],color="k")fig.colorbar(cont)fig.tight_layout()
To animate this plot further, the FuncAnimation function from matplotlib.animation will come in handy. All it needs is a function that can create a plot for a supplied value of the iteration. We define such a function called animate.
def animate(i):    #Print frames left to be added to the animation    sys.stdout.write("\rFrames remaining: {0:03d}".format(len(sorted_iterations)-i))    sys.stdout.flush()    #Get iterations in a sequential manner through sorted_iterations    iteration=sorted_iterations[i]    #Use the read_datafile function to get pressure and velocities    p_p,u_p,v_p=read_datafile(iteration)    #Clear previous plot and make contour and stream plots for current iteration    ax.clear()    ax.set_xlim([0,length])    ax.set_ylim([0,breadth])    ax.set_xlabel("$x$",fontsize=12)    ax.set_ylabel("$y$",fontsize=12)    ax.set_title("Frame No: {0}".format(i))    cont=ax.contourf(X,Y,p_p)    stream=ax.streamplot(X[::index_cut_y,::index_cut_x],\                         Y[::index_cut_y,::index_cut_x],\                         u_p[::index_cut_y,::index_cut_x],\                         v_p[::index_cut_y,::index_cut_x],\                         color="k")    return cont,stream
Finally, it’s time to save the animation and watch some fluids dance around on your computer!
print("######## Making FlowPy Animation ########")print("#########################################")anim=animation.FuncAnimation(fig,animate,frames=number_of_frames,interval=50,blit=False)movie_path=os.path.join(dir_path,"FluidFlowAnimation.mp4")anim.save(r"{0}".format(movie_path))print("\nAnimation saved as FluidFlowAnimation.mp4 in Result")
Run FlowPy_Input.py first to generate simulation data and then FlowPy_Visualizer.py to save a video of the simulation.
The animated contour and stream plot for the lid cavity benchmark at Re=400 is shown below. It shows the formation of a vortex at the center as the simulation progresses and ultimately, a transition to a steady state.
A quantitative comparison of the statistics of the steady flow with the results of Ghia et al. (1982) is also performed. Specifically, the horizontal velocities along a vertical line passing through the center of the cavity and vice versa are compared with the simulation results from the paper. The results show reasonable agreement. Deviations can be attributed to the lower accuracy of the finite difference scheme and smaller grid size.
While this tutorial only includes the simulation of the lid cavity test, you can try playing around with the inputs and boundary conditions to model a variety of different single-phase flow problems, like Poiseuille flow in a pipe.
With the creation and validation of FlowPy, we can move to the next step in the modeling of a crystallizer — addition of heat and mass transfer to the solver, which will be covered in the next article.
The FlowPy code is available on GitHub. Feel free to contact me via email or Twitter for questions, suggestions, or any other discussion about my blog. If you would like to make a quantitative benchmark against the lid cavity test results, send me a quick email and I can share a Jupyter notebook with the code.
Important References:
Barba, L. A., & Forsyth, G. F. (2018). CFD Python: the 12 steps to Navier-Stokes equations. Journal of Open Source Education, 2(16), 21.
Owkes, M. (2020), A guide to writing your first CFD Solver
Ghia, U. K. N. G., Ghia, K. N., & Shin, C. T. (1982). High-Re solutions for incompressible flow using the Navier-Stokes equations and a multigrid method. Journal of computational physics, 48(3), 387–411.