Python CFD-DEM Post Processing Development: Particle Statistics for a Distribution of Particle Sizes.

Particle statistics are extremely important for optimization of any industrial scale unit. Particle collision forces and stresses are difficult to quantify in traditional experiential techniques but is very valuable information because it allows Engineering prediction to be realized. For example, we may want a particular type of collision in flow problem. This can include favor over tangential to normal forces. It may not always be the desire to fully break a particle but instead “scrape” off material from composite particles made of soft and hard materials. Collision frequency is also a very valuable piece of information that can easily be realized from CFD-DEM simulations. Therefore making a Python code that does all these calculations is critical.

While making a code for outputs of particle statistics is relatively straight forward for simulations with a single particle size, what if we want a particle size distribution (PSD)? This is where filtering comes to view. We need to compartmentalize collisions between particle size fractions while also allowing something that us humans can interpret to further help optimization of the industrial unit. For example, shown in Figure1 is particle collision frequency of a simulation that I have previously performed for the company funding my research (DISA©). We then compare such information between simulations to determine which design we would like. For example, do we want a larger collision frequency of “like” sized particles or do we want more larger particles hitting the smaller ones?

Figure1: Particle collision frequency heatmap and table for a CFD-DEM simulation. Particle sizes (μm) are on the top row and left column of the table with their respective collision frequencies as the inner data.

We will want to know collision frequencies and particle collision statistics for force/stresses between all particle size fractions. I therefore created a code that does this in Python. I used Numba for speed and it also has the ability for filtering both by location and by value.

Starting at the simulation level the pairwise velocities, forces, and stresses are computed in the solver of LIGGGHTS© by the adding the following line to the simulation run script

compute frc all pair/gran/local pos vel id force_normal force_tangential contactArea

It should be noted that I have modified the LIGGGHTS© core code to output each particle radius during collision instead of the particle ID. I don’t need ID for my simulations and therefore this makes the most sense. This was easily done by modifying compute_pair_gran_local.cpp starting at line 412 to

    if(idflag)
    {
        radi = atom->radius[i];
        radj = atom->radius[j];
        array[ipair][n++] = radi;
        array[ipair][n++] = radj;
        array[ipair][n++] = 0;
    }

Now we output the particle radius during each collision, and this is used in our Python code.

The Python code starts by inputting all files into memory using the Glob Python package, setting as numpy arrays for contents of each filename, and finally concatenates all the files into a single large numpy array. Some initial calculations and arranging of the array are performed to make it more manageable. This is performed by a function I wrote.

file_list=glob.glob('dump_frc_*')
alldata=read_and_calc(numFiles,file_list)

If wanted, this can then by filtered by location. For example, I am filtering out all the collisions occurring at center ,jetstream , nozzle exit, and inner nozzle areas in the follow code snippet.

  centerData = custom_filter(alldata,cn_rxz,0,0.0254)
  jetstreamData = custom_filter(alldata,cn_rxz,0.0254,0.0508)
  nozExitData = custom_filter(alldata,cn_rxz,0.0508,0.0889)
  innerNoz = custom_filter(alldata,cn_rxz,0.0889,0.4445)
Figure2: Particles filtered by location in a VOF-DEM simulation.

I talked about the custom_filter function in a previous post. This is the most computationally demanding portion of the code because I’m essentially going through each row one by one and filtering, for this case, all particle collision locations with a radius (about the origin) less than a specified value. I tried this before in Pandas and it was ridiculously expensive but now using Numba Python package it is very reasonable by approaching C++ or Fortran speeds.

@nb.jit(nopython=True)
def custom_filter(arr, columnNumber,min,max):

    n, m = arr.shape
    result = np.empty((n, m), dtype=arr.dtype)
    k = 0
    for i in range(n):
        if arr[i, columnNumber] >= min:
          if arr[i, columnNumber] < max:
            result[k, :] = arr[i, :]
            k += 1

    return result[:k, :].copy()

I then collect all collisions in three-dimension Numpy Array with the first two elements of the array being the pairwise collision radius_i and radius_j, and then the third value being whatever value you want specified in that array. For example, it could be the velocity, the force, or the stress.

