Association

Two example molecules, each with multiple association sites

Site Interactions

Each unique site type per unique molecule is characterized by a numerical index siteid, which (for consistency with C++) starts with 0. In the example above, the indices would go like:

0: site type A on left molecule (multiplicity of 2)

1: site type B on left molecule (multiplicity of 1)

2: site type C on left molecule (multiplicity of 1)

3: site type A on right molecule (multiplicity of 1)

4: site type B on right molecule (multiplicity of 1)

Within a molecule, the numbering of sites is arbitrary, but the mapping cannot be changed once it is defined.

Association can occur when a site can “dock” with another kind of site. In the most common kind of association used to model hydrogen bonding, there are two classes of sites, positive or negative (e and H in Clapeyron.jl). teqp allows for great flexibility in defining the site types and how they are permitted to interact with each other.

The work of Langenbach and Enders (2012) shows how to construct a counting matrix to make the successive substitution faster because not all sites are included in the summation, rather the sites within a molecule are clustered into groups, since all sites of a similar type will have the same association fractions. Thus a counting matrix \(\mathbf{D}\) can be defined, with entries \(D_{IJ}\) for the pair of siteid I and J with the pseudocode

def get_DIJ(I, J):
    """ Return the value of an entry in the D_{IJ} matrix

    For a given unique site, look at all other sites on all other molecules
    """
    _, typei = inv_mapping[I]
    _, typej = inv_mapping[J]
    if typej in interaction_partners[typei]:
        return counts[J]
    return 0

in which the dictionary interaction_parameters defines which sites are allowed to interact with each other. The typical alcohol+water family would be modeled with:

interaction_parameters = {'e': ['H'], 'H': ['e']}

and to follow the system considered above, we would have:

inv_mapping = {
    0: (0, 'A'),
    1: (0, 'B'),
    2: (0, 'C'),
    3: (1, 'A'),
    4: (1, 'B')
}
counts = [2, 1, 1, 1, 1] # multiplicities for each siteid

The definition of the dictionary interaction_parameters would depend on how you want to allow the sites to associate. Sites that are not permitted to interact with each other are removed from the D matrix (are set to zero).

The successive substitution step gives the estimated values with

\[X_{\rm step} = \frac{1}{1+\rho_N \sum_Jx_JX_{J}D_{IJ}\Delta_{IJ}}\]

in which \(\rho_N\) is the number density (molecules per volume) of the entire mixture, \(\Delta_{IJ}\) is the interaction strength (volume per site) between site with siteid of I and that with siteid of J and \(x_{J}\) is the mole fraction of the molecule that site J is found in.

Acceleration can be achieved by taking only a partial step of successive substitution, weighted by \(\alpha\):

\[X_{\rm new} = \alpha X_{\rm old} + (1-\alpha)X_{\rm step}\]

This is the method utilized in Langenbach and Enders.

Simplified analysis for pure fluids

In the case that there is only one non-zero interaction strength, the mathematics can be greatly simplified. This section is based on the derivations of Pierre Walker. The general result for a pure fluid with N types of sites, which looks like One molecule, each with two association sites is as shown in Eq. A39 in Lafitte et al.:

\[X_{ai} = \dfrac{1}{1+\rho_{\rm N}\displaystyle\sum_{j=1}^nx_j\displaystyle\sum_{b=1}^{s_j}n_{b,j}X_{bj}\Delta_{abij}}\]

If you have a pure fluid that has two types of sites of arbitrary multiplicity, and no site-site self association (meaning that A cannot dock with A and B cannot dock with B), you can write out the law of mass-action as

\[X_A = \frac{1}{1+\rho_{\rm N}n_BX_B\Delta_{AB}}\]
\[X_B = \frac{1}{1+\rho_{\rm N}n_AX_A\Delta_{BA}}\]

and further assuming that \(\Delta=\Delta_{BA}=\Delta_{AB}\) and the simplification of \(\kappa_k=\rho_{\rm N}n_k\Delta\) yields

