Simulating Single Phase Transport#

The point of an OpenPNM simulation is ultimately to compute some transport process. In this notebook we will cover the following subjects:

  • Defining conductance

  • Settings boundary conditions

Start by defining a network. We’ll use the Demo class which happens to include all the geometrical pore-scale models already.

import numpy as np
import openpnm as op
op.visualization.set_mpl_style()

pn = op.network.Demo(shape=[5, 5, 1], spacing=5e-5)
print(pn)
══════════════════════════════════════════════════════════════════════════════
net : <openpnm.network.Demo at 0x7fc7144e98f0>
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Properties                                                   Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.coords                                                       25 / 25
  3  throat.conns                                                      40 / 40
  4  pore.coordination_number                                          25 / 25
  5  pore.max_size                                                     25 / 25
  6  throat.spacing                                                    40 / 40
  7  pore.seed                                                         25 / 25
  8  pore.diameter                                                     25 / 25
  9  throat.max_size                                                   40 / 40
 10  throat.diameter                                                   40 / 40
 11  throat.cross_sectional_area                                       40 / 40
 12  throat.hydraulic_size_factors                                     40 / 40
 13  throat.diffusive_size_factors                                     40 / 40
 14  throat.lens_volume                                                40 / 40
 15  throat.length                                                     40 / 40
 16  throat.total_volume                                               40 / 40
 17  throat.volume                                                     40 / 40
 18  pore.volume                                                       25 / 25
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Labels                                                 Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.xmin                                                               5
  3  pore.xmax                                                               5
  4  pore.ymin                                                               5
  5  pore.ymax                                                               5
  6  pore.surface                                                           16
  7  throat.surface                                                         16
  8  pore.left                                                               5
  9  pore.right                                                              5
 10  pore.front                                                              5
 11  pore.back                                                               5
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――

Define Phase Viscosity#

To fully illustrate the process of performing transport calculations, we’ll use an empty Phase object and add all the needed properties manually:

water = op.phase.Phase(network=pn)

Let’s assume that we are interested in pressure driven flow. This requires knowing the viscosity of the phase, so let’s add a pore-scale model for computing the viscosity of water:

water.add_model(propname='pore.viscosity',
                model=op.models.phase.viscosity.water_correlation)
print(water)
══════════════════════════════════════════════════════════════════════════════
phase_01 : <openpnm.phase.Phase at 0x7fc6c80d85e0>
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Properties                                                   Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.temperature                                                  25 / 25
  3  pore.pressure                                                     25 / 25
  4  pore.viscosity                                                    25 / 25
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Labels                                                 Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.all                                                               25
  3  throat.all                                                             40
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――

And we can check the individual values to verify they make sense:

print(water['pore.viscosity'])
[0.00089319 0.00089319 0.00089319 0.00089319 0.00089319 0.00089319
 0.00089319 0.00089319 0.00089319 0.00089319 0.00089319 0.00089319
 0.00089319 0.00089319 0.00089319 0.00089319 0.00089319 0.00089319
 0.00089319 0.00089319 0.00089319 0.00089319 0.00089319 0.00089319
 0.00089319]

Basic Conductance Calculation#

Determining the conductance of the conduits between each pair of connected pores is the most important part of performing a simulation. The details of conductance models are covered elsewhere. For this demonstration will assume the very simplest case where all pressure loss occurs in the throats.

Recall the Hagan-Poiseuille equation for fluid flow through a cylindrical tube:

\[ Q = \frac{\pi R^4}{8 \mu L} \Delta P\]

where \(R\) and \(L\) are the radius and length of the throat, and \(\mu\) is the viscosity of the fluid. Together this prefactor can be referred to as the hydraulic conductance, \(g_h\), giving:

\[ Q = g_h \Delta P \]

So the aim is the compute values of \(g_h\) for each throat. We start by doing this manually:

R = pn['throat.diameter']/2
L = pn['throat.length']
mu = water['throat.viscosity']  # See ProTip below
water['throat.hydraulic_conductance'] = np.pi*R**4/(8*mu*L)
print(water['throat.hydraulic_conductance'])
[5.17774155e-15 4.32563283e-14 4.23115961e-14 5.53240830e-14
 2.51424895e-15 2.39394208e-15 9.20259822e-15 1.00439200e-14
 6.61702130e-15 1.38987405e-15 1.35774102e-15 5.42856431e-15
 6.91071517e-15 8.46420127e-15 2.34135177e-14 1.09814325e-14
 1.24347610e-14 1.15170466e-14 1.20213371e-14 7.96516839e-15
 5.07225517e-15 2.56417254e-15 3.08067613e-14 9.74387823e-15
 6.95474966e-14 7.83751250e-15 2.15267100e-15 1.54141439e-15
 5.43421668e-15 9.91167311e-15 6.90703966e-15 6.62052663e-15
 1.73221324e-15 5.82544371e-15 8.26488592e-15 1.10804240e-14
 7.71509891e-15 1.26775566e-14 2.21254891e-14 6.71591108e-15]