center_normStress_all = collect_all_collisions(centerData,particle_radii,cn_normStress)

In this code snippet I am taking all collisions in the center region, passing the particle radii of the collisions (the uniques of the collision rows), and specifying that I want the normal stress of these collisions. This gives me the three-dimensional array for the collisions. I then created a function that returns all the statistics of the collisions.

nub_values,min_values,max_values,mean_values,var_values, skew_values,kurtosis_values =  describe_all_collisions(center_normStress_all,particle_radii)

This returns 2-dimensional arrays with statistics, and I then print out the table and heatmap by

plot_heatmap(mean_values,uniques,filename = 'center__mean',plot_min = None, plot_max = 350000)

print(tabulate(mean_values, diameters, showindex=diameters, tablefmt="fancy_grid"))
Figure3: Heat map and table of mean normal stress of particle collisions in the center region of a CFD-DEM simulation. The top row and left column are the particle sizes in units of μm and the inner data is the mean stress values.

This has proved to be a very valuable tool in my arsenal when analyzing these CFD-DEM simulations. This code will be uploaded to my GitHub if anyone is interested.

Python CFD-DEM Post Processing Development: Faster Particle Filtering

If you’ve perused this website then you may know that I use Python extensively for post processing my simulations. I use Python because of the easy syntax, automation, and its extensive data science libraries. Python seems to have an endless number of packages that can do pretty much anything you require in the post processing or data science field. Paraview, a great visualization tool for simulations, is even written in Python. Therefore, it’s only natural that I use it for my post processing.

When running CFD-DEM (fluid-particle) simulations, I output all collisions to particles using the command to compute local pairwise collisions in LIGGGHTS©

compute frc all pair/gran/local pos vel id force_normal force_tangential contactArea

I then dump this computation to files using

dump dmp_frc1 all local 333 ../DEM/post/force/dump_frc_* c_frc[1] c_frc[2] c_frc[3] c_frc[4] c_frc[5] c_frc[6] c_frc[7] c_frc[8] c_frc[9] c_frc[10] &
c_frc[11] c_frc[12] c_frc[13] c_frc[14] c_frc[15] c_frc[16] c_frc[17] c_frc[18] c_frc[19] c_frc[20] c_frc[21] c_frc[22]

More information can be found at .https://www.cfdem.com/media/DEM/docu/compute_pair_gran_local.html but I’m essentially outputting all pairwise collisions data occurring at the specified time interval. I start running this dump when I reach a pseudo-steady state in my simulations.

Now that I have files with all my collisions data, I might, for example, want to generate histograms of particle collisions to better visualize or calculate the min/max, mean, and variance of all collisions. There could be collisions that aren’t significant and therefore I want to filter them out or I could want to group by location as demonstrated by Figure1. Hence the reason I picked Python, it is a data scientist’s greatest ally.

Figure1: Particles grouped by location in a CFD-DEM simulation.

I might also want to filter by particle location as shown in Figure1. I first started by using Pandas toolbox because it is relatively easy to implement. This worked but once I started getting into the very large amount of particle collisions this proved to be extremely slow. Sometimes I would have to run my code for 6 hours to filter the particles.

I knew the cause of this was using Pandas because although Pandas is a very intuitive and useful module for data management of smaller amounts of data it still uses Python’s back end to solve and is therefore slow. I needed C++ or Fortran, which are the gold standards for speed. The Numba Python package provides this solution at the compiler level that produces the speeds of C++ and Fortran with For Loops. Therefore I converted my Pandas Dataframes of collisions to Numpy arrays and made my filter functions of For Loops with compiler flags for Numba as shown in the code snippet given by

@nb.jit(nopython=True)
def custom_filter2(arr, columnNumber,min,max):
    n, m = arr.shape
    k = 0
    for i in range(n):
        if arr[i, columnNumber] >= min:
          if arr[i, columnNumber] < max:
            k += 1
    result = np.empty((k, m), dtype=arr.dtype)
    k = 0
    for i in range(n):
        if arr[i, columnNumber] >= min:
          if arr[i, columnNumber] < max:
            result[k, :] = arr[i, :]
            k += 1
    return result

