Chemical equilibrium with custom constraints

Written by Allan Leal (ETH Zurich) on Jan 21th, 2022

Reaktoro already implements many different types of chemical equilibrium constraints. The EquilibriumSpecs class allows you to constrain the following properties in an equilibrium state:

  • the temperature of the system

  • the pressure of the system

  • the volume of the system

  • the internal energy of the system

  • the enthalpy of the system

  • the Gibbs energy of the system

  • the Helmholtz energy of the system

  • the entropy of the system

  • the electric charge of the system

  • the amount of an element in the system

  • the amount of an element in a phase

  • the mass of an element in the system

  • the mass of an element in a phase

  • the amount of a phase

  • the mass of a phase

  • the volume of a phase

  • the electric charge of a phase

  • the chemical potential of a species

  • the activity of a species

  • the fugacity of a gas

  • the pH of an aqueous phase

  • the pMg of an aqueous phase

  • the pE of an aqueous phase

  • the Eh of an aqueous phase

Tip

This list of properties that can be constrained may be more extensive now! For a more up-to-date list, check the API of class EquilibriumSpecs.

These options may not suffice to you. This tutorial will demonstrate how your own constraints can be defined and used by the EquilibriumSolver class to honor the conditions you want to see attained at an equilibrium state.

Before we proceed, we need to understand how equilibrium constraints are formulated in Reaktoro and used by its equilibrium solver. Let’s consider you want to impose a value for the enthalpy of the system, \(H_{\mathrm{desired}}\). A chemical state that honors all imposed equilibrium constraints and mass/charge conservation conditions is sought during the chemical equilibrium calculation. This happens iteratively, and in a given iteration of the algorithm, the enthalpy of the system, \(H_{\mathrm{current}}\), may still be different than that you want, \(H_{\mathrm{desired}}\). At some point, the algorithm will converge to a chemical state in which the equilibrium constraint residual satisfies:

\[|H_{\mathrm{current}}-H_{\mathrm{desired}}|<\epsilon_{\mathrm{tolerance}}\]

where \(\epsilon_{\mathrm{tolerance}}\) is a small positive tolerance value (e.g., 10-6).

We will learn in the next sections that equilibrium constraints in Reaktoro can be defined by writing residual expressions like the one above.

Tip

Reaktoro is not restricted to impose only the value of a property at equilibrium, such as shown in the previous example in which enthalpy is constrained. Because equilibrium constraints are formulated in terms of residual expressions, you can write whatever you want there, even expressions involving multiple properties!

Creating your own volume and internal energy constraints

In a previous example, we modeled the combustion of CH4 in a rigid and adiabatic chamber. We’ll repeat this example here. However, this time, we’ll define the volume and internal energy constraints from scratch. This example should be helpful for you to understand the fundamentals of defining functional constraints and attaching them to the equilibrium solver.

So, let’s start as usual with the definition of our chemical system and the creation of an initial chemical state for it (this will be identical to what we did here!).

from reaktoro import *

db = NasaDatabase("nasa-cea")

gases = GaseousPhase("CH4 O2 CO2 CO H2O H2")

system = ChemicalSystem(db, gases)

state0 = ChemicalState(system)
state0.temperature(25.0, "celsius")
state0.pressure(1.0, "bar")
state0.set("CH4", 0.75, "mol")
state0.set("O2",  0.25, "mol")
state0.scaleVolume(10.0, "cm3")

Let’s calculate the chemical properties of this system in this initial state and save the initial volume and internal energy in the variables V0 and U0 for later use:

props0 = ChemicalProps(state0)

V0 = props0.volume()
U0 = props0.internalEnergy()

Let’s now finally define our own constraints (from scratch!). Below is the complete sequence of steps with just a few comments. More details will be given later.

specs = EquilibriumSpecs(system)

idxV = specs.addInput("V")  # add "V" as the symbol for a new input condition to the equilibrium problem
idxU = specs.addInput("U")  # add "U" as the symbol for a new input condition to the equilibrium problem

volumeConstraint = ConstraintEquation()
volumeConstraint.id = "VolumeConstraint"  # give some identification name to the constraint (it's up to you how you call this)
volumeConstraint.fn = lambda props, w: props.volume() - w[idxV]  # the residual function defining V(current) - V(desired)