Phase can do automatic interpolation to get throat values

The Phase class has a special ability to interpolate pore values to throats, and vice-versa. In PNM simulations all the balances are solved for each pore, so the thermodynamic properties like temperature, pressure, etc. are all defined on pores. Consequently, the physical properties are also defined in pores, like viscosity. However, as shown above we often want viscosity values in the throats. OpenPNM provides a shortcut for this, such that if you request a throat property that does not exist, it will attempt to fetch the pores values and do a linear interpolation of values to produce an array of throat values. There is also a function for this, water.interpolate_data('throat.viscosity'), but the water['throat.viscosity'] shortcut is very convenient. The automatic interpolation can be disabled in the phase.settings.

water.interpolate_data('throat.viscosity')
array([0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319])
water['throat.viscosity']
array([0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319,
       0.00089319, 0.00089319, 0.00089319, 0.00089319, 0.00089319])

Create Algorithm Object#

OpenPNM contains a variety of Algorithm classes in the openpnm.algorithms module. Let’s initialize a StokesFlow algorithm, since this simulates pressure driven flow through the network.

sf = op.algorithms.StokesFlow(network=pn, phase=water)
print(sf)
══════════════════════════════════════════════════════════════════════════════
stokes_01 : <openpnm.algorithms.StokesFlow at 0x7fc6c80d9490>
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Properties                                                   Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.bc.rate                                                       0 / 25
  3  pore.bc.value                                                      0 / 25
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Labels                                                 Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.all                                                               25
  3  throat.all                                                             40
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――

Assign Boundary Conditions#

As can be seen in the print-out above there are predefined 'pore.bc' arrays, but they contain no valid values, meaning they are all nans. Once we set some boundary conditions, this will change. Let’s apply pressure BCs on one side of the network, and rate BCs on the other:

sf.set_value_BC(pores=pn.pores('left'), values=100_000)
sf.set_rate_BC(pores=pn.pores('right'), rates=1e-10)
print(sf)
══════════════════════════════════════════════════════════════════════════════
stokes_01 : <openpnm.algorithms.StokesFlow at 0x7fc6c80d9490>
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Properties                                                   Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.bc.rate                                                       5 / 25
  3  pore.bc.value                                                      5 / 25
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Labels                                                 Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.all                                                               25
  3  throat.all                                                             40
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――

All boundary conditions are preceeded with ‘pore.bc’

All boundary conditions are stored as ‘pore.bc.’, which means that OpenPNM’s dictionary lookup tricks can be used to see all types and values of bc’s using: sf['pore.bc'] which will return a dict. This can be used as shown below:

print(sf['pore.bc'].keys())
dict_keys(['rate', 'value'])

Now we can see there are 5 valid values of each type. The sf algorithm will look for 'throat.hydraulic_conductance' on water by default, so we can just run:

soln = sf.run()

The run method solves the mass balance around each pore and computes the pressure within each pore that is required to sustain the flow defined by the boundary conditions. The soln object that is returned is a dictionary with the key corresponding to the quantity that was solved for.

print(soln)
None

The reason for the dict format is to provide a consistent API for single components and multiphysics, where multiple different quantities might be solved for. However, these 'pore.pressure' values are also written to the dictionary of the algorithm object as well:

print(sf)
══════════════════════════════════════════════════════════════════════════════
stokes_01 : <openpnm.algorithms.StokesFlow at 0x7fc6c80d9490>
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Properties                                                   Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.bc.rate                                                       5 / 25
  3  pore.bc.value                                                      5 / 25
  4  pore.pressure                                                     25 / 25
  5  pore.initial_guess                                                25 / 25
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Labels                                                 Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.all                                                               25
  3  throat.all                                                             40
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――

Finally we can look at how much pressure was required in the “right” pores to meet the required flow rate:

sf['pore.pressure'][pn.pores('right')]
array([158743.29503066, 158878.35773512, 156732.56588817, 153839.330863  ,
       154823.32126502])

So we can see that 150 kPa was required to accomplish the requested flow.

