Download this example.

Bidimensional fall of a cloud made of particles in a viscous fluid.

This examples illustrates how to define the fluid problem as well as the particles one. All the relevant mesh information is directly loaded from the GMSH api.

import os
import time
import gmsh
import shutil
import ctypes
import numpy as np
from scipy.spatial import KDTree
from migflow import fluid
from migflow import scontact
from migflow import time_integration

use_callback = True

First, let’s create the output directory. Define output directory

outputdir = "output-drop"
shutil.rmtree(outputdir,ignore_errors=True)
if not os.path.isdir(outputdir) :
    os.makedirs(outputdir)

Mesh generation

The mesh geometry is created with the python GMSH api.

height = 2                                      # domain height
width = 0.1                                     # domain width
origin = [-width/2, -height/2]                  # leftmost bottom corner

def gen_geometry(width, height, origin=np.array([0,0])):
    origin = np.asarray(origin)
    gmsh.model.add("box")
    gmsh.model.occ.add_rectangle(origin[0], origin[1], 0, width, height)
    gmsh.model.occ.synchronize()
    def get_line(x0, x1, eps =1e-6):
        r = gmsh.model.get_entities_in_bounding_box(x0[0]-eps, x0[1]-eps, -eps, x1[0]+eps, x1[1]+eps, eps, 1)
        return list(tag for dim, tag in r)
    h, w = height, width
    gmsh.model.add_physical_group(1,get_line(origin+[0,0], origin+[w,0]), name="Bottom")
    gmsh.model.add_physical_group(1,get_line(origin+[0,h], origin+[w,h]), name="Top")
    gmsh.model.add_physical_group(1,get_line(origin+[0,0], origin+[0,h])+get_line(origin+[w,0], origin+[w,h]), name="Lateral")
    gmsh.model.add_physical_group(2,[1], name="domain")

The mesh size can be chosen based on three approaches; based on a computed field, based on a callback or based on a uniform mesh size.


The mesh size is computed based on the gradient variation. The field is then given to gmsh which will generate the mesh.

def gen_mesh_field(f):
    lcmin = 0.0015/4
    lcmax = 0.015/8
    grad = f.fields_gradient()
    grad_u, grad_v, grad_p = grad[:,0,:], grad[:,1,:], grad[:,2,:]
    grad_v = np.linalg.norm(grad_v, axis=1)
    grad_v_min = np.min(grad_v)
    grad_v_max = np.max(grad_v)
    lv = (grad_v_max - grad_v_min)/(grad_v-grad_v_min) * lcmin
    size = np.maximum(lv, lcmax)

    x = f.coordinates()[f.elements()]
    size_view = gmsh.view.add("size")
    data = np.c_[x.swapaxes(1,2).reshape(-1,9), size[f.elements()]]
    size_field = gmsh.model.mesh.field.add("PostView")
    gmsh.model.mesh.field.setNumber(size_field, "ViewTag", size_view)
    gmsh.model.mesh.field.setAsBackgroundMesh(size_field)
    gmsh.view.add_list_data(size_view, "ST", data.shape[0], data.reshape(-1))
    gmsh.model.mesh.clear()
    gmsh.model.mesh.generate(2)
    gmsh.view.remove(size_view)

The mesh size is computed based on a callback function. Here a refinement is done close to the particles position.

def gen_mesh_callback(xp):
    tree = KDTree(xp)
    def size_f(x):
        dist, _ = tree.query(x[:,:2])
        distmin = 0.01
        lcmin = 0.0015
        lcmax = 0.015
        alpha = np.clip((dist[0]-distmin)/0.1, 0, 1)
        size = lcmin*(1-alpha) + lcmax*alpha
        return size
    gmsh.model.mesh.set_size_callback(lambda dim, tag, x, y, z, lc: size_f(np.array([[x,y]])))
    gmsh.model.mesh.clear()
    gmsh.model.mesh.generate(2)
    gmsh.model.mesh.remove_size_callback()

The mesh is generated based on a constant mesh size.

def gen_mesh_uniform(size):
    gmsh.model.mesh.clear()
    gmsh.model.mesh.set_size_callback(lambda dim, tag, x, y, z, lc: size)
    gmsh.model.mesh.generate(2)
    gmsh.model.mesh.remove_size_callback()

Particle Problem

The particle problem is created and its dimension is provided.

p = scontact.ParticleProblem(2)

The particle properties are defined. All the particles are assumed to be spherical.

r    = 25e-6                                       # particles radius
rhop = 2450                                         # particles density
compacity = 0.2                                     # solid volume fraction in the drop
rout = 3.3e-3                                       # drop radius

The particle positions are initialised randomly in a circular domain to obtain a given compacity.