internalEnergyConstraint = ConstraintEquation()
internalEnergyConstraint.id = "InternalEnergyConstraint"  # give some identification name to the constraint (it's up to you how you call this)
internalEnergyConstraint.fn = lambda props, w: props.internalEnergy() - w[idxU]  # the residual function defining U(current) - U(desired)

specs.addConstraint(volumeConstraint)
specs.addConstraint(internalEnergyConstraint)

solver = EquilibriumSolver(specs)

As usual, we start with the creation of an EquilibriumSpecs object. Besides the code comments above, it’s worth commenting further that:

  • idxV and idxU are the indices of the newly added inputs to the equilibrium problem (namely V and U)

  • lambda props, w: props.volume() - w[idxV] is a lambda function defining our residual expression for the volume constraint: $\(V_\mathrm{current} - V_\mathrm{desired}\)$

  • lambda props, w: props.internalEnergy() - w[idxV] is a lambda function defining our residual expression for the internal energy constraint

  • props is the ChemicalProps object containing the current chemical properties of the system from where we get current volume and internal energy: $\(U_\mathrm{current} - U_\mathrm{desired}\)$

  • w is the array with the values of the inputs introduced in the equilibrium problem from where we get the desired volume and internal energy

  • w[idxV] and w[idxU] are the values of introduced inputs V and U (the desired values of volume and internal energy)

It’s now time to create our EquilibriumConditions object, where specify the values for the inputs V and U:

conditions = EquilibriumConditions(specs)
conditions.set("V", V0)  # remember the symbol "V" introduced before? you're using it here!
conditions.set("U", U0)  # remember the symbol "U" introduced before? you're using it here!

conditions.setLowerBoundTemperature(25.0, "celsius")
conditions.setUpperBoundTemperature(2000.0, "celsius")

conditions.setLowerBoundPressure(1.0, "bar")
conditions.setUpperBoundPressure(1000.0, "bar")

After this, we are ready to solve the chemical equilibrium problem that models the combustion of CH4 in a rigid and adiabatic chamber:

state = ChemicalState(state0)  # let's create a copy of the initial state

solver.solve(state, conditions)

print("FINAL STATE")
print(state)
FINAL STATE
+-----------------+------------+------+
| Property        |      Value | Unit |
+-----------------+------------+------+
| Temperature     |  1080.3867 |    K |
| Pressure        |     5.8362 |  bar |
| Charge:         | 0.0000e+00 |  mol |
| Element Amount: |            |      |
| :: H            | 1.2102e-03 |  mol |
| :: C            | 3.0255e-04 |  mol |
| :: O            | 2.0170e-04 |  mol |
| Species Amount: |            |      |
| :: CH4          | 1.2897e-04 |  mol |
| :: O2           | 1.0000e-16 |  mol |
| :: CO2          | 9.6874e-06 |  mol |
| :: CO           | 1.6389e-04 |  mol |
| :: H2O          | 1.8430e-05 |  mol |
| :: H2           | 3.2873e-04 |  mol |
+-----------------+------------+------+

That’s it! You may want to check if this computed equilibrium state is identical to that found in this tutorial, where we used predefined volume and internal energy constraints to solve the same problem.

Creating your own pH constraint

We’ve seen in this previous tutorial how to use the predefined equilibrium constraint for pH in class EquilibriumSpecs. We also saw that using that pH constraint causes the system to be open to H+. However, what if we want to fix the pH of an aqueous solution by titrating it with another substance?

We consider thus the following case. At 30 °C and 5 atm, how much CO2 must be titrated into an aqueous solution saturated with mineral calcite (CaCO3) to obtain pH 7? This problem can be solved by formulating a chemical equilibrium calculation in which temperature, pressure, and pH are constrained and that the system is open to the mass transfer of CO2.

To solve this problem, we start with the definition of a chemical system with the following phases:

  • an aqueous phase (with aqueous species that can be formed from elements H, O, Na, Cl, C, and Ca);

  • a gaseous phase (with only CO2(g) and H2O(g) species)

  • a solid phase (representing the mineral calcite)

We also set some activity models for the aqueous and gaseous phases (check this tutorial for more information about specifying activity models):

from reaktoro import *

db = PhreeqcDatabase("phreeqc.dat")