When I changed to NumPy Arrays and Numba I saw an astonishing speedup time of over 33. Granted, I wasn’t the most efficient when writing my Pandas code to begin with but even with the most efficient Pandas code I cannot even touch the speed of Numba. That’s C++/Fortran for you!

Figure2: Computation time comparing Pandas and Numpy with Numba using the same collision data files.

CFDEM Coupling

We already went over the DEM forces and how they are implemented in the CFDEM code by default. I will now go over the coupling source term and how it’s implemented in the code by default. It is always good to see if their are some disparities between what they say their formulas use and what is actually used in the code. I’ve come to realize that this is critical to do in OpenSource code and is actually encouraged because, as being a human, we tend to make mistakes unfortunately. Looking into code can’t be done in most commercial software and you just have to take their word for it but when we have the source code we have no excuses!

According to the CFDEM website: https://www.cfdem.com/media/CFDEM/docu/CFDEMcoupling_Manual.html. If we quote them exactly, “‘modelType’ refers to the formulation of the equations to be solved. Choose “A”, “B” or “Bfull”, according to Zhou et al. (2010): “Discrete particle simulation of particle-fluid flow: model formulations and their applicability”, JFM. “A” requires the use of the force models gradPForce and viscForce, whereas “B” requires the force model “Archimedes”. “Bfull” refers to model type I, “A” refers to model type II and “B” refers to type III in the nomenclature used by Zhou et al.

So I take it from this sentence that the coupling of the code and that the equations stated by Zhou et al. are in the code. Taking a snapshot of the equations at the paper Zhou et al. [1] we first see mass conservation given by

This is straight forward but now we have the three sets of momentum equations given by

Set 1 taken directly from Zhou et al. 2010 (Model “Bfull” in CFDEM)
Set 2 taken directly from Zhou et al. 2010 (Model “A” in CFDEM)
Set 3 taken directly from Zhou et al. 2010 (Model “Bfull” in CFDEM)

Comparing Set 1 and Set 2 we can see right away that the momentum source term for Set 1 includes all of the DEM forces; that is the viscous, pressure, drag, and any additional forces such as a Saffman lift force where Set 2 does not. Instead, Set 2 has that the pressure gradient and viscous forces separated from the fluid-particle interaction source term and the pressure gradient and viscous forces are multiplied by volume fraction. Essentially, or shall I say mathematically, these two sets are equal but it is merely the order in which the contribution of viscous and pressure force contribution in the momentum equation is calculated. Is the pressure gradient and velocity force contribution calculated with the U equation or before the U equation with the source momentum term? Does this mean that one is explicit and one is implicit? Well lets look at the code and find out what they have done there.

I’m going to be going over the code as implimented in https://github.com/ParticulateFlow/CFDEMcoupling-PFM instead of CFDcoupling Public.

Starting in cfdemSolverPiso solver we go to the momentum equation to determine what is being solved and in what order. In Ugn.H we see that the momentum equation is solved by

fvVectorMatrix UEqn
(
    fvm::ddt(voidfraction,U) - fvm::Sp(fvc::ddt(voidfraction),U)
  + fvm::div(phi,U) - fvm::Sp(fvc::div(phi),U)
  + particleCloud.divVoidfractionTau(U, voidfraction)
  ==
    fvOptions(U)
  - fvm::Sp(Ksl/rho,U)
);

In through term by term..

{\rm{fvm::ddt(voidfraction,U) }} \to {{\partial \left( {{\varepsilon _f}u} \right)} \over {\partial t}}

{\rm{fvm::div(phi,U)}} \to \nabla \cdot \left( {{\varepsilon _f}uu} \right)

where phi is calculated by voidfraction*U in createFields.H

