Coupling Reaktoro into other reactive transport codes¶
In this tutorial, we show how Reaktoro can be used in other codes for reactive transport modeling. Here, you will find that we have split the mass transport and chemical reaction calculations so that you can clearly see how a dedicated and advanced transport solver could be combined with Reaktoro’s solvers for chemical reaction calculations. A basic transport solver is used here instead for the sake of software coupling demonstration.
The reactive transport problem we consider here is also relatively simple, similar to the one presented in the tutorial Reactive transport of CO2-saturated brine along a porous rock column. Unlike other tutorials, we proceed here first with a step-by-step explanation of the full script found at the end of this tutorial.
Importing python packages¶
First, we need to import a few Python packages to enable us to perform the numerical calculations and plotting.
from reaktoro import *
from numpy import *
import matplotlib.pyplot as plt
from joblib import Parallel, delayed
import os, time
We import the reaktoro Python package so that we can use its classes and methods for performing chemical reaction calculations, numpy for working with arrays, matplotlib for plotting capabilities, joblib for simple parallel computing, and, finally, os, to provide a portable way of using operating system dependent functionality.
Defining parameters for the reactive transport simulation¶
Next, we define reactive transport and numerical discretization parameters.
xl = 0.0 # the x-coordinate of the left boundary
xr = 1.0 # the x-coordinate of the right boundary
nsteps = 100 # the number of steps in the reactive transport simulation
ncells = 100 # the number of cells in the discretization
D = 1.0e-9 # the diffusion coefficient (in units of m2/s)
v = 1.0/day # the fluid pore velocity (in units of m/s)
dt = 10*minute # the time step (in units of s)
T = 60.0 + 273.15 # the temperature (in units of K)
P = 100 * 1e5 # the pressure (in units of Pa)
dirichlet = False # the parameter that determines whether Dirichlet BC must be used
We specify the considered rock domain by setting coordinates of its left and right boundaries to 0.0 m and 100.0 m, respectively. The discretization parameters, i.e., the number of cells and steps in time, are both set to 100. The reactive transport modeling procedure assumes a constant fluid velocity of 1 m/day (1.16 · 10-5 m/s) and the same diffusion coefficient of 10-9 m2/s for all fluid species (without dispersivity). The size of the time-step is set to 10 minutes. Temperature and pressure are set to 60 °C and 100 bar, respectively, throughout the whole tutorial. The chemical equilibrium calculations performed in each mesh cell, at every time step, using Gibbs energy minimisation algorithm (provided by the class EquilibriumSolver).
The boolean variable dirichlet
is set to True
or False
depending on
which boundary condition is considered in the numerical calculation. Set to
False
for imposing the flux of the injected fluid, otherwise, set to
True
for imposing the composition of the fluid on the left boundary.
Specifying the quantities and properties to be outputted¶
Before running the reactive transport simulations, we specify the list of parameters we are interested in outputting. In this case, it is pH, molality of H+, Ca2+, Mg2+, HCO3-, CO2 (aq), as well as a phase volume of calcite and dolomite.
output_quantities = """
pH
speciesMolality(H+)
speciesMolality(Ca++)
speciesMolality(Mg++)
speciesMolality(HCO3-)
speciesMolality(CO2(aq))
phaseVolume(Calcite)
phaseVolume(Dolomite)
""".split()
Organizing the main function in three parts¶
Below is the main function in our script, which shows three parts, each represented by a Python function documented in the next sections:
if __name__ == '__main__':
# Create folders for the results
make_results_folders()
# Run the reactive transport simulations
simulate()
# Plotting the result
plot()
Here, we first create the required folders for the results, we then run the reactive transport simulation, and, finally, plot the outputted results.
Creating folders for the outputted results¶
def make_results_folders():
os.system('mkdir -p results')
os.system('mkdir -p figures/ph')
os.system('mkdir -p figures/aqueous-species')
os.system('mkdir -p figures/calcite-dolomite')
os.system('mkdir -p videos')
Using os package, we create required folders for outputting the obtained results and for the plot and video files later.
Perform the reactive transport simulation¶
The reactive transport simulation is performed in the function simulate
(you may want to have a quick look in the code below and move to a
detailed explanation in the subsequent sections):
def simulate():
# Step 7.1: Construct the chemical system with its phases and species
db = Database('supcrt98.xml')
editor = ChemicalEditor(db)
editor.addAqueousPhase([
'H2O(l)',
'H+',
'OH-',
'Na+',
'Cl-',
'Ca++',
'Mg++',
'HCO3-',
'CO2(aq)',
'CO3--']) # aqueous species are individually selected for performance reasons
editor.addMineralPhase('Quartz')
editor.addMineralPhase('Calcite')
editor.addMineralPhase('Dolomite')
# Step 7.2: Create the ChemicalSystem object using the configured editor
system = ChemicalSystem(editor)
# Step 7.3: Define the initial condition of the reactive transport modeling problem
problem_ic = EquilibriumProblem(system)
problem_ic.setTemperature(T)
problem_ic.setPressure(P)
problem_ic.add('H2O', 1.0, 'kg')
problem_ic.add('NaCl', 0.7, 'mol')
problem_ic.add('CaCO3', 10, 'mol')
problem_ic.add('SiO2', 10, 'mol')
# Step 7.4: Define the boundary condition of the reactive transport modeling problem
problem_bc = EquilibriumProblem(system)
problem_bc.setTemperature(T)
problem_bc.setPressure(P)
problem_bc.add('H2O', 1.0, 'kg')
problem_bc.add('NaCl', 0.90, 'mol')
problem_bc.add('MgCl2', 0.05, 'mol')
problem_bc.add('CaCl2', 0.01, 'mol')
problem_bc.add('CO2', 0.75, 'mol')
# Step 7.5: Calculate the equilibrium states for the initial and boundary conditions
state_ic = equilibrate(problem_ic)
state_bc = equilibrate(problem_bc)
# Step 7.6: Scale the volumes of the phases in the initial condition
state_ic.scalePhaseVolume('Aqueous', 0.1, 'm3')
state_ic.scalePhaseVolume('Quartz', 0.882, 'm3')
state_ic.scalePhaseVolume('Calcite', 0.018, 'm3')
# Scale the boundary condition state to 1 m3
state_bc.scaleVolume(1.0, 'm3')
# Step 7.7: Partitioning fluid and solid species
# The number of elements in the chemical system
nelems = system.numElements()
# The indices of the fluid and solid species
ifluid_species = system.indicesFluidSpecies()
isolid_species = system.indicesSolidSpecies()
# The concentrations of each element in each mesh cell (in the current time step)
b = zeros((ncells, nelems))
# The concentrations (mol/m3) of each element in the fluid partition, in each mesh cell
bfluid = zeros((ncells, nelems))
# The concentrations (mol/m3) of each element in the solid partition, in each mesh cell
bsolid = zeros((ncells, nelems))
# Initialize the concentrations (mol/m3) of the elements in each mesh cell
b[:] = state_ic.elementAmounts()
# Initialize the concentrations (mol/m3) of each element on the boundary
b_bc = state_bc.elementAmounts()
# Step 7.8: Create a list of chemical states for the mesh cells
# The list of chemical states, one for each cell, initialized to state_ic
states = [state_ic.clone() for _ in range(ncells)]
# Step 7.9: Create the equilibrium solver object for the repeated equilibrium calculation
# The interval [xl, xr] split into ncells
x = linspace(xl, xr, ncells + 1)
# The length of each mesh cell (in units of m)
dx = (xr - xl)/ncells
# Step 7.10: Create the equilibrium solver object for the repeated equilibrium calculation
solver = EquilibriumSolver(system)
# Step 7.11: The auxiliary function to create an output file each time step
def outputstate():
# Create the instance of ChemicalOutput class
output = ChemicalOutput(system)
# The number of digits in number of steps (e.g., 100 has 3 digits)
ndigits = len(str(nsteps))
# Provide the output file name, which will correspond
output.filename('results/{}.txt'.format(str(step).zfill(ndigits)))
# We define the columns' tags filled with the name of the quantities
# The first column has a tag 'x' (which corresponds to the center coordinates of the cells )
output.add('tag', 'x') # The value of the center coordinates of the cells
# The rest of the columns correspond to the requested properties
for quantity in output_quantities:
output.add(quantity)
# We update the file with states that correspond to the cells' coordinates stored in x
output.open()
for state, tag in zip(states, x):
output.update(state, tag)
output.close()
# Step 7.12: Running the reactive transport simulation loop
step = 0 # the current step number
t = 0.0 # the current time (in seconds)
while step <= nsteps:
# Print the progress of the simulation
print("Progress: {}/{} steps, {} min".format(step, nsteps, t/minute))
# Output the current state of the reactive transport calculation
outputstate()
# Collect the amounts of elements from fluid and solid partitions
for icell in range(ncells):
bfluid[icell] = states[icell].elementAmountsInSpecies(ifluid_species)
bsolid[icell] = states[icell].elementAmountsInSpecies(isolid_species)
# Transport each element in the fluid phase
for j in range(nelems):
transport(bfluid[:, j], dt, dx, v, D, b_bc[j])
# Update the amounts of elements in both fluid and solid partitions
b[:] = bsolid + bfluid
# Equilibrating all cells with the updated element amounts
for icell in range(ncells):
solver.solve(states[icell], T, P, b[icell])
# Increment time step and number of time steps
t += dt
step += 1
print("Finished!")
We now explain step-by-step the function simulate
. We begin by defining the
chemical system using the ChemicalEditor class:
db = Database('supcrt98.xml')
editor = ChemicalEditor(db)
editor.addAqueousPhase([
'H2O(l)',
'H+',
'OH-',
'Na+',
'Cl-',
'Ca++',
'Mg++',
'HCO3-',
'CO2(aq)',
'CO3--']) # aqueous species are individually selected for performance reasons
editor.addMineralPhase('Quartz')
editor.addMineralPhase('Calcite')
editor.addMineralPhase('Dolomite')
We specify aqueous and mineral phases that should be considered in the chemical system. For performance reasons, the aqueous phase is defined by manually specifying the chemical species, instead of automatic collection of species from database. There are three pure mineral phase considered: quartz (SiO2), calcite (CaCO3), and dolomite (CaMg(CO3)2).
Creating the chemical system¶
This step is where we create an object of class ChemicalSystem using the
chemical system definition details stored in the object editor
.
system = ChemicalSystem(editor)
Initial condition (IC) of the reactive transport problem¶
After constructing the chemical system of interest, we can proceed to the definition of a chemical equilibrium problem to set the initial condition for the fluid and rock composition that is consistent with our intention of modeling reactive transport in a porous rock column composed of quartz and calcite, at 60 °C and 100 bar, with a 0.7 NaCl molal brine in equilibrium with the rock minerals. To ensure equilibrium with both quartz and calcite, the equilibrium problem setup below considers a relatively large amount for these minerals (10 moles each).
problem_ic = EquilibriumProblem(system)
problem_ic.setTemperature(T)
problem_ic.setPressure(P)
problem_ic.add('H2O', 1.0, 'kg')
problem_ic.add('NaCl', 0.7, 'mol')
problem_ic.add('CaCO3', 10, 'mol')
problem_ic.add('SiO2', 10, 'mol')
We will later scale the volumes of the aqueous and mineral phases so that they are consistent with a 10 % porosity and the required volume percentages of the rock minerals (98 %vol of quartz and 2 %vol of calcite).
Boundary condition (BC) of the reactive transport problem¶
For the boundary condition, we need to specify the composition of the fluid that is injected into the rock. This is done below, by defining an equilibrium problem that will later be solved to produce an aqueous fluid with 0.9 molal NaCl, 0.05 molal MgCl2, 0.01 CaCl2, and 0.75 molal CO2, in a state very close to CO2 saturation.
problem_bc = EquilibriumProblem(system)
problem_bc.setTemperature(T)
problem_bc.setPressure(P)
problem_bc.add('H2O', 1.0, 'kg')
problem_bc.add('NaCl', 0.90, 'mol')
problem_bc.add('MgCl2', 0.05, 'mol')
problem_bc.add('CaCl2', 0.01, 'mol')
problem_bc.add('CO2', 0.75, 'mol')
The composition of the injected aqueous fluid results from the solution of the above equilibrium problem, in which 1 kg of H2O is mixed with 0.90 moles of NaCl, 0.05 moles of MgCl2, 0.01 moles of CaCl2, and 0.75 moles of CO2.
Calculate the equilibrium states for the initial and boundary conditions¶
state_ic = equilibrate(problem_ic)
state_bc = equilibrate(problem_bc)
In this step, we use method equilibrate
to calculate the chemical
equilibrium state of the system with the given initial and boundary equilibrium
conditions stored in the objects problem_ic
and problem_bc
. The
numerical solution of each problem results in the objects state_ic
and state_bc
, respectively, of class ChemicalState, which stores the
temperature, pressure, and the amounts of every species in the system.
Scaling the volumes of the phases¶
We scale the volumes of the phases in the initial condition according to the condition that porosity is 10 % (ratio of fluid volume to total volume), and the volume fractions of minerals are 98 %vol of quartz (SiO2) and 2 %vol of calcite (ratio of mineral volume to solid volume).
For the chemical state representing the boundary condition for the injected fluid composition, we scale its volume to 1 m3. This is done so that the amounts of the species in the fluid is consistent with a mol/m3 scale.
state_ic.scalePhaseVolume('Aqueous', 0.1, 'm3')
state_ic.scalePhaseVolume('Quartz', 0.882, 'm3')
state_ic.scalePhaseVolume('Calcite', 0.018, 'm3')
# Scale the boundary condition state to 1 m3
state_bc.scaleVolume(1.0, 'm3')
Partitioning fluid and solid species¶
Only species in fluid phases are mobile and transported by advection and diffusion mechanisms. The solid phases are immobile.
The code below identify the indices of the fluid and solid species, and also create arrays to keep track of the amounts of elements in the fluid and solid partition (i.e., the amounts of elements among all fluid phases, here only an aqueous phase, and the amounts of elements among all solid phases, here the mineral phases).
# The number of elements in the chemical system
nelems = system.numElements()
# The indices of the fluid and solid species
ifluid_species = system.indicesFluidSpecies()
isolid_species = system.indicesSolidSpecies()
# The concentrations of each element in each mesh cell (in the current time step)
b = zeros((ncells, nelems))
# The concentrations (mol/m3) of each element in the fluid partition, in each mesh cell
bfluid = zeros((ncells, nelems))
# The concentrations (mol/m3) of each element in the solid partition, in each mesh cell
bsolid = zeros((ncells, nelems))
# Initialize the concentrations (mol/m3) of the elements in each mesh cell
b[:] = state_ic.elementAmounts()
# Initialize the concentrations (mol/m3) of each element on the boundary
b_bc = state_bc.elementAmounts()
We use methods indicesFluidSpecies
and indicesSolidSpecies
of class
ChemicalSystem to get the indices of the fluid and solid species, which are
then stored in the lists ifluid_species
and isolid_species
,
respectively. Then, we define the arrays b
, bfluid
, bsolid
, that
will store, respectively, the concentrations (mol/m3) of each element in the
system, in the fluid partition, and in the solid partition at every time step.
The array b
is initialized with the concentrations of the elements at the
initial chemical state, state_ic
, using method elementAmounts
of class
ChemicalState. The array b_bc
stores the concentrations of each element
on the boundary.
Create a list of chemical states for the mesh cells¶
Every mesh cell needs a ChemicalState object, which is done below with the
creation of a list states
of length ncells
, initialized with state_ic
for each cell (i.e., all cells have initially the same fluid and rock composition).
# The list of chemical states, one for each cell, initialized to state_ic
states = [state_ic.clone() for _ in range(ncells)]
Cell coordinates and lengths¶
Next, we generate the coordinates of the mesh nodes (array x
) by equally
dividing the interval [xr, xl] into ncells
. The length between each
consecutive mesh nodes is computed and stored in dx
(the length of the mesh
cells).
# The interval [xl, xr] split into ncells
x = linspace(xl, xr, ncells + 1)
# The length of each mesh cell (in units of m)
dx = (xr - xl)/ncells
Creating the equilibrium solver¶
For the repeated equilibrium calculation, we define an equilibrium solver object using EquilibriumSolver class.
solver = EquilibriumSolver(system)
Define an auxiliary function for creating the output file¶
The auxiliary function outputstate
uses the class ChemicalOutput to
output quantities and properties at each mesh cell, at every time step.
def outputstate():
# Create the instance of ChemicalOutput class
output = ChemicalOutput(system)
# The number of digits in number of steps (e.g., 100 has 3 digits)
ndigits = len(str(nsteps))
# Provide the output file name, which will correspond
output.filename('results/{}.txt'.format(str(step).zfill(ndigits)))
# We define the columns' tags filled with the name of the quantities
# The first column has a tag 'x' (which corresponds to the center coordinates of the cells )
output.add('tag', 'x') # The value of the center coordinates of the cells
# The rest of the columns correspond to the requested properties
for quantity in output_quantities:
output.add(quantity)
# We update the file with states that correspond to the cells' coordinates stored in x
output.open()
for state, tag in zip(states, x):
output.update(state, tag)
output.close()
Running the reactive transport simulation¶
Before proceeding to the reactive transport calculations, we set the initial time and a counter for the number of time steps.
step = 0 # the current step number
t = 0.0 # the current time (in seconds)
while step <= nsteps:
# Print the progress of the simulation
print("Progress: {}/{} steps, {} min".format(step, nsteps, t/minute))
# Output the current state of the reactive transport calculation
outputstate()
# Collect the amounts of elements from fluid and solid partitions
for icell in range(ncells):
bfluid[icell] = states[icell].elementAmountsInSpecies(ifluid_species)
bsolid[icell] = states[icell].elementAmountsInSpecies(isolid_species)
# Transport each element in the fluid phase
for j in range(nelems):
transport(bfluid[:, j], dt, dx, v, D, b_bc[j])
# Update the amounts of elements in both fluid and solid partitions
b[:] = bsolid + bfluid
# Equilibrating all cells with the updated element amounts
for icell in range(ncells):
solver.solve(states[icell], T, P, b[icell])
# Increment time step and number of time steps
t += dt
step += 1
print("Finished!")
The reactive transport simulation continues until the maximum number of time
steps is achieved. At each time step, we output the progress of the simulation
using function outputstate
. Then, we collect the amounts of elements from
fluid and solid partition. We now apply an operator splitting procedure, in
which we first update the amounts of elements in the fluid partition
(bfluid
) using the transport equations (without reactions). These updated
amounts of elements in the fluid partition are now summed with the amounts of
elements in the solid partition (bsolid
, which remained constant during the
transport step), and thus updating the amounts of elements in the chemical
system (b
). We now use these updated amounts of elements in the cell
(b
) to evaluate its new chemical equilibrium state, thus producing new
amounts of the species in both the fluid and solid phases (available in the
list states
of ChemicalState objects). This chemical reaction
equilibrium calculation step, at each mesh cell, permits, for example,
aqueous species and minerals to react, and thus causes mineral dissolution or
precipitation, depending on how much the amount of mineral species changes. This
can then be used, for example, to compute new porosity value for the cell.
Solving the transport equations¶
Reactive transport calculations involve the solution of a system of advection-diffusion-reaction equations. Due to the use of operator splitting scheme in this tutorial, we solve the transport equations and then perform the chemical reaction calculations for each cell before proceeding to the next time step.
The transport
function below is responsible for solving an
advection-diffusion equation, that is later applied to transport the
concentrations (mol/m3) of elements in the fluid partition (a simplification
that is possible because of common diffusion coefficients and velocities of the
fluid species, otherwise the transport of individual fluid species would be
needed).
def transport(u, dt, dx, v, D, g):
# Number of DOFs
n = len(u)
alpha = D*dt/dx**2
beta = v*dt/dx
a = full(n, -beta - alpha)
b = full(n, 1 + beta + 2*alpha)
c = full(n, -alpha)
# Set the boundary condition
if dirichlet:
# Use Dirichlet BC boundary conditions
b[0] = 1.0
c[0] = 0.0
u[0] = g
else:
# Use flux boundary conditions
u[0] += beta*g
b[0] = 1 + beta + alpha
b[-1] = 1 + beta + alpha
# Solve a tridiagonal matrix equation
thomas(a, b, c, u)
The function transport
expects a conservative property (argument u
)
(e.g., the concentration (mol/m3) of j-th element in the fluid given by
bfluid[j]
), the time step (dt
), the mesh cell length (dx
), the
fluid velocity (v
), the diffusion coefficient (D
), and the boundary
condition of the conservative property (g
) (e.g., the concentration of the
j-th element in the fluid on the left boundary).
The transport equations are solved with a finite volume method. Its
discretization in space and time (implicit) results in the constants alpha
and beta
. These correspond to the diffusion and advection terms in the
equation: D*dt/dx**2
and v*dt/dx
, respectively.
Arrays a
, b
, c
are the diagonals in the tridiagonal matrix that
results by writing all discretized equations in a matrix equation. This system
of linear equations is solved by the tridiagonal matrix algorithm, also known
as the Thomas algorithm:
def thomas(a, b, c, d):
n = len(d)
c[0] /= b[0]
for i in range(1, n - 1):
c[i] /= b[i] - a[i]*c[i - 1]
d[0] /= b[0]
for i in range(1, n):
d[i] = (d[i] - a[i]*d[i - 1])/(b[i] - a[i]*c[i - 1])
x = d
for i in reversed(range(0, n - 1)):
x[i] -= c[i]*x[i + 1]
return x
Plotting of the results¶
The last block of the main routine is plotting of the results.
def plot():
# Plot all result files
files = sorted(os.listdir('results'))
Parallel(n_jobs=16)(delayed(plotfile)(file) for file in files)
# Create videos for the figures
ffmpegstr = 'ffmpeg -y -r 30 -i figures/{0}/%03d.png -codec:v mpeg4 -flags:v +qscale -global_quality:v 0 videos/{0}.mp4'
os.system(ffmpegstr.format('calcite-dolomite'))
os.system(ffmpegstr.format('aqueous-species'))
os.system(ffmpegstr.format('ph'))
The full script for the reactive transport calculation is shown below.
from reaktoro import *
from numpy import *
import matplotlib.pyplot as plt
from joblib import Parallel, delayed
import os, time
# Step 2: Auxiliary time related constants
second = 1
minute = 60
hour = 60 * minute
day = 24 * hour
year = 365 * day
# Step 3: Parameters for the reactive transport simulation
xl = 0.0 # the x-coordinate of the left boundary
xr = 1.0 # the x-coordinate of the right boundary
nsteps = 100 # the number of steps in the reactive transport simulation
ncells = 100 # the number of cells in the discretization
D = 1.0e-9 # the diffusion coefficient (in units of m2/s)
v = 1.0/day # the fluid pore velocity (in units of m/s)
dt = 10*minute # the time step (in units of s)
T = 60.0 + 273.15 # the temperature (in units of K)
P = 100 * 1e5 # the pressure (in units of Pa)
dirichlet = False # the parameter that determines whether Dirichlet BC must be used
# Step 4: The list of quantities to be output for each mesh cell, each time step
output_quantities = """
pH
speciesMolality(H+)
speciesMolality(Ca++)
speciesMolality(Mg++)
speciesMolality(HCO3-)
speciesMolality(CO2(aq))
phaseVolume(Calcite)
phaseVolume(Dolomite)
""".split()
# Step 7: Perform the reactive transport simulation
def simulate():
# Step 7.1: Construct the chemical system with its phases and species
db = Database('supcrt98.xml')
editor = ChemicalEditor(db)
editor.addAqueousPhase([
'H2O(l)',
'H+',
'OH-',
'Na+',
'Cl-',
'Ca++',
'Mg++',
'HCO3-',
'CO2(aq)',
'CO3--']) # aqueous species are individually selected for performance reasons
editor.addMineralPhase('Quartz')
editor.addMineralPhase('Calcite')
editor.addMineralPhase('Dolomite')
# Step 7.2: Create the ChemicalSystem object using the configured editor
system = ChemicalSystem(editor)
# Step 7.3: Define the initial condition of the reactive transport modeling problem
problem_ic = EquilibriumProblem(system)
problem_ic.setTemperature(T)
problem_ic.setPressure(P)
problem_ic.add('H2O', 1.0, 'kg')
problem_ic.add('NaCl', 0.7, 'mol')
problem_ic.add('CaCO3', 10, 'mol')
problem_ic.add('SiO2', 10, 'mol')
# Step 7.4: Define the boundary condition of the reactive transport modeling problem
problem_bc = EquilibriumProblem(system)
problem_bc.setTemperature(T)
problem_bc.setPressure(P)
problem_bc.add('H2O', 1.0, 'kg')
problem_bc.add('NaCl', 0.90, 'mol')
problem_bc.add('MgCl2', 0.05, 'mol')
problem_bc.add('CaCl2', 0.01, 'mol')
problem_bc.add('CO2', 0.75, 'mol')
# Step 7.5: Calculate the equilibrium states for the initial and boundary conditions
state_ic = equilibrate(problem_ic)
state_bc = equilibrate(problem_bc)
# Step 7.6: Scale the volumes of the phases in the initial condition
state_ic.scalePhaseVolume('Aqueous', 0.1, 'm3')
state_ic.scalePhaseVolume('Quartz', 0.882, 'm3')
state_ic.scalePhaseVolume('Calcite', 0.018, 'm3')
# Scale the boundary condition state to 1 m3
state_bc.scaleVolume(1.0, 'm3')
# Step 7.7: Partitioning fluid and solid species
# The number of elements in the chemical system
nelems = system.numElements()
# The indices of the fluid and solid species
ifluid_species = system.indicesFluidSpecies()
isolid_species = system.indicesSolidSpecies()
# The concentrations of each element in each mesh cell (in the current time step)
b = zeros((ncells, nelems))
# The concentrations (mol/m3) of each element in the fluid partition, in each mesh cell
bfluid = zeros((ncells, nelems))
# The concentrations (mol/m3) of each element in the solid partition, in each mesh cell
bsolid = zeros((ncells, nelems))
# Initialize the concentrations (mol/m3) of the elements in each mesh cell
b[:] = state_ic.elementAmounts()
# Initialize the concentrations (mol/m3) of each element on the boundary
b_bc = state_bc.elementAmounts()
# Step 7.8: Create a list of chemical states for the mesh cells
# The list of chemical states, one for each cell, initialized to state_ic
states = [state_ic.clone() for _ in range(ncells)]
# Step 7.9: Create the equilibrium solver object for the repeated equilibrium calculation
# The interval [xl, xr] split into ncells
x = linspace(xl, xr, ncells + 1)
# The length of each mesh cell (in units of m)
dx = (xr - xl)/ncells
# Step 7.10: Create the equilibrium solver object for the repeated equilibrium calculation
solver = EquilibriumSolver(system)
# Step 7.11: The auxiliary function to create an output file each time step
def outputstate():
# Create the instance of ChemicalOutput class
output = ChemicalOutput(system)
# The number of digits in number of steps (e.g., 100 has 3 digits)
ndigits = len(str(nsteps))
# Provide the output file name, which will correspond
output.filename('results/{}.txt'.format(str(step).zfill(ndigits)))
# We define the columns' tags filled with the name of the quantities
# The first column has a tag 'x' (which corresponds to the center coordinates of the cells )
output.add('tag', 'x') # The value of the center coordinates of the cells
# The rest of the columns correspond to the requested properties
for quantity in output_quantities:
output.add(quantity)
# We update the file with states that correspond to the cells' coordinates stored in x
output.open()
for state, tag in zip(states, x):
output.update(state, tag)
output.close()
# Step 7.12: Running the reactive transport simulation loop
step = 0 # the current step number
t = 0.0 # the current time (in seconds)
while step <= nsteps:
# Print the progress of the simulation
print("Progress: {}/{} steps, {} min".format(step, nsteps, t/minute))
# Output the current state of the reactive transport calculation
outputstate()
# Collect the amounts of elements from fluid and solid partitions
for icell in range(ncells):
bfluid[icell] = states[icell].elementAmountsInSpecies(ifluid_species)
bsolid[icell] = states[icell].elementAmountsInSpecies(isolid_species)
# Transport each element in the fluid phase
for j in range(nelems):
transport(bfluid[:, j], dt, dx, v, D, b_bc[j])
# Update the amounts of elements in both fluid and solid partitions
b[:] = bsolid + bfluid
# Equilibrating all cells with the updated element amounts
for icell in range(ncells):
solver.solve(states[icell], T, P, b[icell])
# Increment time step and number of time steps
t += dt
step += 1
print("Finished!")
# Step 10: Return a string for the title of a figure in the format Time: #h##m
def titlestr(t):
t = t / minute # Convert from seconds to minutes
h = int(t) / 60 # The number of hours
m = int(t) % 60 # The number of remaining minutes
return 'Time: {:>3}h{:>2}m'.format(h, str(m).zfill(2))
# Step 9: Generate figures for a result file
def plotfile(file):
step = int(file.split('.')[0])
print('Plotting figure', step, '...')
t = step * dt
filearray = loadtxt('results/' + file, skiprows=1)
data = filearray.T
ndigits = len(str(nsteps))
plt.figure()
plt.xlim(left=-0.02, right=0.52)
plt.ylim(bottom=2.5, top=10.5)
plt.title(titlestr(t))
plt.xlabel('Distance [m]')
plt.ylabel('pH')
plt.plot(data[0], data[1])
plt.tight_layout()
plt.savefig('figures/ph/{}.png'.format(str(step).zfill(ndigits)))
plt.figure()
plt.xlim(left=-0.02, right=0.52)
plt.ylim(bottom=-0.1, top=2.1)
plt.ticklabel_format(style='sci', axis='y', scilimits=(0,0))
plt.title(titlestr(t))
plt.xlabel('Distance [m]')
plt.ylabel('Mineral Volume [%$_{\mathsf{vol}}$]')
plt.plot(data[0], data[7] * 100, label='Calcite')
plt.plot(data[0], data[8] * 100, label='Dolomite')
plt.legend(loc='center right')
plt.tight_layout()
plt.savefig('figures/calcite-dolomite/{}.png'.format(str(step).zfill(ndigits)))
plt.figure()
plt.yscale('log')
plt.xlim(left=-0.02, right=0.52)
plt.ylim(bottom=0.5e-5, top=2)
plt.title(titlestr(t))
plt.xlabel('Distance [m]')
plt.ylabel('Concentration [molal]')
plt.plot(data[0], data[3], label='Ca++')
plt.plot(data[0], data[4], label='Mg++')
plt.plot(data[0], data[5], label='HCO3-')
plt.plot(data[0], data[6], label='CO2(aq)')
plt.plot(data[0], data[2], label='H+')
plt.legend(loc='lower right')
plt.tight_layout()
plt.savefig('figures/aqueous-species/{}.png'.format(str(step).zfill(ndigits)))
plt.close('all')
# Step 8: Plot all result files and generate a video
def plot():
# Plot all result files
files = sorted(os.listdir('results'))
Parallel(n_jobs=16)(delayed(plotfile)(file) for file in files)
# Create videos for the figures
ffmpegstr = 'ffmpeg -y -r 30 -i figures/{0}/%03d.png -codec:v mpeg4 -flags:v +qscale -global_quality:v 0 videos/{0}.mp4'
os.system(ffmpegstr.format('calcite-dolomite'))
os.system(ffmpegstr.format('aqueous-species'))
os.system(ffmpegstr.format('ph'))
# Step 7.10.2: Solve a tridiagonal matrix equation using Thomas algorithm (or TriDiagonal Matrix Algorithm (TDMA))
def thomas(a, b, c, d):
n = len(d)
c[0] /= b[0]
for i in range(1, n - 1):
c[i] /= b[i] - a[i]*c[i - 1]
d[0] /= b[0]
for i in range(1, n):
d[i] = (d[i] - a[i]*d[i - 1])/(b[i] - a[i]*c[i - 1])
x = d
for i in reversed(range(0, n - 1)):
x[i] -= c[i]*x[i + 1]
return x
# Step 7.10.1: Perform a transport step
def transport(u, dt, dx, v, D, g):
# Number of DOFs
n = len(u)
alpha = D*dt/dx**2
beta = v*dt/dx
a = full(n, -beta - alpha)
b = full(n, 1 + beta + 2*alpha)
c = full(n, -alpha)
# Set the boundary condition
if dirichlet:
# Use Dirichlet BC boundary conditions
b[0] = 1.0
c[0] = 0.0
u[0] = g
else:
# Use flux boundary conditions
u[0] += beta*g
b[0] = 1 + beta + alpha
b[-1] = 1 + beta + alpha
# Solve a tridiagonal matrix equation
thomas(a, b, c, u)
# Step 6: Creating folders for the results' output
def make_results_folders():
os.system('mkdir -p results')
os.system('mkdir -p figures/ph')
os.system('mkdir -p figures/aqueous-species')
os.system('mkdir -p figures/calcite-dolomite')
os.system('mkdir -p videos')
# Step 5: Define the main function
if __name__ == '__main__':
# Create folders for the results
make_results_folders()
# Run the reactive transport simulations
simulate()
# Plotting the result
plot()
Todo
Add figures and videos here in this tutorial.
Have you got an issue?¶
Have you found any issue or error in this tutorial? Do you have any recommendations or you think something is not clear enough? Please, let us know by filling a new issue here:
You’ll need a GitHub account - but this is easy to sort out if you don’t have one yet!