solution = AqueousPhase(speciate("H O Na Cl C Ca"))
solution.setActivityModel(chain(
    ActivityModelHKF(),
    ActivityModelDrummond("CO2")
))

gases = GaseousPhase("CO2(g) H2O(g)")
gases.setActivityModel(ActivityModelPengRobinson())

calcite = MineralPhase("Calcite")

system = ChemicalSystem(db, solution, gases, calcite)

Let’s now define a helper function that computes pH for given chemical properties of a system:

def pH(props: ChemicalProps):
    return -props.speciesActivityLg("H+")  # this results in -log10(a[H+]), which is pH

We’ll use this convenience function next (in the lambda function defining the residual expression), while creating our EquilibriumSpecs object in which we provide the specifications for the equilibrium problem we want to formulate.

Tip

The method call props.speciesActivityLg("H+") will be performed several times. If you would like to avoid an index search for H+ in every such call, you could do the following instead:

idxH = system.species().index("H+")
def pH(props: ChemicalProps):
    return -props.speciesActivityLg(idxH)

We want to perform a chemical equilibrium calculation in which the following properties are constrained:

  • temperature;

  • pressure; and

  • pH.

Moreover, our system is open to CO2. These specifications are all provided next in the EquilibriumSpecs object we create:

specs = EquilibriumSpecs(system)

specs.addInput("pH")  # add "pH" as the symbol for a new input condition to the equilibrium problem

pHConstraint = ConstraintEquation()
pHConstraint.id = "pHConstraint"  # give some identification name to the constraint (it's up to you how you call this)
pHConstraint.fn = lambda props, w: pH(props) - float(w)  # the residual function defining pH(current) - pH(desired)

specs.temperature()
specs.pressure()
specs.addConstraint(pHConstraint)
specs.openTo("CO2")

solver = EquilibriumSolver(specs)

We’ll now create an initial chemical state for the system representing a 1 molal NaCl aqueous solution mixed with a sufficient amount of calcite to saturate the fluid at 25 °C and 1 atm.

state = ChemicalState(system)
state.temperature(25.0, "celsius")
state.pressure(1.0, "atm")
state.set("H2O", 1.0, "kg")
state.set("Na+", 1.0, "mol")
state.set("Cl-", 1.0, "mol")
state.set("Calcite", 10.0, "mol")  # plenty of Calcite to ensure saturation levels!

Finally, we set the conditions of this system at the chemical equilibrium state of interest (using EquilibriumConditions) and then use EquilibriumSolver to compute this state of equilibrium:

conditions = EquilibriumConditions(specs)
conditions.temperature(30.0, "celsius")
conditions.pressure(5.0, "atm")
conditions.set("pH", 7.0)  # remember the symbol "pH" introduced before? you're using it here!

solver = EquilibriumSolver(specs)
solver.solve(state, conditions)

aprops = AqueousProps(state)
print(aprops)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [10], in <cell line: 7>()
      4 conditions.set("pH", 7.0)  # remember the symbol "pH" introduced before? you're using it here!
      6 solver = EquilibriumSolver(specs)
----> 7 solver.solve(state, conditions)
      9 aprops = AqueousProps(state)
     10 print(aprops)

Input In [8], in <lambda>(props, w)
      5 pHConstraint = ConstraintEquation()
      6 pHConstraint.id = "pHConstraint"  # give some identification name to the constraint (it's up to you how you call this)
----> 7 pHConstraint.fn = lambda props, w: pH(props) - float(w)  # the residual function defining pH(current) - pH(desired)
      9 specs.temperature()
     10 specs.pressure()

TypeError: float() argument must be a string or a number, not 'autodiff.autodiff4py.VectorXreal1stConstRef'

The aqueous properties displayed above should show that the prescribed temperature, pressure, and pH values are satisfied.

You may be wondering how much CO2 was transferred to the system for this pH value to be attained. We show this next:

tCO2 = float(state.equilibrium().explicitTitrantAmounts())  # the amount of CO2 titrated into the system
print("Amount of CO2 titrated into the system was", tCO2 * 1e+3, "mmol")
Amount of CO2 titrated into the system was 6.642933215740025 mmol

That’s it. You should now be able to adapt these tutorials and define the constraints you want (if not already covered in the EquilibriumSpecs class).