Now looking at {\rm{particleCloud}}{\rm{.divVoidfractionTau(U, voidfraction)}} we see that this function is in cfdemCloud.C with the return of

    return
    (
      - fvm::laplacian(voidfractionNuEff(voidfraction), U)
      - fvc::div(voidfractionNuEff(voidfraction)*dev2(fvc::grad(U().T()))
    );

This is the expanded out version of the divergence of the fluid stress tensor.

{\varepsilon _f}\nabla \cdot \tau = - {\varepsilon _f}\nabla \cdot \left( {\nabla u + {{\left( {\nabla u} \right)}^T}} \right) = - {\varepsilon _f}\left( {\Delta u} \right) - {\varepsilon _f}\left( {\nabla \cdot {{\left( {\nabla u} \right)}^T}} \right) \to

After the specification we see an if statement to switch between model “Bfull” and “A” or “B” that is with

if (piso.momentumPredictor() && (modelType=="B" || modelType=="Bfull"))
{
    solve(UEqn == - fvc::grad(p) + Ksl/rho*Us);
    fvOptions.correct(U);
}
else if (piso.momentumPredictor())
{
    solve(UEqn == - voidfraction*fvc::grad(p) + Ksl/rho*Us);
    fvOptions.correct(U);
}

It should first be noted that there is the use of the OpenFOAM function Sp() with terms being added to the LHS and RHS of the momentum equation implicitly to contribute to the diagonal of the matrix and help with convergence. This is done because we have explicit source terms added from the DEM side that could make our matrix ill-posed.

Tying it all together in math form the full equation is given by

{{\partial \left( {{\varepsilon _f}{u_l}} \right)} \over {\partial t}} + \nabla \cdot \left( {{\varepsilon _f}{u_l}{u_l}} \right) = - switch \cdot \nabla p - Ksl\left( {{u_l} - {u_s}} \right) + \nabla \cdot \left( {switch \cdot \tau } \right) + f

where switch is is equal to void fraction, {{\varepsilon _f}}, for model A (set II) and 1 for model Bfull (set II).

Mathematically the model A and model Bfull are equivalent but it does change the order in which particle to fluid momentum coupling occurs. Model A can be seen as implicit in that it goes against Newton’s third law by taking in the volumetric fraction of solid momentum while calculating the fluid velocity where Model Bfull uses the fluid velocity from the previous time step to calculate the coupling source term. This being said Zhou et. al 2010 does state that there is very little difference between the two models.

Now we will go into the calculation of the momentum exchange term Ksl in the code. In CFDEM the calculation of Ksl is given by

Ksl = {{{\varepsilon _f}\left| {\sum\limits_i {{F_d}} } \right|} \over {{V_c}\left| {{u_l} - {u_p}} \right|}}

where {\sum\limits_i {{F_d}} } is the summation of all the particle to fluid interaction forces. To clarify we have for Model A (set II)

\sum\limits_i {{F_d}} = {f_d} + {f^{''}}

where {f_d} is the particle drag force and {f^{''}} is the summation of all other forces besides viscous and pressure forces. For example a Saffman lift force would be used here.

For Model Bfull (set I) we have

References

\sum\limits_i {{F_d}} = {f_d} + {f_{\nabla \tau }} + {f_{\nabla p}} + {f^{''}}

where {f_{\nabla \tau }} is the viscous force due to intermolecular forces between the particles and fluid, and {f_{\nabla p}} is the pressure gradient force.

Looking at where Kslin the code we see that first the particles are “evolved” or moved on the DEM side in cfdemSolverPiso.C by

        particleCloud.clockM().start(2,"Coupling");
        bool hasEvolved = particleCloud.evolve(voidfraction,Us,U);

Once particles are evolved then the field is first smoothed and Ksl is calculated by

Ksl = particleCloud.momCoupleM(0).impMomSource();

We see that the impMomSource() function is called. This function is called in implicitCoupling.C with

tmp<volScalarField> implicitCouple::impMomSource()

Ksl in then calculated by

    return tmp<volScalarField>
    (
        new volScalarField("Ksl_implicitCouple", (1. - tsf)*KslPrev_ + tsf * KslNext_)
    );


Where KslNext is calculated previously in the same file by

KslNext_[cellI] = mag(particleCloud_.forceM(0).impParticleForces()[cellI])/Ur/particleCloud_.mesh().V()[cellI];

KslPrev is set when resetMomSourceField() is called in cfdemCloud.C with the function given by

    return tmp<volScalarField>
    (
        new volScalarField("Ksl_implicitCouple", (1. - tsf) * KslPrev_ + tsf * KslNext_)
    );

tsf is calculated by

const scalar tsf = particleCloud_.dataExchangeM().timeStepFraction();

with timeStepFraction() function called in dataExchangeModel.C for returning the fraction between previous coupling time step and actual time step. This ensures that Ksl is always an interpolated value from the previous coupling time step and the current time step.

Now back to the Ksl term. We can see that KslNext_[cellI] is calculated with impParticleForces(). This is set in cfdemCloud.C by adding all the impForces_ inside of each cell by

    averagingM().setVectorSum
    (
        forceM(0).impParticleForces(),
        impForces_,
        particleWeights_,
        NULL //mask
    );

Ok, so now where/how are impForces calculated? We see in forceSubModel.C that it is calculated in the member fucntion partToArray() by

    if(!switches_[SW_TREAT_FORCE_DEM])// !treatDEM
    {
        if(switches_[SW_TREAT_FORCE_EXPLICIT]) // treatExplicit
        {
            for(int j=0;j<3;j++)
                particleCloud_.expForces()[index][j] += dragTot[j];
        }
        else   //implicit treatment, taking explicit force contribution into account
        {
            for(int j=0;j<3;j++)
            {
                particleCloud_.impForces()[index][j] += dragTot[j] - dragEx[j]; //only consider implicit part!
                particleCloud_.expForces()[index][j] += dragEx[j];
            }
        }
    }

Essentially this uses a switch to determine if coupling force for viscosity and pressure are used. If treatForceExplicit is specified in couplingProperties expForces will only be used with adding all of the coupling forces together. Otherwise, the the explicit drag calculation will be subtracted out where dragEx is the third term in the function partToArray. For example if we look into how drag is calculated in the GidaSpowDrag.C we can see that both drag and an explicit drag is calculated.

forceSubM(0).partToArray(index,drag,dragExplicit,Ufluid,dragCoefficient);

So now, when is this switch turned on? It appears this doesn’t matter anyway because impForces() is always used by default in CFDEM. Therefore we need to look at how the explicit forces using dragEx

Therefore looking back to GidaspowDrag.C we can see an explicit correction that sets the drag to explicit given by

forceSubM(0).explicitCorr(drag,dragExplicit,dragCoefficient,Ufluid,U_[cellI],Us,UsField_[cellI],forceSubM(0).verbose());

Now we have the explicitCorr function because this is where dragExplicit is calculated and ultimately whether we subtract that portion from impForces or not. The explicitCorr function is located in forceSubModel.C with it given by

void forceSubModel::explicitCorr
(
    vector& dragImplicit,
    vector& dragExplicit,
    scalar& dragCoefficient,
    vector& Ufluid,
    const vector& Ucell,
    vector& Us,
    const vector& UsCell,
    bool verbose,
    label index
) const
{
    dragExplicit=vector::zero;
}

So essentially this function merely sets dragExplicit to zero and therefore it appears that by default in CFDEM we cannot use any specification of explicit forces or rather use Bfull model for that matter. Now, I keep talking about drag force which might be confusing because this is actually the total coupling forces used in the Ksl term because the partToArray function is used at the end of every force call. Lets look at the pressure and viscous forces to illustrate this. In viscForce.C and gradPForce.C we see at the end of the code that the force is added with

forceSubM(0).partToArray(index,force,vector::zero);

In conclusion we have that as each force is calculated an “if” statement is used inside of each force file (gradPForce.C, GidaspowDrag.C,viscForce.C...) to set the SW_TREAT_FORCE_DEM switch to true or false. An if statement is then used with this switch in forceSubModel.C to determine if the force is added to the impForces() which ultimately….. -> impMomSource() ->Ksl.

We see that code is indeed following the equations as outlined in the Zhou et al. paper. This is good news and I can precede. It’s always good know that the equations think are being solved are actually being solved. Especially when there is a free and full (paid) version to software. You don’t know if some features are turned off or wrong and that the full version might be correct.

[1] Zhou, Z. Y., Kuang, S. B., Chu, K. W., & Yu, A. B. (2010). Discrete particle simulation of particle–fluid flow: Model formulations and their applicability. Journal of Fluid Mechanics, 661, 482–510. https://doi.org/10.1017/S002211201000306X

Spreading Rates and Stokes Number in Dense High Speed Jet Flow: Part 1

This is going to the beginning of me posting about Stokes number and its affect on spreading rates in dense high speed slurry jet flow. This topic is quite extensive and so I’ll break this up into a series of posts so that it is more manageable. This is a significant portion of my dissertation and so writing about this helps me on my side and hopefully it will help other people as well.

I experienced an interesting phenomenon when running simulations at greater than 30% solids by mass (defined by mp/(mp+mf) in my fully coupled three-phase VOF-DEM simulations where spreading rates of the jets significantly increased. Along with this, fluid and particle kinetic energy increased. Now this wasn’t just a slight or linear increase. I would have relatively “straight” flow with little difference between 0-30% solids but then when raised to 40% solids there was a sharp increase in spreading rates and overall system turbulence.

Figure1: Particles in three-phase VOF-DEM simulations for green: 10%, yellow:20%, red:30%, and purple: 40% solids.

In these simulations I am running 150 micron particles at a bulk fluid (water at room temperature) velocity of 20 m/s through a 6.35mm 5 degree nozzle. We will call this case_set1. What is going on here? Now I’m sure that this has to do with the momentum coupling between the two phases with a significant amount of turbulence generated with increased mass fraction, that one is the first most obvious engineering conclusion. That being said, I had run some previous nozzle-nozzle simulations using 700 micron particles through a 19mm nozzle at bulk velocity of 15m/s (we will call this case_set2) and I was experiencing this same behavior of drastic changes in spreading rates at 30% solids instead of 40% solids and so I also know that we have influences including particle size, fluid properties, and nozzle shape. This brings up the affect of Stokes number on spreading rates and how it relates to increasing percentage of solids.

We know that Stokes number is defined by the ratio of particle and fluid (or eddy) response time. It is a good measure on whether the particle will “go its own path” or follow the flow. There are varying ways to calculate the eddy response time, as pointed out by Lau and Nathan 2016 and Monchaux et al. 2012 [1][2], and I will create a post about this in the future. For now we will categorize our two different flows by the classical nozzle exit Stokes number given by

where {\rho _p} is the particle density, {d_p} is the mean particle diameter, {U_{f,b}} is the fluid bulk velocity, \mu is the fluid viscosity, and D is the nozzle diameter. Now calculating the exit Stokes number for case_set1 we obtain 11 and for case_set2 we obtain 40.This is starting to make more sense. As expected, as we lower the Stokes number we will get a tendency for the particles to follow the flow more and therefore for case_set1 we will be able to increase mass fraction more than case_set2 until we see the higher spreading rates and turbulence. That being said, this doesn’t explain why we experience the change in spreading rates in the first place but only explains, albeit slightly, why we experience a drastic increase in spreading rates for case_set1 with a higher percentage of solids then for case_set2.

Now, this is only the start to this series of posts. I will be diving into Stokes number further with different ways of calculating the Stokes number along with experiments and simulations. We will also discuss the tendency for particle to cluster in highly turbulent regions. The ultimate question is why the sudden increase highly turbulent regions? Where does that come from? Turbophoretic effect? More rabbit holes to come that hopefully will come to some fruition.

References

[1]Lau, T. C. W., and Nathan, G. J., 2016, “The Effect of Stokes Number on Particle Velocity and Concentration Distributions in a Well-Characterised, Turbulent, Co-Flowing Two-Phase Jet,” J. Fluid Mech., 809, pp. 72–110.

[2]Balachandar, S., and Eaton, J. K., 2010, “Turbulent Dispersed Multiphase Flow,” Annu. Rev. Fluid Mech., 42(1), pp. 111–133.

CFDEM Particle Forces and Their Implementation in the Code.

Particle forces in CFDEM are given by

{m_p}{{d{V_p}} \over {dt}} = {m_p}g + {f_{\nabla \cdot \tau }} + {f_{\nabla p}} + \sum\limits_{{N_p}} {{f_{p - p}} + } \sum\limits_{{N_p}} {{f_{p - w}}} + {f_{drag}}

Particle Volume / Void Fraction

It is first prudent to discuss how the volume of the particle is calculated in CFDEM because this volume is used in almost all particle forces.

{V_s} is the volume of partial or full particle (depending on void fraction model) volume. Looking at {V_s} in the code we see that it is calculated by

Vs = particleCloud_.particleVolume(index)

Where {V_s} is the particle partial or full volume. Now this will depend on the void fraction model you used and so looking at {V_s} in the code we see that it is calculated by

Vs = particleCloud_.particleVolume(index)

The particleVolume function is calculated by

inline double cfdemCloud::particleVolume(int index) const
{
    return particleV_[index][0];
}

In #include "cfdemCloudI.H". Where cfdemCloudI.H is included by cfdemCloud.H at the end of it’s code.

If we go through the code we have starting at gradPForce.H -> forceModel.H-> cfdemCloud.H ->"cfdemCloudI.H" to end up at particleVolume().

Now that we have particleVolume() we know it returns particleV_. We find particleV_ in dividedVoidFraction.C and setWeightedSource.H.

This is where things mildy interesting. We see that in dividedVoidFraction.C there is a loop starting at

for (int index=0; index < particleCloud_.numberOfParticles(); index++)

All particleWeights, particleVolumes, and particleV are set equal to 0 in this loop initially and so every time this loops it is reset and the new particle partial/full volumes are calculated.

cellID = particleCloud_.cellIDs()[index][0];

Particle volume inside of the loop is calculated by volume = Vp(index,radius,scaleVol); with Vp() located in dividedVoidFraction.H as

       return constant::mathematical::fourPiByThree*radius*radius*radius*scaleVol;

cellID associated with that particle index is found by

cellID = particleCloud_.cellIDs()[index][0];

voidFractionNext_ is then calculated by using cellID associated with that particle index using

                scalar newAlpha = voidfractionNext_[cellID]- volume*centreWeight/cellVol;
                if (newAlpha > alphaMin_)
                {
                    voidfractionNext_[cellID] = newAlpha;
                }
                else
                {
                    voidfractionNext_[cellID] = alphaMin_;
                    tooMuch_ += (alphaMin_-newAlpha) * cellVol;
                }

Finally, particleVolumes and particleV is stored by

                particleVolumes[index][0] += volume*centreWeight;
                particleV[index][0] += volume*centreWeight;

Where particleV is the return of the function particleVolume().

Pressure Force, {f_{\nabla p}} = - {V_s}\nabla p

The pressure force is given by {f_{\nabla p}} = - {V_s}\nabla p in gradPForce.C and gradPForce.H

This force is relatively straight forward. In gradPForce.C the pressure is set to

p_(sm.mesh().lookupObject<volScalarField> (pFieldName_))

Then gradient is calculated of the pressure in the respective cell and multiplied by the particle volume, Vs, to get the force. It should be noted that there is a pressure interpolation function in CFDEM but I won’t go into this in the post.

volVectorField gradPField = fvc::grad(p_);
gradP = gradPField[cellI]
force = -Vs*gradP*(1.0+addedMassCoeff_);

Where addedMassCoeff is an artificial mass added by user in pressure properties of couplingProperties file.

Viscous Force

The viscous force is given by

\tau = {\rho _f}\nu \left( {\nabla u + {{\left( {\nabla u} \right)}^T}} \right)

Starting at viscForce.C we see that the viscous force is calculated by

force = -Vs*divTau*(1.0+addedMassCoeff_);

Calculation of divTau is located in the same file with

const volVectorField& divTauField = forceSubM(0).divTauField(U_);
divTau = divTauField[cellI]

We then find the divTauField() function in forceSubModel.C

const volVectorField& forceSubModel::divTauField(const volVectorField& U) const
{
    // calc div(Tau)
    #ifdef compre
        const volScalarField& mu_ = muField();
        divTau_ = -fvc::laplacian(mu_, U) - fvc::div(mu_*dev(fvc::grad(U)().T()));
        return divTau_;
    #else
        const volScalarField& nu_ = nuField();
        const volScalarField& rho_ = rhoField();
        divTau_ = -fvc::laplacian(nu_*rho_, U)- fvc::div(nu_*rho_*dev(fvc::grad(U)().T()));
        return divTau_;
    #endif
}

where

divTau_ = -fvc::laplacian(nu_*rho_, U)- fvc::div(nu_*rho_*dev(fvc::grad(U)().T()));

We can see that we take the Laplacian of the velocity first multiplied by the density and viscosity. Then we take the divergence of the deviatoric particle of the transform of the gradient of velocity. To put it in math terms…

{f_{\nabla \cdot \tau }} = - \nabla \cdot \left( {\nabla u + {{\left( {\nabla u} \right)}^T}} \right){\rho _f}\nu = - {\rho _f}\nu \left( {\Delta u} \right) - {\rho _f}\nu \left( {\nabla \cdot {{\left( {\nabla u} \right)}^T}} \right)

Archimedes lift force

The Archimedes lift force is another easy force that is implemented given by

{f_l} = {\rho _f}g{V_p}

The force is calculated by Archimedes’ principle that a solid body in a fluid will experience opposing force to gravity because of the difference in the density.

The force is implemented in Archimedes.C by

force = -g_.value()*forceSubM(0).rhoField()[cellI]*particleCloud_.particleVolume(index);

Drag Force and Contact Forces

This topic is quite extensive and so I’ll talk about this either by editing this post or adding a new post later on.

CFDEM Rosin-Rammler Distribution

The company that is funding my research reached out to me and wanted a Rosin-Rammler distribution in our CFD-DEM simulations. I currently use CFDEM open-source software to run all my fully coupled dense jet flow simulations. The most obvious solution was to create a python program that takes an input JSON file and sample PSD and then outputs the required input file to CFDEM.

The .json file is as follows

{
  "sample_name": "sample.dat",

  "fitting_function": "1-exp(-(x/dbar)**n)",
  "fitting_variables": [
    {
      "dbar": 200,
      "n":    0.5
    }
  ],

  "minimum_particle_diameter": 300,
  "maximum_particle_diameter": 700,
  "CFDEM_num_particle_groups":10,
  "particle_density": 2800,

  "mass_flow_rate_particles": 3.79,
  "mass_flow_rate_fluid": 5.7,
  "fluid_density": 1000,
  "fluid_dyn_viscocity": 0.00089
}

The python program takes the fitting_function, converts to a formula that python can read using ExpressionModel() and then uses curve_fit in scipy.optimize library to find the best fit to the sample PSD data. This allows any fitting_function to be the input (not just Rosin-Rammler!). The variable x is the independent variable and all other fitting variables need to be added to the “fitting_variables” section of the JSON file.

The program outputs the required particle distribution for the input file of CFDEM.

fix   pts0 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 150.0e-6
fix   pts1 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 172.22222222222223e-6
fix   pts2 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 194.44444444444446e-6
fix   pts3 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 216.66666666666666e-6
fix   pts4 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 238.88888888888889e-6
fix   pts5 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 261.1111111111111e-6
fix   pts6 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 283.3333333333333e-6
fix   pts7 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 305.55555555555554e-6
fix   pts8 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 327.77777777777777e-6
fix   pts9 all particletemplate/sphere 1 atom_type 1 density constant 2800    radius constant 350.0e-6
fix pdd all particledistribution/discrete 63243 10 pts0 0  pts1 0.268968132296593  pts2 0.20856233840831423  pts3 0.1583807705318659  pts4 0.11804978460247006  pts5 0.08651083609375576  pts6 0.06241857768856962  pts7 0.044389700574514857  pts8 0.031144533105351146  pts9 0.021575326698565434 


Because of the issue with extremely large computation expense with the particle fines, functionality was added to specify “minimum_particle_diameter”. Once specified, the program will model the fines as an Eulerian mixture in the fluid by outputting the Eulerian mixture density and viscosity and the resultant fraction of Lagrangian mass flow rate in the terminal screen. This type of modelling smaller particles as a single-fluid is known as the “dusty gas” method and is suitable for when St \leqslant 0.2. This method and it’s use is talked about extensively by S. Balachandar and John K. Eaton in their wonderful paper in Annual Review of Fluid Mechanics [1].

To get the full code with an example please see the github link.

https://github.com/dusweaver/rosin_rammler_CFDEM.git

References

[1]Balachandar, S., and Eaton, J. K., 2010, “Turbulent Dispersed Multiphase Flow,” Annu. Rev. Fluid Mech., 42(1), pp. 111–133.