\[X_A = \frac{1}{1+\kappa_BX_B}\]
\[X_B = \frac{1}{1+\kappa_AX_A}\]

which can be solved simultaneously from a quadratic equation (see below)

Interaction strength

The interaction site strength is a matrix with side length of the number of siteid. It is a block matrix because practically speaking the interaction sites are still about molecule-molecule interactions

\[\Delta_{IJ} = gb_{IJ}\beta_{IJ}\left(\exp\left(\frac{\epsilon_{IJ}}{RT}\right)-1\right)/N_A\]

Reminder: \(b\), \(\beta\), and \(\epsilon\) values are associated with the molecule, not the site.

CR1 combining rule

In the CR1 combining rule:

\[b_{IJ} = b_{ij} = \frac{b_i + b_j}{2}\]
\[\beta_{IJ} = \beta_{ij} = \sqrt{\beta_i\beta_j}\]
\[\epsilon_{IJ} = \epsilon_{ij} = \frac{\epsilon_i + \epsilon_j}{2}\]

in which \(i\) is the molecule index associated with siteid \(I\) and the same for \(j\) and \(J\)

Radial distribution function

CS: \(g= \frac{2-\eta}{2(1-\eta)^2}\)

KG: \(g= \frac{1}{1 - 1.9\eta}\)

where \(\eta = b_{\rm mix}\rho/4\) in which \(\rho\) is density with units to match the reciprocal of \(b_{\rm mix}\) (so if \(b_{\rm mix}\) is mean covolume per atom, then \(\rho\) is the number density \(\rho_{\rm N}\))

References:

  1. Langenbach & S. Enders (2012): Cross-association of multi-component systems, Molecular Physics, 110:11-12, 1249-1260; https://dx.doi.org/10.1080/00268976.2012.668963

[1]:
# Here is the algebraic solutions to the laws of mass-action for the simplified cases
# covered in Huang & Radosz for the case of a pure fluid with two types
# of sites of arbitrary multiplicity
import sympy as sy

rhoN, Delta, kappa_B, kappa_A, X_A, X_B = sy.symbols('rho_N, Delta, kappa_B, kappa_A, X_A, X_B')

# Definitions of the equations to be solved
# In Eq(), the first arg is the LHS, second is RHS
eq1 = sy.Eq(X_B, 1/(1+kappa_A*X_A))
eq2 = sy.Eq(X_A, 1/(1+kappa_B*X_B))

# The solutions
solns = sy.solve([eq1, eq2], [X_A, X_B])
for soln in solns:
    for x in soln:
        display(x)

# 2B scheme; one site of type A, one site of type B, no A-A or B-B interactions
print('2B solutions:')
for soln in solns:
    print('X_A, X_B:')
    for x in soln:
        display(x.subs(kappa_A,rhoN*1*Delta).subs(kappa_B,rhoN*1*Delta))

# 3B scheme; one site of type A, two sites of type B, no A-A or B-B interactions
print('3B solutions:')
for soln in solns:
    print('X_A, X_B:')
    for x in soln:
        display(x.subs(kappa_A,rhoN*1*Delta).subs(kappa_B,rhoN*2*Delta))