Rigorous Conductance Calculation#

The above demonstration used a very simplistic conductance calculation. It was also stated that computing conductance is the most important part of doing a PNM simulation. To finish this notebook, we’ll look more closely at this process.

Manual Method#

Let’s print the network object again:

print(pn)
══════════════════════════════════════════════════════════════════════════════
net : <openpnm.network.Demo at 0x7fc7144e98f0>
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Properties                                                   Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.coords                                                       25 / 25
  3  throat.conns                                                      40 / 40
  4  pore.coordination_number                                          25 / 25
  5  pore.max_size                                                     25 / 25
  6  throat.spacing                                                    40 / 40
  7  pore.seed                                                         25 / 25
  8  pore.diameter                                                     25 / 25
  9  throat.max_size                                                   40 / 40
 10  throat.diameter                                                   40 / 40
 11  throat.cross_sectional_area                                       40 / 40
 12  throat.hydraulic_size_factors                                     40 / 40
 13  throat.diffusive_size_factors                                     40 / 40
 14  throat.lens_volume                                                40 / 40
 15  throat.length                                                     40 / 40
 16  throat.total_volume                                               40 / 40
 17  throat.volume                                                     40 / 40
 18  pore.volume                                                       25 / 25
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  #  Labels                                                 Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
  2  pore.xmin                                                               5
  3  pore.xmax                                                               5
  4  pore.ymin                                                               5
  5  pore.ymax                                                               5
  6  pore.surface                                                           16
  7  throat.surface                                                         16
  8  pore.left                                                               5
  9  pore.right                                                              5
 10  pore.front                                                              5
 11  pore.back                                                               5
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――

Note the 'throat.hydraulic_size_factors' array. This is computed by a pore-scale model on the Demo network. This computation is more rigorous in the following ways:

  1. The conductance of each half pore and that throat is considered.

  2. The throat length is computed carefully by accounting for the ‘lens’ between the intersection of the spherical pore bodies and the cylindrical throat.

  3. The net cross-sectional area of the pores are computed by integrating between the pore center and the pore-throat intersection point

The conductance of each element in the conduit is returned as an Nt-by-3 array, where columns 1 and 3 contain the hydraulic conductance of the half pore on either end of the throat, and the column 1 contains the throat conductance.

pn['throat.hydraulic_size_factors']
array([[1.02039120e-16, 4.62471188e-18, 1.93770519e-16],
       [4.90093817e-16, 3.86361608e-17, 4.51955394e-16],
       [4.51955394e-16, 3.77923346e-17, 4.73008308e-16],
       [5.13012612e-16, 4.94149701e-17, 5.81274008e-16],
       [1.35663105e-16, 2.24570440e-18, 6.17747778e-17],
       [6.17747778e-17, 2.13824740e-18, 1.20697663e-16],
       [2.19673494e-16, 8.21967742e-18, 1.61811680e-16],
       [1.61811680e-16, 8.97113842e-18, 2.63941093e-16],
       [1.39003219e-16, 5.91026352e-18, 1.44261496e-16],
       [7.12260995e-17, 1.24142292e-18, 4.49681644e-17],
       [4.49681644e-17, 1.21272199e-18, 6.48053525e-17],
       [1.20158545e-16, 4.84874450e-18, 1.33408896e-16],
       [1.64644894e-16, 6.17258823e-18, 1.39056217e-16],
       [1.39056217e-16, 7.56014795e-18, 2.59863055e-16],
       [4.04213557e-16, 2.09127420e-17, 2.83030088e-16],
       [2.18934742e-16, 9.80851608e-18, 1.88891381e-16],
       [1.94914510e-16, 1.11066160e-17, 2.68188118e-16],
       [2.59322348e-16, 1.02869218e-17, 1.84648847e-16],
       [1.84648847e-16, 1.07373495e-17, 2.82305601e-16],
       [2.35723654e-16, 7.11441630e-18, 1.38129245e-16],
       [1.02039120e-16, 4.53049240e-18, 1.85669934e-16],
       [1.41503475e-16, 2.29029570e-18, 6.17747778e-17],
       [3.97232831e-16, 2.75163203e-17, 3.69294461e-16],
       [2.48813267e-16, 8.70314383e-18, 1.61811680e-16],
       [6.41428071e-16, 6.21191944e-17, 5.99475942e-16],
       [2.25036784e-16, 7.00039521e-18, 1.39003219e-16],
       [6.17747778e-17, 1.92274625e-18, 8.67498683e-17],
       [9.89339997e-17, 1.37677738e-18, 4.49681644e-17],
       [1.33869119e-16, 4.85379314e-18, 1.20158545e-16],
       [2.62413969e-16, 8.85301670e-18, 1.60307514e-16],
       [1.39003219e-16, 6.16930530e-18, 1.64605565e-16],
       [1.44296623e-16, 5.91339446e-18, 1.39056217e-16],
       [4.49681644e-17, 1.54719718e-18, 1.27701360e-16],
       [1.20158545e-16, 5.20323359e-18, 1.65086800e-16],
       [1.60307514e-16, 7.38212129e-18, 1.70067124e-16],
       [2.03785305e-16, 9.89693437e-18, 1.94914510e-16],
       [1.39056217e-16, 6.89105650e-18, 2.17455215e-16],
       [3.10038184e-16, 1.13234788e-17, 1.84648847e-16],
       [2.83030088e-16, 1.97622866e-17, 3.68105925e-16],
       [1.54765439e-16, 5.99859098e-18, 1.38129245e-16]])