def genInitialPosition(p, r, rout, rhop, compacity, origin) :
    """Set all the particles centre positions and create the particles objects

    Keyword arguments:
    p -- Particle Problem
    r -- max radius of the particles
    rout -- outer radius of the cloud
    rhop -- particles density
    compacity -- initial compacity in the cloud
    """
    # Space between the particles to obtain the expected compacity
    N = compacity*(rout/r)**2
    bodies = np.asarray([], int)
    while p.n_particles() < N:
        xyp = np.random.uniform(-rout, rout, 2)
        if np.hypot(xyp[0], xyp[1]) < rout:
            if p.n_particles() == 0:
                d = 1
            else:
                d = np.min(np.linalg.norm(p.position()-xyp[None,:],axis=1))
            if d > 2.1*r:
                body = p.add_particle(xyp, r, r**2 * np.pi * rhop)
                bodies = np.append(bodies,body)
    # Shift of the particles to the top of the box
    p.body_position()[bodies, :] += origin
    return bodies
bodies = genInitialPosition(p, r, rout, rhop, compacity, np.array([0,1.9*height/4]))

Fluid Problem

The fluid is described by its dimension, the external volume force applied, its dynamic viscosity and its density.

g   = np.array([0,-9.81])                           # gravity
rho = 1030                                          # fluid density
nu  = 1.17/(2*rho)                                  # kinematic viscosity
mu  = rho*nu                                        # dynamic viscosity
f   = fluid.FluidProblem(2,g,[nu*rho],[rho], usolid=True)

The mesh is created with a refinment close to the particles position. The boundaries are loaded either from a .msh file or from the current model loaded with the GMSH api (if None as a filename is given).

gen_geometry(width, height, origin)
if use_callback:
    gen_mesh_callback(p.position()[p.r()[:,0]>0])
else:
    gen_mesh_uniform(0.015)
    f.load_msh(None)
p.load_msh_boundaries(None, ["Bottom","Top","Lateral"])
## %%
# The mesh can be loaded through the GMSH api if None is given or through a mesh filename.
# The boundary conditions are described by their physical name.
# To fully describe the pressure, its mean value over all the nodal values is set to zero.
f.load_msh(None)
f.set_wall_boundary("Bottom")
f.set_wall_boundary("Top")
f.set_wall_boundary("Lateral")
f.set_mean_pressure(0)

FEM-DEM coupling

The presence of particles is given to the fluid through the fluid volume fraction, i.e. the porosity and through a drag parametrization. All the relevant informations needed for the fluid is given by :

f.set_particles(p.delassus(), p.volume(), p.position(), p.velocity(), p.omega(), p.contact_forces())

Time integration

The numerical parameters are defined given and the initial conditions are written in the output directory.

outf = 10                                           # number of iterations between output files
remeshing=20                                         # time step to adapt the mesh
dt   = 5e-3                                         # time step
tEnd = 50                                           # final time
t    =  0                                           # initial time
ii   =  0                                           # initial iteration
number_p = f.n_particles
position_p = f.particle_position()
volume_p = f.particle_volume()

Write chosen fields into the output

def get_fields(fluid):
    y = fluid.coordinates_fields()[fluid.pressure_index()][:,1]
    y = y.reshape(-1,1)
    p1_element = fluid.get_p1_element()
    return {"pressure": (fluid.pressure(), p1_element),
            "velocity": (fluid.velocity(), p1_element),
            "porosity": (fluid.porosity(), p1_element),
            "dynamic_pressure":(fluid.pressure()-rho*g[1]*y, p1_element)}

p.write_mig(outputdir, t)
f.write_mig(outputdir, t, get_fields(f))

The computational loop can start. The particles phase will use sub-iterations to keep a stable method. The minimal number of subiterations is given by min_nsub. The external forces given to the particles have to be given at each time step. A predictor-corrector method can also be used by setting the flag to True.

tic = time.process_time()
forces = g*np.pi*p.r()**2*rhop
mass = np.pi*p.r()**2*rhop
while t < tEnd :
    print("%i : %.2g/%.2g (cpu %.6g)" % (ii, t, tEnd, time.process_time() - tic))
    f.set_particles(p.delassus(), p.volume(), p.position(), p.velocity(), p.omega(), p.contact_forces())
    tic = time.process_time()
    f.implicit_euler(dt, check_residual_norm=1)
    print("implicit : ", time.process_time()-tic)
    fext = f.compute_node_force(dt) + g*mass
    tic = time.process_time()
    time_integration._advance_particles(p, fext, dt, 1, 1e-3*r)
    print("NSCD : ", time.process_time()-tic)

    # time_integration.iterate(f, p, dt, min_nsub=2, external_particles_forces=forces, use_predictor_corrector=False)
    if ((ii+1)%remeshing==0):
        number_p = f.n_particles
        position_p = f.particle_position()
        volume_p = f.particle_volume()
    if (ii%remeshing==0 and ii != 0):
        if use_callback:
            gen_mesh_callback(p.position()[p.r()[:,0]>0])
        else:
            gen_mesh_field(f)
        f.adapt_mesh(old_n_particles=number_p, old_particle_position=position_p, old_particle_volume=volume_p)
        f.set_particles(p.delassus(), p.volume(), p.position(), p.velocity(), p.omega(), p.contact_forces())
    t += dt
    # Output files writting
    if ii%outf == 0 :
        p.write_mig(outputdir, t)
        f.write_mig(outputdir, t, get_fields(f))
    ii += 1