$\displaystyle \frac{\kappa_{A} - \kappa_{B} - \sqrt{\kappa_{A}^{2} - 2 \kappa_{A} \kappa_{B} + 2 \kappa_{A} + \kappa_{B}^{2} + 2 \kappa_{B} + 1} - 1}{2 \kappa_{A}}$
$\displaystyle \frac{- \kappa_{A} + \kappa_{B} - \sqrt{\kappa_{A}^{2} - 2 \kappa_{A} \kappa_{B} + 2 \kappa_{A} + \kappa_{B}^{2} + 2 \kappa_{B} + 1} - 1}{2 \kappa_{B}}$
$\displaystyle \frac{\kappa_{A} - \kappa_{B} + \sqrt{\kappa_{A}^{2} - 2 \kappa_{A} \kappa_{B} + 2 \kappa_{A} + \kappa_{B}^{2} + 2 \kappa_{B} + 1} - 1}{2 \kappa_{A}}$
$\displaystyle \frac{- \kappa_{A} + \kappa_{B} + \sqrt{\kappa_{A}^{2} - 2 \kappa_{A} \kappa_{B} + 2 \kappa_{A} + \kappa_{B}^{2} + 2 \kappa_{B} + 1} - 1}{2 \kappa_{B}}$
2B solutions:
X_A, X_B:
$\displaystyle \frac{- \sqrt{4 \Delta \rho_{N} + 1} - 1}{2 \Delta \rho_{N}}$
$\displaystyle \frac{- \sqrt{4 \Delta \rho_{N} + 1} - 1}{2 \Delta \rho_{N}}$
X_A, X_B:
$\displaystyle \frac{\sqrt{4 \Delta \rho_{N} + 1} - 1}{2 \Delta \rho_{N}}$
$\displaystyle \frac{\sqrt{4 \Delta \rho_{N} + 1} - 1}{2 \Delta \rho_{N}}$
3B solutions:
X_A, X_B:
$\displaystyle \frac{- \Delta \rho_{N} - \sqrt{\Delta^{2} \rho_{N}^{2} + 6 \Delta \rho_{N} + 1} - 1}{2 \Delta \rho_{N}}$
$\displaystyle \frac{\Delta \rho_{N} - \sqrt{\Delta^{2} \rho_{N}^{2} + 6 \Delta \rho_{N} + 1} - 1}{4 \Delta \rho_{N}}$
X_A, X_B:
$\displaystyle \frac{- \Delta \rho_{N} + \sqrt{\Delta^{2} \rho_{N}^{2} + 6 \Delta \rho_{N} + 1} - 1}{2 \Delta \rho_{N}}$
$\displaystyle \frac{\Delta \rho_{N} + \sqrt{\Delta^{2} \rho_{N}^{2} + 6 \Delta \rho_{N} + 1} - 1}{4 \Delta \rho_{N}}$
[2]:
import teqp, numpy as np
[3]:
ethanol = {
    "a0i / Pa m^6/mol^2": 0.85164,
    "bi / m^3/mol": 0.0491e-3,
    "c1": 0.7502,
    "Tc / K": 513.92,
    "epsABi / J/mol": 21500.0,
    "betaABi": 0.008,
    "sites": ["e","H"]
}
water = {
    "a0i / Pa m^6/mol^2": 0.12277,
    "bi / m^3/mol": 0.0000145,
    "c1": 0.6736,
    "Tc / K": 647.13,
    "epsABi / J/mol": 16655.0,
    "betaABi": 0.0692,
    "sites": ["e","e","H","H"]
}

jCPA = {
    "cubic": "SRK",
    "radial_dist": "CS",
#     "combining": "CR1", # No other option is implemented yet
    "options": {"self_association_mask": [True, True]},
    "pures": [ethanol, water],
    "R_gas / J/mol/K": 8.31446261815324
}

model = teqp.make_model({"kind": "CPA", "model": jCPA, "validate": False}, False)
T = 303.15 # K
rhomolar = 1/3.0680691201961814e-5 # mol/m^3
molefracs = np.array([0.3, 0.7])

# Note: passing data back and forth in JSON format is done for convenience and flexibility, not speed
res = model.get_assoc_calcs(T, rhomolar, molefracs)

print('D:', np.array(res['D']))
print('∆:', np.array(res['Delta']))
print('X_A:', np.array(res['X_A']))
print('siteid->(component, name):', res['to_CompSite'])
print('(component, name)->siteid:', res['to_siteid'])
print('multiplicities:', np.array(res['counts']))
D: [[0 1 0 2]
 [1 0 2 0]
 [0 1 0 2]
 [1 0 2 0]]
∆: [[5.85623687e-27 5.85623687e-27 4.26510827e-27 4.26510827e-27]
 [5.85623687e-27 5.85623687e-27 4.26510827e-27 4.26510827e-27]
 [4.26510827e-27 4.26510827e-27 2.18581242e-27 2.18581242e-27]
 [4.26510827e-27 4.26510827e-27 2.18581242e-27 2.18581242e-27]]