This data is called the size factor because it is purely the geometrical information required for the computation of the hydraulic conductance. So the Hagan-Poisseiulle equation for each element is written as:

\[ Q = \frac{F_h}{\mu} \Delta P = g_h \Delta P\]

Note that both the \(\frac{\pi R^4}{8}\) term and \(L\) have been rolled into the \(F_h\) value.

The total conductance of the pore-throat-pore conduit can be found as the sum of three resistors in series. Since we have conductance values, we add the inverses, and invert again. The full expression for the hydraulic conductance between pores i and j, through throat k, is:

\[ Q = \bigg( \frac{\mu}{F_{h, i}} + \frac{\mu}{F_{h, k}} + \frac{\mu}{F_{h, j}} \bigg) ^ {-1} \Delta P \]

This can be computed by hand:

F_h = water['throat.hydraulic_size_factors']
water['throat.hydraulic_conductance'] = (mu * (1/F_h).sum(axis=1))**(-1)
water['throat.hydraulic_conductance']
array([4.84267728e-15, 3.71515524e-14, 3.63652445e-14, 4.68318485e-14,
       2.38791253e-15, 2.27489818e-15, 8.45659540e-15, 9.21941807e-15,
       6.10714777e-15, 1.32997715e-15, 1.29842661e-15, 5.04186404e-15,
       6.38769417e-15, 7.81219269e-15, 2.08004484e-14, 1.00129057e-14,
       1.13208411e-14, 1.05142097e-14, 1.09665263e-14, 7.36365582e-15,
       4.74574617e-15, 2.43450966e-15, 2.69341545e-14, 8.94948399e-15,
       5.79336703e-14, 7.24709748e-15, 2.04376032e-15, 1.47569740e-15,
       5.04732575e-15, 9.10194541e-15, 6.38440117e-15, 6.11028129e-15,
       1.65520919e-15, 5.41991869e-15, 7.58624436e-15, 1.00791486e-14,
       7.13538164e-15, 1.15476507e-14, 1.96931771e-14, 6.20587148e-15])
sf = op.algorithms.StokesFlow(network=pn, phase=water)
sf.set_value_BC(pores=pn.pores('left'), values=100_000)
sf.set_rate_BC(pores=pn.pores('right'), rates=1e-10)
soln = sf.run()
sf['pore.pressure'][pn.pores('right')]
array([163565.99290322, 163717.29917025, 161450.14687272, 158368.83547256,
       159403.09400938])

As can be seen the numbers are about the same as with the simple case, but should be somewhat more correct. In fact, these above pressures are a bit higher, which is because the total conductance of the conduit is lower due to the inclusion of the pore body lengths into the total length, compared to above where only the throat length was included.

Pore-Scale Model Method#

Instead of computing the hydraulic conductance manually as done above, there is a pore-scale model available:

water.add_model(propname='throat.hydraulic_conductance',
                model=op.models.physics.hydraulic_conductance.generic_hydraulic)
sf = op.algorithms.StokesFlow(network=pn, phase=water)
sf.set_value_BC(pores=pn.pores('left'), values=100_000)
sf.set_rate_BC(pores=pn.pores('right'), rates=1e-10)
soln = sf.run()
sf['pore.pressure'][pn.pores('right')]
array([163565.99290322, 163717.29917025, 161450.14687272, 158368.83547256,
       159403.09400938])

Which gives exactly the same result, without have to manually deal with the conductances-in-series calculation.