X_A: [0.062584   0.062584   0.10938445 0.10938445]
siteid->(component, name): [[0, [0, 'H']], [1, [0, 'e']], [2, [1, 'H']], [3, [1, 'e']]]
(component, name)->siteid: [[[0, 'H'], 0], [[0, 'e'], 1], [[1, 'H'], 2], [[1, 'e'], 3]]
multiplicities: [1 1 2 2]
[4]:
# For completeness, here is the worked Python example that was used to develop the association implementation in teqp:

import collections
import numpy as np

class AssocClass:
    def __init__(self, molecules):

        # Get all the kinds of sites present
        mapping = {}
        counts = {}

        def sort_sites(sites):
            counts = collections.Counter(sites)
            out = []
            for k in ['B', 'P', 'N']:
                if k in counts:
                    out += [k]*counts[k]
            return out

        uid = 0
        for i, molecule in enumerate(molecules):
            for site in sort_sites(set(molecule)):
                mapping[(i, site)] = uid
                counts[uid] = molecule.count(site)
                uid += 1
        inv_mapping = {v:k for k,v in mapping.items()} # from superindex to (molecule, site pair)

        interaction_partners = {
                'B': ('N', 'P', 'B'),
                'N': ('P', 'B'),
                'P': ('N', 'B')
        }

        def get_DIJ(I, J):
            """ Return the value of an entry in the D_{IJ} matrix

            For a given unique site, look at all other sites on all other molecules
            """
            _, typei = inv_mapping[I]
            _, typej = inv_mapping[J]
            if typej in interaction_partners[typei]:
                return counts[J]
            return 0

        Ngroups = len(mapping)
        D = np.zeros((Ngroups, Ngroups), dtype=int)
        for I in range(Ngroups):
            for J in range(Ngroups):
                D[I, J] = get_DIJ(I, J)

        # Store variables needed for later use
        self.D = D
        self.counts = counts
        self.inv_mapping = inv_mapping
        self.Ngroups = Ngroups

        # ethanol, water
        self.b_Lmol = np.array([0.0491, 0.0145])
        self.epsilon_barLmol = np.array([215.00, 166.55])
        self.beta = [8e-3, 69.2e-3]

        self.b_m3mol = self.b_Lmol/1e3
        R = 8.31446261815324 # J/(mol*K)
        self.epsilon_K = self.epsilon_barLmol*100/R # K, from (bar*L)/mol * (1e5 Pa/bar) * (Pa / 1000 L), Pa*m^3 = J, then we divide by R to do [J/mol]/[J/mol/K] -> K

    def get_xJ(self, moleculemolefracs):
        """
        Return the fractions of sites within the mixture, not to be confused
        with the mole fractions of molecules within the mixture
        """
        counter = 0
        xJ = np.zeros((self.D.shape[0],))
        for J in range(self.D.shape[0]):
            j, sitej = self.inv_mapping[J] # molecule index and site name
            xJ[J] = self.counts[J]*moleculemolefracs[j]
            counter += xJ[J]
        return xJ/counter

    def get_bmix(self, molefracs):
        return (self.b_m3mol*molefracs).sum()

    def get_bij(self, i, j):
        """ CR1 """
        return (self.b_m3mol[i] + self.b_m3mol[j])/2

    def get_epsilon_k_IJ_CR1(self, *, i, j):
        """ CR1 """
        return (self.epsilon_K[i] + self.epsilon_K[j])/2

    def get_beta_IJ_CR1(self, *, i, j):
        """ CR1 """
        return (self.beta[i]*self.beta[j])**0.5

    def get_DeltaIJ(self, T, rhomolar, molefracs, *, i, j):
        b_ij = self.get_bij(i, j)
        bmix = self.get_bmix(molefracs)
        eta = bmix*rhomolar/4 # packing fraction
        g_ij = (2-eta)/(2*(1-eta)**3)
        beta = self.get_beta_IJ_CR1(i=i,j=j) # dimensionless
        eRT = self.get_epsilon_k_IJ_CR1(i=i,j=j)/T # epsilon/(R*T), dimensionless
        return g_ij*b_ij*beta*(np.exp(eRT)-1.0) # epsilon_k_IJ is in K, beta_IJ is dimensionless

    def get_Delta(self, T, rhomolar, *, molefracs, Ngroups):
        Delta = np.zeros((Ngroups, Ngroups))
        for I in range(Ngroups):
            i, _ = self.inv_mapping[I]
            for J in range(Ngroups):
                j, _ = self.inv_mapping[J]
                Delta[I, J] = self.get_DeltaIJ(T, rhomolar, i=i, j=j, molefracs=molefracs)
        return Delta

    def X_iter_Langenbach(self, T:float, rhomolar:float, molefracs, init):
        """Iterate with successive substitution to obtain the non-bonded fraction of each site

        Args:
            T (float): Temperature, K
            rhomolar (float): Molar density, mol/m^3
            molefracs (array): Mole fractions of the components
            init (array): Starting values for X_A

        Returns:
            array: non-bonded fractions for each site as one big array, indexed by site family

        TODO: why do we need mole fractions here and site fractions elsewhere?
        """
        # xJ = np.array(self.get_xJ(moleculemolefracs=molefracs), ndmin=2) # row vector

        XXJ = np.array([ molefracs[self.inv_mapping[J][0]] for J in range(self.Ngroups)])
        N_A = 6.02214076e23 # [1/mol]
        Delta = self.get_Delta(T, rhomolar, Ngroups=self.Ngroups, molefracs=molefracs)/N_A
        rhoN = rhomolar*N_A # number density, in 1/m^3
        Y = np.array(init[:], ndmin=2) # copy, row vector

        DD = self.D*Delta # coefficient-wise product
        DDX = XXJ*DD # coefficient-wise product

        for _ in range(30):
            # The naive treatment
            summer = 0.0
            for J in range(self.Ngroups):
                summer += Y[0,J]*XXJ[J]*self.D[:,J]*Delta[:,J]
            # Optimized treatment
            summer2 = (DDX@Y.T).squeeze()
            # print(summer, summer2)
            term = rhoN*summer2
            Y = 0.5*(Y+1/(1+term))

        return Y

    def X_A_pure_Langenbach(self, i:int, T:float, rhomolar:float):
        """Calculate the association fractions for a pure fluid
        based upon the method of Eq. 20, from
        Langenbach & Enders, Mol. Phys.
        URL: https://www.tandfonline.com/doi/abs/10.1080/00268976.2012.668963

        Args:
            int (int): Index of the pure fluid
            T (float): Temperature, K
            rhomolar (float): Molar density, mol/m^3
            molefracs (_type_): Molar fractions, array

        TODO: why do we need site fractions here and mole fractions elsewhere?
        """
        molefracs = [0]*len(self.b_m3mol)
        molefracs[i] = 1
        xJ = self.get_xJ(moleculemolefracs=molefracs)
        N_A = 6.02214076e23 # [1/mol]
        Delta = self.get_Delta(T, rhomolar, Ngroups=self.Ngroups, molefracs=molefracs)/N_A
        common = np.array(2*rhomolar*N_A*(xJ@self.D@Delta), ndmin=2).sum(axis=0)
        return (np.sqrt(1+2*common)-1)/common

    def X_A_pure_HuangRadosz(self, *, i:int, T:float, rhomolar:float, klass:str):
        """Use the explicit solutions from Huang and Radosz to obtain the
        association fraction for a pure fluid

        Args:
            i (int): The fluid index for which the method is being applied
            T (float): Temperature, K
            rhomolar (float): Molar density, in mol/m^3
            klass (str): Association class, one in {'2B','3B','4C'}

        Returns:
            float: value of X_A
        """

        b_ij = b_cubic = self.get_bij(i=i,j=i)
        betaABi = self.get_beta_IJ_CR1(i=i,j=i)
        R = 8.31446261815324
        RT = R*T
        epsABi = self.get_epsilon_k_IJ_CR1(i=i,j=i)*R # To get J/mol

        eta = b_ij*rhomolar/4 # packing fraction
        g_vm_ref = (2-eta)/(2*(1-eta)**3)
        DeltaAiBj = g_vm_ref*(np.exp(epsABi/RT) - 1.0)*b_cubic* betaABi

        if klass == '2B':
            X_A = (-1.0 + (1.0 + 4.0 * rhomolar * DeltaAiBj)**0.5) / (2.0 * rhomolar * DeltaAiBj)
        elif klass == '3B':
            X_A = ((-(1.0 - rhomolar * DeltaAiBj) + np.sqrt((1.0 + rhomolar * DeltaAiBj)**2 + 4.0 * rhomolar * DeltaAiBj)) / (4.0 * rhomolar * DeltaAiBj))
        elif klass == '4C':
            X_A = (-1.0 + np.sqrt(1.0 + 8.0 * rhomolar * DeltaAiBj)) / (4.0 * rhomolar * DeltaAiBj)

        return X_A

if __name__ == '__main__':
    a = AssocClass([('B'), ('P','N','N'), ('P')])
    assert(a.D.tolist() == [[1, 1, 2, 1], [1, 0, 2, 0], [1, 1, 0, 1], [1, 0, 2, 0]])

    #### 4C water
    a = AssocClass([(), ('P','P','N','N')])
    T = 303.15
    rhomolar = 1/1.7915123921401366e-5
    X_A_Clapeyron = 0.07920738195861185 # version 0.5.9
    X_A_HR = a.X_A_pure_HuangRadosz(i=1, T=T, rhomolar=rhomolar, klass='4C')
    X_A_La = a.X_A_pure_Langenbach(i=1, T=T, rhomolar=rhomolar)[0]
    assert(abs(X_A_HR - X_A_Clapeyron) < 1e-10)
    assert(abs(X_A_La - X_A_Clapeyron) < 1e-10)
    a.X_iter_Langenbach(T=T, rhomolar=rhomolar, molefracs=[0,1], init=np.array([1.0, 1.0]))

    ### 2B ethanol
    a = AssocClass([('P','N'), ()])
    T = 303.15
    rhomolar = 1/1.7915123921401366e-5
    X_A_Clapeyron = 0.020464699705843845 # version 0.5.9
    X_A_HR = a.X_A_pure_HuangRadosz(i=0, T=T, rhomolar=rhomolar, klass='2B')
    X_A_La = a.X_A_pure_Langenbach(i=0, T=T, rhomolar=rhomolar)[0]
    assert(abs(X_A_HR - X_A_Clapeyron) < 1e-10)
    assert(abs(X_A_La - X_A_Clapeyron) < 1e-10)
    a.X_iter_Langenbach(T=T, rhomolar=rhomolar, molefracs=[1,0], init=np.array([1.0, 1.0]))

    a = AssocClass([('P','N'), ('P','P','N','N')])
    T = 303.15
    print(a.D)
    rhomolar = 1/3.0680691201961814e-5
    print(a.get_Delta(T, rhomolar, molefracs=[0.3, 0.7], Ngroups=4)/6.02214076e23)
    print(a.X_iter_Langenbach(T=T, rhomolar=rhomolar, molefracs=[0.3,0.7], init=np.array([1.0, 1.0, 1, 1])))
[[0 1 0 2]
 [1 0 2 0]
 [0 1 0 2]
 [1 0 2 0]]
[[5.85623687e-27 5.85623687e-27 4.26510827e-27 4.26510827e-27]
 [5.85623687e-27 5.85623687e-27 4.26510827e-27 4.26510827e-27]
 [4.26510827e-27 4.26510827e-27 2.18581242e-27 2.18581242e-27]
 [4.26510827e-27 4.26510827e-27 2.18581242e-27 2.18581242e-27]]
[[0.062584   0.062584   0.10938445 0.10938445]]