pyEQL 0.5.2__py3-none-any.whl → 1.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyEQL/__init__.py +50 -43
- pyEQL/activity_correction.py +481 -707
- pyEQL/database/geothermal.dat +5693 -0
- pyEQL/database/llnl.dat +19305 -0
- pyEQL/database/phreeqc_license.txt +54 -0
- pyEQL/database/pyeql_db.json +35902 -0
- pyEQL/engines.py +793 -0
- pyEQL/equilibrium.py +148 -228
- pyEQL/functions.py +121 -416
- pyEQL/pint_custom_units.txt +2 -2
- pyEQL/presets/Ringers lactate.yaml +20 -0
- pyEQL/presets/normal saline.yaml +17 -0
- pyEQL/presets/rainwater.yaml +17 -0
- pyEQL/presets/seawater.yaml +29 -0
- pyEQL/presets/urine.yaml +26 -0
- pyEQL/presets/wastewater.yaml +21 -0
- pyEQL/salt_ion_match.py +53 -284
- pyEQL/solute.py +126 -191
- pyEQL/solution.py +2163 -2090
- pyEQL/utils.py +211 -0
- pyEQL-1.0.3.dist-info/AUTHORS.md +13 -0
- {pyEQL-0.5.2.dist-info → pyEQL-1.0.3.dist-info}/COPYING +1 -1
- pyEQL-0.5.2.dist-info/LICENSE → pyEQL-1.0.3.dist-info/LICENSE.txt +3 -7
- pyEQL-1.0.3.dist-info/METADATA +131 -0
- pyEQL-1.0.3.dist-info/RECORD +27 -0
- {pyEQL-0.5.2.dist-info → pyEQL-1.0.3.dist-info}/WHEEL +1 -1
- pyEQL/chemical_formula.py +0 -1006
- pyEQL/database/Erying_viscosity.tsv +0 -18
- pyEQL/database/Jones_Dole_B.tsv +0 -32
- pyEQL/database/Jones_Dole_B_inorganic_Jenkins.tsv +0 -75
- pyEQL/database/LICENSE +0 -4
- pyEQL/database/dielectric_parameter.tsv +0 -30
- pyEQL/database/diffusion_coefficient.tsv +0 -116
- pyEQL/database/hydrated_radius.tsv +0 -35
- pyEQL/database/ionic_radius.tsv +0 -35
- pyEQL/database/partial_molar_volume.tsv +0 -22
- pyEQL/database/pitzer_activity.tsv +0 -169
- pyEQL/database/pitzer_volume.tsv +0 -132
- pyEQL/database/template.tsv +0 -14
- pyEQL/database.py +0 -300
- pyEQL/elements.py +0 -4552
- pyEQL/logging_system.py +0 -53
- pyEQL/parameter.py +0 -435
- pyEQL/tests/__init__.py +0 -32
- pyEQL/tests/test_activity.py +0 -578
- pyEQL/tests/test_bulk_properties.py +0 -86
- pyEQL/tests/test_chemical_formula.py +0 -279
- pyEQL/tests/test_debye_length.py +0 -79
- pyEQL/tests/test_density.py +0 -106
- pyEQL/tests/test_dielectric.py +0 -153
- pyEQL/tests/test_effective_pitzer.py +0 -276
- pyEQL/tests/test_mixed_electrolyte_activity.py +0 -154
- pyEQL/tests/test_osmotic_coeff.py +0 -99
- pyEQL/tests/test_pyeql_volume_concentration.py +0 -428
- pyEQL/tests/test_salt_matching.py +0 -337
- pyEQL/tests/test_solute_properties.py +0 -251
- pyEQL/water_properties.py +0 -352
- pyEQL-0.5.2.dist-info/AUTHORS +0 -7
- pyEQL-0.5.2.dist-info/METADATA +0 -72
- pyEQL-0.5.2.dist-info/RECORD +0 -47
- pyEQL-0.5.2.dist-info/entry_points.txt +0 -3
- {pyEQL-0.5.2.dist-info → pyEQL-1.0.3.dist-info}/top_level.txt +0 -0
pyEQL/solution.py
CHANGED
|
@@ -1,955 +1,1315 @@
|
|
|
1
1
|
"""
|
|
2
|
-
pyEQL Solution Class
|
|
2
|
+
pyEQL Solution Class.
|
|
3
3
|
|
|
4
|
-
:copyright: 2013-
|
|
4
|
+
:copyright: 2013-2024 by Ryan S. Kingsbury
|
|
5
5
|
:license: LGPL, see LICENSE for more details.
|
|
6
6
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
# import libraries for scientific functions
|
|
11
|
-
import math
|
|
12
|
-
from pint import UndefinedUnitError
|
|
9
|
+
from __future__ import annotations
|
|
13
10
|
|
|
14
|
-
# internal pyEQL imports
|
|
15
|
-
import pyEQL.activity_correction as ac
|
|
16
|
-
import pyEQL.water_properties as h2o
|
|
17
|
-
import pyEQL.solute as sol
|
|
18
|
-
import pyEQL.salt_ion_match as salt
|
|
19
|
-
|
|
20
|
-
# the pint unit registry
|
|
21
|
-
from pyEQL import unit
|
|
22
|
-
|
|
23
|
-
# import the parameters database
|
|
24
|
-
from pyEQL import paramsDB as db
|
|
25
|
-
|
|
26
|
-
# add a filter to emit only unique log messages to the handler
|
|
27
|
-
from pyEQL.logging_system import Unique
|
|
28
|
-
|
|
29
|
-
# logging system
|
|
30
11
|
import logging
|
|
12
|
+
import os
|
|
13
|
+
import warnings
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from importlib.resources import files
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from maggma.stores import JSONStore, Store
|
|
21
|
+
from monty.dev import deprecated
|
|
22
|
+
from monty.json import MontyDecoder, MSONable
|
|
23
|
+
from monty.serialization import dumpfn, loadfn
|
|
24
|
+
from pint import DimensionalityError, Quantity
|
|
25
|
+
from pymatgen.core import Element
|
|
26
|
+
from pymatgen.core.ion import Ion
|
|
27
|
+
|
|
28
|
+
from pyEQL import IonDB, ureg
|
|
29
|
+
from pyEQL.activity_correction import _debye_parameter_activity, _debye_parameter_B
|
|
30
|
+
from pyEQL.engines import EOS, IdealEOS, NativeEOS, PhreeqcEOS
|
|
31
|
+
from pyEQL.salt_ion_match import Salt
|
|
32
|
+
from pyEQL.solute import Solute
|
|
33
|
+
from pyEQL.utils import FormulaDict, create_water_substance, interpret_units, standardize_formula
|
|
34
|
+
|
|
35
|
+
EQUIV_WT_CACO3 = ureg.Quantity(100.09 / 2, "g/mol")
|
|
36
|
+
# string to denote unknown oxidation states
|
|
37
|
+
UNKNOWN_OXI_STATE = "unk"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Solution(MSONable):
|
|
41
|
+
"""
|
|
42
|
+
Class representing the properties of a solution. Instances of this class
|
|
43
|
+
contain information about the solutes, solvent, and bulk properties.
|
|
44
|
+
"""
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
solutes: list[list[str]] | dict[str, str] | None = None,
|
|
49
|
+
volume: str | None = None,
|
|
50
|
+
temperature: str = "298.15 K",
|
|
51
|
+
pressure: str = "1 atm",
|
|
52
|
+
pH: float = 7,
|
|
53
|
+
pE: float = 8.5,
|
|
54
|
+
balance_charge: str | None = None,
|
|
55
|
+
solvent: str | list = "H2O",
|
|
56
|
+
engine: EOS | Literal["native", "ideal", "phreeqc"] = "native",
|
|
57
|
+
database: str | Path | Store | None = None,
|
|
58
|
+
default_diffusion_coeff: float = 1.6106e-9,
|
|
59
|
+
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = "ERROR",
|
|
60
|
+
) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Instantiate a Solution from a composition.
|
|
39
63
|
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
Args:
|
|
65
|
+
solutes: dict, optional. Keys must be the chemical formula, while values must be
|
|
66
|
+
str Quantity representing the amount. For example:
|
|
42
67
|
|
|
43
|
-
|
|
44
|
-
ch.setFormatter(formatter)
|
|
45
|
-
logger.addHandler(ch)
|
|
68
|
+
{"Na+": "0.1 mol/L", "Cl-": "0.1 mol/L"}
|
|
46
69
|
|
|
70
|
+
Note that an older "list of lists" syntax is also supported; however this
|
|
71
|
+
will be deprecated in the future and is no longer recommended. The equivalent
|
|
72
|
+
list syntax for the above example is
|
|
47
73
|
|
|
48
|
-
|
|
49
|
-
"""
|
|
50
|
-
Class representing the properties of a solution. Instances of this class
|
|
51
|
-
contain information about the solutes, solvent, and bulk properties.
|
|
74
|
+
[["Na+", "0.1 mol/L"], ["Cl-", "0.1 mol/L"]]
|
|
52
75
|
|
|
53
|
-
Parameters
|
|
54
|
-
----------
|
|
55
|
-
solutes : list of lists, optional
|
|
56
|
-
See add_solute() documentation for formatting of this list.
|
|
57
76
|
Defaults to empty (pure solvent) if omitted
|
|
58
|
-
|
|
77
|
+
volume: str, optional
|
|
59
78
|
Volume of the solvent, including the unit. Defaults to '1 L' if omitted.
|
|
60
79
|
Note that the total solution volume will be computed using partial molar
|
|
61
80
|
volumes of the respective solutes as they are added to the solution.
|
|
62
|
-
|
|
63
|
-
The solution temperature, including the
|
|
64
|
-
|
|
81
|
+
temperature: str, optional
|
|
82
|
+
The solution temperature, including the ureg. Defaults to '25 degC' if omitted.
|
|
83
|
+
pressure: Quantity, optional
|
|
65
84
|
The ambient pressure of the solution, including the unit.
|
|
66
85
|
Defaults to '1 atm' if omitted.
|
|
67
|
-
|
|
86
|
+
pH: number, optional
|
|
68
87
|
Negative log of H+ activity. If omitted, the solution will be
|
|
69
88
|
initialized to pH 7 (neutral) with appropriate quantities of
|
|
70
89
|
H+ and OH- ions
|
|
90
|
+
pE: the pE value (redox potential) of the solution. Lower values = more reducing,
|
|
91
|
+
higher values = more oxidizing. At pH 7, water is stable between approximately
|
|
92
|
+
-7 to +14. The default value corresponds to a pE value typical of natural
|
|
93
|
+
waters in equilibrium with the atmosphere.
|
|
94
|
+
balance_charge: The strategy for balancing charge during init and equilibrium calculations. Valid options
|
|
95
|
+
are 'pH', which will adjust the solution pH to balance charge, 'pE' which will adjust the
|
|
96
|
+
redox equilibrium to balance charge, or the name of a dissolved species e.g. 'Ca+2' or 'Cl-'
|
|
97
|
+
that will be added/subtracted to balance charge. If set to None, no charge balancing will be
|
|
98
|
+
performed either on init or when equilibrate() is called. Note that in this case, equilibrate()
|
|
99
|
+
can distort the charge balance!
|
|
100
|
+
solvent: Formula of the solvent. Solvents other than water are not supported at this time.
|
|
101
|
+
engine: Electrolyte modeling engine to use. See documentation for details on the available engines.
|
|
102
|
+
database: path to a .json file (str or Path) or maggma Store instance that
|
|
103
|
+
contains serialized SoluteDocs. `None` (default) will use the built-in pyEQL database.
|
|
104
|
+
log_level: Log messages of this or higher severity will be printed to stdout. Defaults to 'ERROR', meaning
|
|
105
|
+
that ERROR and CRITICAL messages will be shown, while WARNING, INFO, and DEBUG messages are not. If set to None, nothing will be printed.
|
|
106
|
+
default_diffusion_coeff: Diffusion coefficient value in m^2/s to use in
|
|
107
|
+
calculations when there is no diffusion coefficient for a species in the database. This affects several
|
|
108
|
+
important property calculations including conductivity and transport number, which are related to the
|
|
109
|
+
weighted sums of diffusion coefficients of all species. Setting this argument to zero will exclude any
|
|
110
|
+
species that does not have a tabulated diffusion coefficient from these calculations, possibly resulting
|
|
111
|
+
in underestimation of the conductivity and/or inaccurate transport numbers.
|
|
112
|
+
|
|
113
|
+
Missing diffusion coefficients are especially likely in complex electrolytes containing, for example,
|
|
114
|
+
complexes or paired species such as NaSO4[-1]. In such cases, setting default_diffusion_coeff to zero
|
|
115
|
+
is likely to result in the above errors.
|
|
116
|
+
|
|
117
|
+
By default, this argument is set to the diffusion coefficient of NaCl salt, 1.61x10^-9 m2/s.
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
>>> s1 = pyEQL.Solution({'Na+': '1 mol/L','Cl-': '1 mol/L'},temperature='20 degC',volume='500 mL')
|
|
121
|
+
>>> print(s1)
|
|
122
|
+
Components:
|
|
123
|
+
Volume: 0.500 l
|
|
124
|
+
Pressure: 1.000 atm
|
|
125
|
+
Temperature: 293.150 K
|
|
126
|
+
Components: ['H2O(aq)', 'H[+1]', 'OH[-1]', 'Na[+1]', 'Cl[-1]']
|
|
127
|
+
"""
|
|
128
|
+
# create a logger and attach it to this class
|
|
129
|
+
self.log_level = log_level.upper()
|
|
130
|
+
self.logger = logging.getLogger("pyEQL")
|
|
131
|
+
if self.log_level is not None:
|
|
132
|
+
# set the level of the module logger
|
|
133
|
+
self.logger.setLevel(self.log_level)
|
|
134
|
+
# clear handlers and add a StreamHandler
|
|
135
|
+
self.logger.handlers.clear()
|
|
136
|
+
# use rich for pretty log formatting, if installed
|
|
137
|
+
try:
|
|
138
|
+
from rich.logging import RichHandler
|
|
139
|
+
|
|
140
|
+
sh = RichHandler(rich_tracebacks=True)
|
|
141
|
+
except ImportError:
|
|
142
|
+
sh = logging.StreamHandler()
|
|
143
|
+
# the formatter determines what our logs will look like
|
|
144
|
+
formatter = logging.Formatter("[%(asctime)s] [%(levelname)8s] --- %(message)s (%(filename)s:%(lineno)d)")
|
|
145
|
+
sh.setFormatter(formatter)
|
|
146
|
+
self.logger.addHandler(sh)
|
|
147
|
+
|
|
148
|
+
# per-instance cache of get_property and other calls that do not depend
|
|
149
|
+
# on composition
|
|
150
|
+
# see https://rednafi.com/python/lru_cache_on_methods/
|
|
151
|
+
self.get_property = lru_cache()(self._get_property)
|
|
152
|
+
self.get_molar_conductivity = lru_cache()(self._get_molar_conductivity)
|
|
153
|
+
self.get_mobility = lru_cache()(self._get_mobility)
|
|
154
|
+
self.default_diffusion_coeff = default_diffusion_coeff
|
|
155
|
+
self.get_diffusion_coefficient = lru_cache()(self._get_diffusion_coefficient)
|
|
71
156
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
Solution
|
|
75
|
-
A Solution object.
|
|
76
|
-
|
|
77
|
-
Examples
|
|
78
|
-
--------
|
|
79
|
-
>>> s1 = pyEQL.Solution([['Na+','1 mol/L'],['Cl-','1 mol/L']],temperature='20 degC',volume='500 mL')
|
|
80
|
-
>>> print(s1)
|
|
81
|
-
Components:
|
|
82
|
-
['H2O', 'Cl-', 'H+', 'OH-', 'Na+']
|
|
83
|
-
Volume: 0.5 l
|
|
84
|
-
Density: 1.0383030844030992 kg/l
|
|
85
|
-
|
|
86
|
-
See Also
|
|
87
|
-
--------
|
|
88
|
-
add_solute
|
|
89
|
-
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
def __init__(self, solutes=[], **kwargs):
|
|
157
|
+
# initialize the volume recalculation flag
|
|
158
|
+
self.volume_update_required = False
|
|
93
159
|
|
|
94
|
-
# initialize the volume
|
|
95
|
-
if
|
|
96
|
-
volume_set = True
|
|
97
|
-
self.
|
|
160
|
+
# initialize the volume with a flag to distinguish user-specified volume
|
|
161
|
+
if volume is not None:
|
|
162
|
+
# volume_set = True
|
|
163
|
+
self._volume = ureg.Quantity(volume).to("L")
|
|
98
164
|
else:
|
|
99
|
-
volume_set = False
|
|
100
|
-
self.
|
|
101
|
-
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
165
|
+
# volume_set = False
|
|
166
|
+
self._volume = ureg.Quantity(1, "L")
|
|
167
|
+
# store the initial conditions as private variables in case they are
|
|
168
|
+
# changed later
|
|
169
|
+
self._temperature = ureg.Quantity(temperature)
|
|
170
|
+
self._pressure = ureg.Quantity(pressure)
|
|
171
|
+
self._pE = pE
|
|
172
|
+
self._pH = pH
|
|
173
|
+
self.pE = self._pE
|
|
174
|
+
if isinstance(balance_charge, str) and balance_charge not in ["pH", "pE"]:
|
|
175
|
+
self.balance_charge = standardize_formula(balance_charge)
|
|
105
176
|
else:
|
|
106
|
-
self.
|
|
107
|
-
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
177
|
+
self.balance_charge = balance_charge #: Standardized formula of the species used for charge balancing.
|
|
178
|
+
|
|
179
|
+
# instantiate a water substance for property retrieval
|
|
180
|
+
self.water_substance = create_water_substance(self.temperature, self.pressure)
|
|
181
|
+
"""IAPWS instance describing water properties."""
|
|
182
|
+
|
|
183
|
+
# create an empty dictionary of components. This dict comprises {formula: moles}
|
|
184
|
+
# where moles is the number of moles in the solution.
|
|
185
|
+
self.components = FormulaDict({})
|
|
186
|
+
"""Special dictionary where keys are standardized formula and values are the moles present in Solution."""
|
|
187
|
+
|
|
188
|
+
# connect to the desired property database
|
|
189
|
+
if database is None:
|
|
190
|
+
# load the default database, which is a JSONStore
|
|
191
|
+
db_store = IonDB
|
|
192
|
+
elif isinstance(database, (str, Path)):
|
|
193
|
+
db_store = JSONStore(str(database), key="formula")
|
|
194
|
+
self.logger.debug(f"Created maggma JSONStore from .json file {database}")
|
|
111
195
|
else:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
self.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
#
|
|
121
|
-
if
|
|
122
|
-
self.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
# raise an error if the solvent volume has also been given
|
|
130
|
-
if volume_set is True:
|
|
131
|
-
logger.error(
|
|
132
|
-
"Solvent volume and mass cannot both be specified. Calculating volume based on solvent mass."
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
# add the solvent and the mass
|
|
136
|
-
self.add_solvent(self.solvent_name, kwargs["solvent"][1])
|
|
196
|
+
db_store = database
|
|
197
|
+
self.database = db_store
|
|
198
|
+
"""`Store` instance containing the solute property database."""
|
|
199
|
+
self.database.connect()
|
|
200
|
+
self.logger.debug(f"Connected to property database {self.database!s}")
|
|
201
|
+
|
|
202
|
+
# set the equation of state engine
|
|
203
|
+
self._engine = engine
|
|
204
|
+
# self.engine: Optional[EOS] = None
|
|
205
|
+
if isinstance(self._engine, EOS):
|
|
206
|
+
self.engine: EOS = self._engine
|
|
207
|
+
elif self._engine == "ideal":
|
|
208
|
+
self.engine = IdealEOS()
|
|
209
|
+
elif self._engine == "native":
|
|
210
|
+
self.engine = NativeEOS()
|
|
211
|
+
elif self._engine == "phreeqc":
|
|
212
|
+
self.engine = PhreeqcEOS()
|
|
137
213
|
else:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)
|
|
214
|
+
raise ValueError(f'{engine} is not a valid value for the "engine" kwarg!')
|
|
215
|
+
|
|
216
|
+
# define the solvent. Allow for list input to support future use of mixed solvents
|
|
217
|
+
if not isinstance(solvent, list):
|
|
218
|
+
solvent = [solvent]
|
|
219
|
+
if len(solvent) > 1:
|
|
220
|
+
raise ValueError("Multiple solvents are not yet supported!")
|
|
221
|
+
if solvent[0] not in ["H2O", "H2O(aq)", "water", "Water", "HOH"]:
|
|
222
|
+
raise ValueError("Non-aqueous solvent detected. These are not yet supported!")
|
|
223
|
+
self.solvent = standardize_formula(solvent[0])
|
|
224
|
+
"""Formula of the component that is set as the solvent (currently only H2O(aq) is supported)."""
|
|
225
|
+
|
|
226
|
+
# TODO - do I need the ability to specify the solvent mass?
|
|
227
|
+
# # raise an error if the solvent volume has also been given
|
|
228
|
+
# if volume_set is True:
|
|
229
|
+
# self.logger.error(
|
|
230
|
+
# "Solvent volume and mass cannot both be specified. Calculating volume based on solvent mass."
|
|
231
|
+
# )
|
|
232
|
+
# # add the solvent and the mass
|
|
233
|
+
# self.add_solvent(self.solvent, kwargs["solvent"][1])
|
|
234
|
+
|
|
235
|
+
# calculate the moles of solvent (water) on the density and the solution volume
|
|
236
|
+
moles = self.volume.magnitude / 55.55 # molarity of pure water
|
|
237
|
+
self.components["H2O"] = moles
|
|
145
238
|
|
|
146
239
|
# set the pH with H+ and OH-
|
|
147
|
-
if "pH" in kwargs:
|
|
148
|
-
pH = kwargs["pH"]
|
|
149
|
-
else:
|
|
150
|
-
pH = 7
|
|
151
|
-
|
|
152
240
|
self.add_solute("H+", str(10 ** (-1 * pH)) + "mol/L")
|
|
153
241
|
self.add_solute("OH-", str(10 ** (-1 * (14 - pH))) + "mol/L")
|
|
154
242
|
|
|
155
243
|
# populate the other solutes
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
Chemical formula for the solute.
|
|
167
|
-
Charged species must contain a + or - and (for polyvalent solutes) a number representing the net
|
|
168
|
-
charge (e.g. 'SO4-2').
|
|
169
|
-
amount : str
|
|
170
|
-
The amount of substance in the specified unit system. The string should contain both a quantity and
|
|
171
|
-
a pint-compatible representation of a unit. e.g. '5 mol/kg' or '0.1 g/L'
|
|
172
|
-
parameters : dictionary, optional
|
|
173
|
-
Dictionary of custom parameters, such as diffusion coefficients, transport numbers, etc. Specify
|
|
174
|
-
parameters as key:value pairs separated by commas within curly braces, e.g. {diffusion_coeff:5e-10,
|
|
175
|
-
transport_number:0.8}. The 'key' is the name that will be used to access the parameter, the value
|
|
176
|
-
is its value.
|
|
177
|
-
|
|
178
|
-
"""
|
|
179
|
-
|
|
180
|
-
# if units are given on a per-volume basis,
|
|
181
|
-
# iteratively solve for the amount of solute that will preserve the
|
|
182
|
-
# original volume and result in the desired concentration
|
|
183
|
-
if unit(amount).dimensionality in (
|
|
184
|
-
"[substance]/[length]**3",
|
|
185
|
-
"[mass]/[length]**3",
|
|
186
|
-
):
|
|
187
|
-
|
|
188
|
-
# store the original volume for later
|
|
189
|
-
orig_volume = self.get_volume()
|
|
190
|
-
|
|
191
|
-
# add the new solute
|
|
192
|
-
new_solute = sol.Solute(
|
|
193
|
-
formula, amount, self.get_volume(), self.get_solvent_mass(), parameters
|
|
244
|
+
self._solutes = solutes
|
|
245
|
+
if self._solutes is None:
|
|
246
|
+
self._solutes = {}
|
|
247
|
+
if isinstance(self._solutes, dict):
|
|
248
|
+
for k, v in self._solutes.items():
|
|
249
|
+
self.add_solute(k, v)
|
|
250
|
+
elif isinstance(self._solutes, list):
|
|
251
|
+
msg = (
|
|
252
|
+
'List input of solutes (e.g., [["Na+", "0.5 mol/L]]) is deprecated! Use dictionary formatted input '
|
|
253
|
+
'(e.g., {"Na+":"0.5 mol/L"} instead.)'
|
|
194
254
|
)
|
|
195
|
-
self.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
target_mol = target_mass / mw
|
|
207
|
-
self.get_solvent().moles = target_mol
|
|
208
|
-
|
|
209
|
-
else:
|
|
210
|
-
|
|
211
|
-
# add the new solute
|
|
212
|
-
new_solute = sol.Solute(
|
|
213
|
-
formula, amount, self.get_volume(), self.get_solvent_mass(), parameters
|
|
255
|
+
self.logger.warning(msg)
|
|
256
|
+
warnings.warn(msg, DeprecationWarning)
|
|
257
|
+
for item in self._solutes:
|
|
258
|
+
self.add_solute(*item)
|
|
259
|
+
|
|
260
|
+
# adjust the charge balance, if necessary
|
|
261
|
+
cb = self.charge_balance
|
|
262
|
+
if not np.isclose(cb, 0, atol=1e-8) and self.balance_charge is not None:
|
|
263
|
+
balanced = False
|
|
264
|
+
self.logger.info(
|
|
265
|
+
f"Solution is not electroneutral (C.B. = {cb} eq/L). Adding {balance_charge} to compensate."
|
|
214
266
|
)
|
|
215
|
-
self.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
267
|
+
if self.balance_charge == "pH":
|
|
268
|
+
self.components["H+"] += (
|
|
269
|
+
-1 * cb * self.volume.to("L").magnitude
|
|
270
|
+
) # if C.B. is negative, we need to add cations. H+ is 1 eq/mol
|
|
271
|
+
balanced = True
|
|
272
|
+
elif self.balance_charge == "pE":
|
|
273
|
+
raise NotImplementedError("Balancing charge via redox (pE) is not yet implemented!")
|
|
222
274
|
else:
|
|
223
|
-
|
|
224
|
-
self.
|
|
275
|
+
ions = set().union(*[self.cations, self.anions]) # all ions
|
|
276
|
+
if self.balance_charge not in ions:
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"Charge balancing species {self.balance_charge} was not found in the solution!. "
|
|
279
|
+
f"Species {ions} were found."
|
|
280
|
+
)
|
|
281
|
+
z = self.get_property(balance_charge, "charge")
|
|
282
|
+
self.components[balance_charge] += -1 * cb / z * self.volume.to("L").magnitude
|
|
283
|
+
balanced = True
|
|
225
284
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
Same as add_solute but omits the need to pass solvent mass to pint
|
|
229
|
-
"""
|
|
230
|
-
new_solvent = sol.Solute(formula, amount, self.get_volume(), amount)
|
|
231
|
-
self.components.update({new_solvent.get_name(): new_solvent})
|
|
285
|
+
if not balanced:
|
|
286
|
+
warnings.warn(f"Unable to balance charge using species {self.balance_charge}")
|
|
232
287
|
|
|
233
|
-
|
|
288
|
+
@property
|
|
289
|
+
def mass(self) -> Quantity:
|
|
234
290
|
"""
|
|
235
|
-
Return the
|
|
291
|
+
Return the total mass of the solution.
|
|
236
292
|
|
|
237
|
-
|
|
238
|
-
return self.components[i]
|
|
293
|
+
The mass is calculated each time this method is called.
|
|
239
294
|
|
|
240
|
-
|
|
241
|
-
"""
|
|
242
|
-
Return the solvent object.
|
|
295
|
+
Returns: The mass of the solution, in kg
|
|
243
296
|
|
|
244
297
|
"""
|
|
245
|
-
|
|
298
|
+
mass = np.sum([self.get_amount(item, "kg").magnitude for item in self.components])
|
|
299
|
+
return ureg.Quantity(mass, "kg")
|
|
246
300
|
|
|
247
|
-
|
|
301
|
+
@property
|
|
302
|
+
def solvent_mass(self) -> Quantity:
|
|
248
303
|
"""
|
|
249
|
-
Return the
|
|
304
|
+
Return the mass of the solvent.
|
|
250
305
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
None
|
|
306
|
+
This property is used whenever mol/kg (or similar) concentrations
|
|
307
|
+
are requested by get_amount()
|
|
254
308
|
|
|
255
|
-
Returns
|
|
256
|
-
|
|
257
|
-
Quantity: The temperature of the solution, in Kelvin.
|
|
258
|
-
"""
|
|
259
|
-
return self.temperature.to("K")
|
|
309
|
+
Returns:
|
|
310
|
+
The mass of the solvent, in kg
|
|
260
311
|
|
|
261
|
-
|
|
312
|
+
See Also:
|
|
313
|
+
:py:meth:`get_amount()`
|
|
262
314
|
"""
|
|
263
|
-
|
|
315
|
+
return self.get_amount(self.solvent, "kg")
|
|
264
316
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
temperature : str
|
|
268
|
-
String representing the temperature, e.g. '25 degC'
|
|
317
|
+
@property
|
|
318
|
+
def volume(self) -> Quantity:
|
|
269
319
|
"""
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
# recalculate the volume
|
|
273
|
-
self._update_volume()
|
|
320
|
+
Return the volume of the solution.
|
|
274
321
|
|
|
275
|
-
|
|
322
|
+
Returns:
|
|
323
|
+
Quantity: the volume of the solution, in L
|
|
276
324
|
"""
|
|
277
|
-
|
|
325
|
+
# if the composition has changed, recalculate the volume first
|
|
326
|
+
if self.volume_update_required is True:
|
|
327
|
+
self._update_volume()
|
|
328
|
+
self.volume_update_required = False
|
|
278
329
|
|
|
279
|
-
|
|
280
|
-
-------
|
|
281
|
-
Quantity: The hydrostatic pressure of the solution, in atm.
|
|
282
|
-
"""
|
|
283
|
-
return self.pressure.to("atm")
|
|
330
|
+
return self._volume.to("L")
|
|
284
331
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
332
|
+
@volume.setter
|
|
333
|
+
def volume(self, volume: str):
|
|
334
|
+
"""Change the total solution volume to volume, while preserving
|
|
335
|
+
all component concentrations.
|
|
288
336
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
pressure : str
|
|
292
|
-
String representing the temperature, e.g. '25 degC'
|
|
293
|
-
"""
|
|
294
|
-
self.pressure = unit(pressure)
|
|
337
|
+
Args:
|
|
338
|
+
volume : Total volume of the solution, including the unit, e.g. '1 L'
|
|
295
339
|
|
|
296
|
-
|
|
297
|
-
|
|
340
|
+
Examples:
|
|
341
|
+
>>> mysol = Solution([['Na+','2 mol/L'],['Cl-','0.01 mol/L']],volume='500 mL')
|
|
342
|
+
>>> print(mysol.volume)
|
|
343
|
+
0.5000883925072983 l
|
|
344
|
+
>>> mysol.list_concentrations()
|
|
345
|
+
{'H2O': '55.508435061791985 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
|
|
346
|
+
>>> mysol.volume = '200 mL')
|
|
347
|
+
>>> print(mysol.volume)
|
|
348
|
+
0.2 l
|
|
349
|
+
>>> mysol.list_concentrations()
|
|
350
|
+
{'H2O': '55.50843506179199 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
|
|
298
351
|
|
|
299
|
-
def get_solvent_mass(self):
|
|
300
352
|
"""
|
|
301
|
-
|
|
353
|
+
# figure out the factor to multiply the old concentrations by
|
|
354
|
+
scale_factor = ureg.Quantity(volume) / self.volume
|
|
302
355
|
|
|
303
|
-
|
|
304
|
-
|
|
356
|
+
# scale down the amount of all the solutes according to the factor
|
|
357
|
+
for solute in self.components:
|
|
358
|
+
self.components[solute] *= scale_factor.magnitude
|
|
305
359
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
None
|
|
360
|
+
# update the solution volume
|
|
361
|
+
self._volume *= scale_factor.magnitude
|
|
309
362
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
363
|
+
@property
|
|
364
|
+
def temperature(self) -> Quantity:
|
|
365
|
+
"""Return the temperature of the solution in Kelvin."""
|
|
366
|
+
return self._temperature.to("K")
|
|
313
367
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
get_amount
|
|
368
|
+
@temperature.setter
|
|
369
|
+
def temperature(self, temperature: str):
|
|
317
370
|
"""
|
|
318
|
-
|
|
319
|
-
solvent = self.get_solvent()
|
|
320
|
-
mw = solvent.get_molecular_weight()
|
|
321
|
-
|
|
322
|
-
return solvent.get_moles().to("kg", "chem", mw=mw)
|
|
371
|
+
Set the solution temperature.
|
|
323
372
|
|
|
324
|
-
|
|
373
|
+
Args:
|
|
374
|
+
temperature: pint-compatible string, e.g. '25 degC'
|
|
325
375
|
"""
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
Parameters
|
|
329
|
-
----------
|
|
330
|
-
None
|
|
376
|
+
self._temperature = ureg.Quantity(temperature)
|
|
331
377
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
Quantity: the volume of the solution, in L
|
|
335
|
-
"""
|
|
378
|
+
# update the water substance
|
|
379
|
+
self.water_substance = create_water_substance(self.temperature, self.pressure)
|
|
336
380
|
|
|
337
|
-
#
|
|
338
|
-
|
|
339
|
-
self._update_volume()
|
|
340
|
-
self.volume_update_required = False
|
|
381
|
+
# recalculate the volume
|
|
382
|
+
self.volume_update_required = True
|
|
341
383
|
|
|
342
|
-
|
|
384
|
+
# clear any cached solute properties that may depend on temperature
|
|
385
|
+
self.get_property.cache_clear()
|
|
386
|
+
self.get_molar_conductivity.cache_clear()
|
|
387
|
+
self.get_mobility.cache_clear()
|
|
388
|
+
self.get_diffusion_coefficient.cache_clear()
|
|
343
389
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
390
|
+
@property
|
|
391
|
+
def pressure(self) -> Quantity:
|
|
392
|
+
"""Return the hydrostatic pressure of the solution in atm."""
|
|
393
|
+
return self._pressure.to("atm")
|
|
347
394
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
Examples
|
|
354
|
-
---------
|
|
355
|
-
>>> mysol = Solution([['Na+','2 mol/L'],['Cl-','0.01 mol/L']],volume='500 mL')
|
|
356
|
-
>>> print(mysol.get_volume())
|
|
357
|
-
0.5000883925072983 l
|
|
358
|
-
>>> mysol.list_concentrations()
|
|
359
|
-
{'H2O': '55.508435061791985 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
|
|
360
|
-
>>> mysol.set_volume('200 mL')
|
|
361
|
-
>>> print(mysol.get_volume())
|
|
362
|
-
0.2 l
|
|
363
|
-
>>> mysol.list_concentrations()
|
|
364
|
-
{'H2O': '55.50843506179199 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
|
|
395
|
+
@pressure.setter
|
|
396
|
+
def pressure(self, pressure: str):
|
|
397
|
+
"""
|
|
398
|
+
Set the solution pressure.
|
|
365
399
|
|
|
400
|
+
Args:
|
|
401
|
+
pressure: pint-compatible string, e.g. '1.2 atmC'
|
|
366
402
|
"""
|
|
367
|
-
|
|
368
|
-
scale_factor = unit(volume) / self.get_volume()
|
|
403
|
+
self._pressure = ureg.Quantity(pressure)
|
|
369
404
|
|
|
370
|
-
#
|
|
371
|
-
|
|
372
|
-
self.get_solute(item).moles = self.get_solute(item).moles * scale_factor
|
|
405
|
+
# update the water substance
|
|
406
|
+
self.water_substance = create_water_substance(self.temperature, self.pressure)
|
|
373
407
|
|
|
374
|
-
#
|
|
375
|
-
self.
|
|
408
|
+
# recalculate the volume
|
|
409
|
+
self.volume_update_required = True
|
|
410
|
+
|
|
411
|
+
@property
|
|
412
|
+
def pH(self) -> float | None:
|
|
413
|
+
"""Return the pH of the solution."""
|
|
414
|
+
return self.p("H+", activity=False)
|
|
376
415
|
|
|
377
|
-
def
|
|
416
|
+
def p(self, solute: str, activity=True) -> float | None:
|
|
378
417
|
"""
|
|
379
|
-
Return the
|
|
418
|
+
Return the negative log of the activity of solute.
|
|
380
419
|
|
|
381
|
-
|
|
382
|
-
Parameters
|
|
383
|
-
----------
|
|
384
|
-
None
|
|
420
|
+
Generally used for expressing concentration of hydrogen ions (pH)
|
|
385
421
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
422
|
+
Args:
|
|
423
|
+
solute : str
|
|
424
|
+
String representing the formula of the solute
|
|
425
|
+
activity: bool, optional
|
|
426
|
+
If False, the function will use the molar concentration rather
|
|
427
|
+
than the activity to calculate p. Defaults to True.
|
|
389
428
|
|
|
429
|
+
Returns:
|
|
430
|
+
Quantity
|
|
431
|
+
The negative log10 of the activity (or molar concentration if
|
|
432
|
+
activity = False) of the solute.
|
|
390
433
|
"""
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
434
|
+
try:
|
|
435
|
+
# TODO - for some reason this specific method requires the use of math.log10 rather than np.log10.
|
|
436
|
+
# Using np.exp raises ZeroDivisionError
|
|
437
|
+
import math
|
|
438
|
+
|
|
439
|
+
if activity is True:
|
|
440
|
+
return -1 * math.log10(self.get_activity(solute))
|
|
441
|
+
return -1 * math.log10(self.get_amount(solute, "mol/L").magnitude)
|
|
442
|
+
# if the solute has zero concentration, the log will generate a ValueError
|
|
443
|
+
except ValueError:
|
|
444
|
+
return 0
|
|
395
445
|
|
|
396
|
-
|
|
446
|
+
@property
|
|
447
|
+
def density(self) -> Quantity:
|
|
397
448
|
"""
|
|
398
449
|
Return the density of the solution.
|
|
399
450
|
|
|
400
451
|
Density is calculated from the mass and volume each time this method is called.
|
|
401
452
|
|
|
402
|
-
Returns
|
|
403
|
-
|
|
404
|
-
Quantity: The density of the solution.
|
|
453
|
+
Returns:
|
|
454
|
+
Quantity: The density of the solution.
|
|
405
455
|
"""
|
|
406
|
-
return self.
|
|
456
|
+
return self.mass / self.volume
|
|
407
457
|
|
|
408
|
-
|
|
409
|
-
|
|
458
|
+
@property
|
|
459
|
+
def dielectric_constant(self) -> Quantity:
|
|
460
|
+
r"""
|
|
410
461
|
Returns the dielectric constant of the solution.
|
|
411
462
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
None
|
|
463
|
+
Args:
|
|
464
|
+
None
|
|
415
465
|
|
|
416
|
-
Returns
|
|
417
|
-
|
|
418
|
-
Quantity: the dielectric constant of the solution, dimensionless.
|
|
466
|
+
Returns:
|
|
467
|
+
Quantity: the dielectric constant of the solution, dimensionless.
|
|
419
468
|
|
|
420
|
-
Notes
|
|
421
|
-
|
|
422
|
-
Implements the following equation as given by [#]_
|
|
469
|
+
Notes:
|
|
470
|
+
Implements the following equation as given by Zuber et al.
|
|
423
471
|
|
|
424
|
-
|
|
472
|
+
.. math:: \epsilon = \epsilon_{solvent} \over 1 + \sum_i \alpha_i x_i
|
|
425
473
|
|
|
426
|
-
|
|
427
|
-
|
|
474
|
+
where :math:`\alpha_i` is a coefficient specific to the solvent and ion, and :math:`x_i`
|
|
475
|
+
is the mole fraction of the ion in solution.
|
|
428
476
|
|
|
429
477
|
|
|
430
|
-
References
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
doi:10.1016/j.fluid.2014.05.037.
|
|
478
|
+
References:
|
|
479
|
+
A. Zuber, L. Cardozo-Filho, V.F. Cabral, R.F. Checoni, M. Castier,
|
|
480
|
+
An empirical equation for the dielectric constant in aqueous and nonaqueous
|
|
481
|
+
electrolyte mixtures, Fluid Phase Equilib. 376 (2014) 116-123.
|
|
482
|
+
doi:10.1016/j.fluid.2014.05.037.
|
|
436
483
|
"""
|
|
437
|
-
di_water =
|
|
484
|
+
di_water = self.water_substance.epsilon
|
|
438
485
|
|
|
439
486
|
denominator = 1
|
|
440
487
|
for item in self.components:
|
|
441
488
|
# ignore water
|
|
442
|
-
if item != "H2O":
|
|
489
|
+
if item != "H2O(aq)":
|
|
443
490
|
# skip over solutes that don't have parameters
|
|
444
|
-
try:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
)
|
|
491
|
+
# try:
|
|
492
|
+
fraction = self.get_amount(item, "fraction")
|
|
493
|
+
coefficient = self.get_property(item, "model_parameters.dielectric_zuber")
|
|
494
|
+
if coefficient is not None:
|
|
449
495
|
denominator += coefficient * fraction
|
|
450
|
-
except TypeError:
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
496
|
+
# except TypeError:
|
|
497
|
+
# self.logger.warning("No dielectric parameters found for species %s." % item)
|
|
498
|
+
# continue
|
|
499
|
+
|
|
500
|
+
return ureg.Quantity(di_water / denominator, "dimensionless")
|
|
455
501
|
|
|
456
|
-
|
|
502
|
+
@property
|
|
503
|
+
def chemical_system(self) -> str:
|
|
504
|
+
"""
|
|
505
|
+
Return the chemical system of the Solution as a "-" separated list of elements, sorted alphabetically. For
|
|
506
|
+
example, a solution containing CaCO3 would have a chemical system of "C-Ca-H-O".
|
|
507
|
+
"""
|
|
508
|
+
return "-".join(self.elements)
|
|
457
509
|
|
|
458
|
-
|
|
510
|
+
@property
|
|
511
|
+
def elements(self) -> list:
|
|
512
|
+
"""
|
|
513
|
+
Return a list of elements that are present in the solution.
|
|
459
514
|
|
|
460
|
-
|
|
515
|
+
For example, a solution containing CaCO3 would return ["C", "Ca", "H", "O"]
|
|
461
516
|
"""
|
|
462
|
-
|
|
517
|
+
els = []
|
|
518
|
+
for s in self.components:
|
|
519
|
+
els.extend(self.get_property(s, "elements"))
|
|
520
|
+
return sorted(set(els))
|
|
463
521
|
|
|
464
|
-
|
|
522
|
+
@property
|
|
523
|
+
def cations(self) -> dict[str, float]:
|
|
524
|
+
"""
|
|
525
|
+
Returns the subset of `components` that are cations.
|
|
465
526
|
|
|
466
|
-
|
|
527
|
+
The returned dict is sorted by amount in descending order.
|
|
528
|
+
"""
|
|
529
|
+
return {k: v for k, v in self.components.items() if self.get_property(k, "charge") > 0}
|
|
467
530
|
|
|
468
|
-
|
|
531
|
+
@property
|
|
532
|
+
def anions(self) -> dict[str, float]:
|
|
533
|
+
"""
|
|
534
|
+
Returns the subset of `components` that are anions.
|
|
469
535
|
|
|
470
|
-
|
|
471
|
-
<http://downloads.olisystems.com/ResourceCD/TransportProperties/Viscosity-Aqueous.pdf>
|
|
472
|
-
<http://www.nrcresearchpress.com/doi/pdf/10.1139/v77-148>
|
|
473
|
-
<http://apple.csgi.unifi.it/~fratini/chen/pdf/14.pdf>
|
|
536
|
+
The returned dict is sorted by amount in descending order.
|
|
474
537
|
"""
|
|
475
|
-
|
|
476
|
-
# logger.warning('Viscosity calculation has limited accuracy above 0.2m')
|
|
538
|
+
return {k: v for k, v in self.components.items() if self.get_property(k, "charge") < 0}
|
|
477
539
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
# # skip over solutes that don't have parameters
|
|
483
|
-
# try:
|
|
484
|
-
# conc = self.get_amount(item,'mol/kg').magnitude
|
|
485
|
-
# coefficients= self.get_solute(item).get_parameter('jones_dole_viscosity')
|
|
486
|
-
# viscosity_rel += coefficients[0] * conc ** 0.5 + coefficients[1] * conc + \
|
|
487
|
-
# coefficients[2] * conc ** 2
|
|
488
|
-
# except TypeError:
|
|
489
|
-
# continue
|
|
490
|
-
viscosity_rel = self.get_viscosity_dynamic() / h2o.water_viscosity_dynamic(
|
|
491
|
-
self.get_temperature(), self.get_pressure()
|
|
492
|
-
)
|
|
540
|
+
@property
|
|
541
|
+
def neutrals(self) -> dict[str, float]:
|
|
542
|
+
"""
|
|
543
|
+
Returns the subset of `components` that are neutral (not charged).
|
|
493
544
|
|
|
494
|
-
|
|
545
|
+
The returned dict is sorted by amount in descending order.
|
|
546
|
+
"""
|
|
547
|
+
return {k: v for k, v in self.components.items() if self.get_property(k, "charge") == 0}
|
|
495
548
|
|
|
496
|
-
|
|
549
|
+
# TODO - need tests for viscosity
|
|
550
|
+
@property
|
|
551
|
+
def viscosity_dynamic(self) -> Quantity:
|
|
497
552
|
"""
|
|
498
553
|
Return the dynamic (absolute) viscosity of the solution.
|
|
499
554
|
|
|
500
555
|
Calculated from the kinematic viscosity
|
|
501
556
|
|
|
502
|
-
See Also
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
557
|
+
See Also:
|
|
558
|
+
:attr:`viscosity_kinematic`
|
|
559
|
+
"""
|
|
560
|
+
return self.viscosity_kinematic * self.density
|
|
561
|
+
|
|
562
|
+
# TODO - before deprecating get_viscosity_relative, consider whether the Jones-Dole
|
|
563
|
+
# model should be integrated here as a fallback, in case salt parameters for the
|
|
564
|
+
# other model are not available.
|
|
565
|
+
# if self.ionic_strength.magnitude > 0.2:
|
|
566
|
+
# self.logger.warning('Viscosity calculation has limited accuracy above 0.2m')
|
|
567
|
+
|
|
568
|
+
# viscosity_rel = 1
|
|
569
|
+
# for item in self.components:
|
|
570
|
+
# # ignore water
|
|
571
|
+
# if item != 'H2O':
|
|
572
|
+
# # skip over solutes that don't have parameters
|
|
573
|
+
# try:
|
|
574
|
+
# conc = self.get_amount(item,'mol/kg').magnitude
|
|
575
|
+
# coefficients= self.get_property(item, 'jones_dole_viscosity')
|
|
576
|
+
# viscosity_rel += coefficients[0] * conc ** 0.5 + coefficients[1] * conc + \
|
|
577
|
+
# coefficients[2] * conc ** 2
|
|
578
|
+
# except TypeError:
|
|
579
|
+
# continue
|
|
580
|
+
# return (
|
|
581
|
+
# self.viscosity_dynamic / self.water_substance.mu * ureg.Quantity("1 Pa*s")
|
|
582
|
+
# )
|
|
583
|
+
@property
|
|
584
|
+
def viscosity_kinematic(self) -> Quantity:
|
|
585
|
+
r"""
|
|
511
586
|
Return the kinematic viscosity of the solution.
|
|
512
587
|
|
|
513
|
-
Notes
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
and presented in [#]_
|
|
588
|
+
Notes:
|
|
589
|
+
The calculation is based on a model derived from the Eyring equation
|
|
590
|
+
and presented in
|
|
517
591
|
|
|
518
|
-
|
|
592
|
+
.. math::
|
|
519
593
|
|
|
520
|
-
|
|
521
|
-
|
|
594
|
+
\ln \nu = \ln {\nu_w MW_w \over \sum_i x_i MW_i } +
|
|
595
|
+
15 x_+^2 + x_+^3 \delta G^*_{123} + 3 x_+ \delta G^*_{23} (1-0.05x_+)
|
|
522
596
|
|
|
523
|
-
|
|
597
|
+
Where:
|
|
524
598
|
|
|
525
|
-
|
|
526
|
-
|
|
599
|
+
.. math:: \delta G^*_{123} = a_o + a_1 (T)^{0.75}
|
|
600
|
+
.. math:: \delta G^*_{23} = b_o + b_1 (T)^{0.5}
|
|
527
601
|
|
|
528
|
-
|
|
529
|
-
|
|
602
|
+
In which :math:`\nu` is the kinematic viscosity, MW is the molecular weight,
|
|
603
|
+
:math:`x_{+}` is the mole fraction of cations, and :math:`T` is the temperature in degrees C.
|
|
530
604
|
|
|
531
|
-
|
|
532
|
-
|
|
605
|
+
The a and b fitting parameters for a variety of common salts are included in the
|
|
606
|
+
database.
|
|
533
607
|
|
|
534
|
-
References
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
of the McAllister model to correlate kinematic viscosity of electrolyte solutions. \
|
|
538
|
-
Fluid Phase Equilib. 2013, 358, 44–49.
|
|
608
|
+
References:
|
|
609
|
+
Vásquez-Castillo, G.; Iglesias-Silva, G. a.; Hall, K. R. An extension of the McAllister model to correlate
|
|
610
|
+
kinematic viscosity of electrolyte solutions. Fluid Phase Equilib. 2013, 358, 44-49.
|
|
539
611
|
|
|
540
|
-
See Also
|
|
541
|
-
|
|
542
|
-
get_viscosity_dynamic
|
|
543
|
-
get_viscosity_relative
|
|
612
|
+
See Also:
|
|
613
|
+
:py:meth:`viscosity_dynamic`
|
|
544
614
|
"""
|
|
545
615
|
# identify the main salt in the solution
|
|
546
616
|
salt = self.get_salt()
|
|
547
|
-
cation = salt.cation
|
|
548
|
-
|
|
549
|
-
# search the database for parameters for 'salt'
|
|
550
|
-
db.search_parameters(salt.formula)
|
|
551
617
|
|
|
552
618
|
a0 = a1 = b0 = b1 = 0
|
|
553
619
|
|
|
554
620
|
# retrieve the parameters for the delta G equations
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
621
|
+
params = self.get_property(salt.formula, "model_parameters.viscosity_eyring")
|
|
622
|
+
if params is not None:
|
|
623
|
+
a0 = ureg.Quantity(params["a0"]["value"]).magnitude
|
|
624
|
+
a1 = ureg.Quantity(params["a1"]["value"]).magnitude
|
|
625
|
+
b0 = ureg.Quantity(params["b0"]["value"]).magnitude
|
|
626
|
+
b1 = ureg.Quantity(params["b1"]["value"]).magnitude
|
|
627
|
+
|
|
628
|
+
# compute the delta G parameters
|
|
629
|
+
temperature = self.temperature.to("degC").magnitude
|
|
630
|
+
G_123 = a0 + a1 * (temperature) ** 0.75
|
|
631
|
+
G_23 = b0 + b1 * (temperature) ** 0.5
|
|
562
632
|
else:
|
|
633
|
+
# TODO - fall back to the Jones-Dole model! There are currently no eyring parameters in the database!
|
|
563
634
|
# proceed with the coefficients equal to zero and log a warning
|
|
564
|
-
logger.warning(
|
|
565
|
-
|
|
566
|
-
% salt.formula
|
|
567
|
-
)
|
|
568
|
-
|
|
569
|
-
# compute the delta G parameters
|
|
570
|
-
temperature = self.get_temperature().to("degC")
|
|
571
|
-
G_123 = a0 + a1 * (temperature.magnitude) ** 0.75
|
|
572
|
-
G_23 = b0 + b1 * (temperature.magnitude) ** 0.5
|
|
635
|
+
self.logger.warning(f"Viscosity coefficients for {salt.formula} not found. Viscosity will be approximate.")
|
|
636
|
+
G_123 = G_23 = 0
|
|
573
637
|
|
|
574
|
-
# get the kinematic viscosity of water
|
|
575
|
-
nu_w =
|
|
576
|
-
h2o.water_viscosity_kinematic(temperature, self.get_pressure())
|
|
577
|
-
.to("m**2 / s")
|
|
578
|
-
.magnitude
|
|
579
|
-
)
|
|
638
|
+
# get the kinematic viscosity of water, returned by IAPWS in m2/s
|
|
639
|
+
nu_w = self.water_substance.nu
|
|
580
640
|
|
|
581
641
|
# compute the effective molar mass of the solution
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
)
|
|
642
|
+
total_moles = np.sum([v for k, v in self.components.items()])
|
|
643
|
+
MW = self.mass.to("g").magnitude / total_moles
|
|
585
644
|
|
|
586
645
|
# get the MW of water
|
|
587
|
-
MW_w = self.
|
|
646
|
+
MW_w = self.get_property(self.solvent, "molecular_weight").magnitude
|
|
588
647
|
|
|
589
648
|
# calculate the cation mole fraction
|
|
590
|
-
x_cat = self.get_amount(cation, "fraction")
|
|
649
|
+
# x_cat = self.get_amount(cation, "fraction")
|
|
650
|
+
x_cat = self.get_amount(salt.cation, "fraction").magnitude
|
|
591
651
|
|
|
592
652
|
# calculate the kinematic viscosity
|
|
593
|
-
nu = (
|
|
594
|
-
math.log(nu_w * MW_w / MW)
|
|
595
|
-
+ 15 * x_cat ** 2
|
|
596
|
-
+ x_cat ** 3 * G_123
|
|
597
|
-
+ 3 * x_cat * G_23 * (1 - 0.05 * x_cat)
|
|
598
|
-
)
|
|
653
|
+
nu = np.log(nu_w * MW_w / MW) + 15 * x_cat**2 + x_cat**3 * G_123 + 3 * x_cat * G_23 * (1 - 0.05 * x_cat)
|
|
599
654
|
|
|
600
|
-
return
|
|
655
|
+
return ureg.Quantity(np.exp(nu), "m**2 / s")
|
|
601
656
|
|
|
602
|
-
|
|
603
|
-
|
|
657
|
+
@property
|
|
658
|
+
def conductivity(self) -> Quantity:
|
|
659
|
+
r"""
|
|
604
660
|
Compute the electrical conductivity of the solution.
|
|
605
661
|
|
|
606
|
-
|
|
607
|
-
----------
|
|
608
|
-
None
|
|
609
|
-
|
|
610
|
-
Returns
|
|
611
|
-
-------
|
|
612
|
-
Quantity
|
|
662
|
+
Returns:
|
|
613
663
|
The electrical conductivity of the solution in Siemens / meter.
|
|
614
664
|
|
|
615
|
-
Notes
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
solutes, but they are activity-corrected and adjusted using an empricial exponent.
|
|
619
|
-
This approach is used in PHREEQC and Aqion models [#]_ [#]_
|
|
665
|
+
Notes:
|
|
666
|
+
Conductivity is calculated by summing the molar conductivities of the respective
|
|
667
|
+
solutes.
|
|
620
668
|
|
|
621
|
-
|
|
669
|
+
.. math::
|
|
622
670
|
|
|
623
|
-
|
|
671
|
+
EC = {F^2 \over R T} \sum_i D_i z_i ^ 2 m_i = \sum_i \lambda_i m_i
|
|
624
672
|
|
|
625
|
-
|
|
673
|
+
Where :math:`D_i` is the diffusion coefficient, :math:`m_i` is the molal concentration,
|
|
674
|
+
:math:`z_i` is the charge, and the summation extends over all species in the solution.
|
|
675
|
+
Alternatively, :math:`\lambda_i` is the molar conductivity of solute i.
|
|
626
676
|
|
|
627
|
-
|
|
677
|
+
Diffusion coefficients :math:`D_i` (and molar conductivities :math:`\lambda_i`) are
|
|
678
|
+
adjusted for the effects of temperature and ionic strength using the method implemented
|
|
679
|
+
in PHREEQC >= 3.4. [aq]_ [hc]_ See `get_diffusion_coefficient for` further details.
|
|
628
680
|
|
|
629
|
-
|
|
630
|
-
|
|
681
|
+
References:
|
|
682
|
+
.. [aq] https://www.aqion.de/site/electrical-conductivity
|
|
683
|
+
.. [hc] http://www.hydrochemistry.eu/exmpls/sc.html
|
|
631
684
|
|
|
632
|
-
|
|
633
|
-
|
|
685
|
+
See Also:
|
|
686
|
+
:py:attr:`ionic_strength`
|
|
687
|
+
:py:meth:`get_diffusion_coefficient`
|
|
688
|
+
:py:meth:`get_molar_conductivity`
|
|
689
|
+
"""
|
|
690
|
+
EC = ureg.Quantity(
|
|
691
|
+
np.asarray(
|
|
692
|
+
[
|
|
693
|
+
self.get_molar_conductivity(i).to("S*L/mol/m").magnitude * self.get_amount(i, "mol/L").magnitude
|
|
694
|
+
for i in self.components
|
|
695
|
+
]
|
|
696
|
+
),
|
|
697
|
+
"S/m",
|
|
698
|
+
)
|
|
699
|
+
return np.sum(EC)
|
|
634
700
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
701
|
+
@property
|
|
702
|
+
def ionic_strength(self) -> Quantity:
|
|
703
|
+
r"""
|
|
704
|
+
Return the ionic strength of the solution.
|
|
639
705
|
|
|
640
|
-
|
|
641
|
-
--------
|
|
642
|
-
get_ionic_strength
|
|
643
|
-
get_molar_conductivity
|
|
644
|
-
get_activity_coefficient
|
|
706
|
+
Return the ionic strength of the solution, calculated as 1/2 * sum ( molality * charge ^2) over all the ions.
|
|
645
707
|
|
|
646
|
-
|
|
647
|
-
EC = 0 * unit("S/m")
|
|
648
|
-
temperature = self.get_temperature()
|
|
708
|
+
Molal (mol/kg) scale concentrations are used for compatibility with the activity correction formulas.
|
|
649
709
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if not z == 0:
|
|
654
|
-
# determine the value of the exponent alpha
|
|
655
|
-
if self.get_ionic_strength().magnitude < 0.36 * z:
|
|
656
|
-
alpha = 0.6 / z ** 0.5
|
|
657
|
-
else:
|
|
658
|
-
alpha = self.get_ionic_strength().magnitude ** 0.5 / z
|
|
710
|
+
Returns:
|
|
711
|
+
Quantity:
|
|
712
|
+
The ionic strength of the parent solution, mol/kg.
|
|
659
713
|
|
|
660
|
-
|
|
714
|
+
See Also:
|
|
715
|
+
:py:meth:`get_activity`
|
|
716
|
+
:py:meth:`get_water_activity`
|
|
661
717
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
* (unit.e * unit.N_A) ** 2
|
|
665
|
-
* self.get_solute(item).get_formal_charge() ** 2
|
|
666
|
-
/ (unit.R * temperature)
|
|
667
|
-
)
|
|
718
|
+
Notes:
|
|
719
|
+
The ionic strength is calculated according to:
|
|
668
720
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
* self.get_amount(item, "mol/L")
|
|
673
|
-
)
|
|
721
|
+
.. math:: I = \sum_i m_i z_i^2
|
|
722
|
+
|
|
723
|
+
Where :math:`m_i` is the molal concentration and :math:`z_i` is the charge on species i.
|
|
674
724
|
|
|
675
|
-
|
|
725
|
+
Examples:
|
|
726
|
+
>>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
|
|
727
|
+
>>> s1.ionic_strength
|
|
728
|
+
<Quantity(0.20000010029672785, 'mole / kilogram')>
|
|
676
729
|
|
|
677
|
-
|
|
730
|
+
>>> s1 = pyEQL.Solution([['Mg+2','0.3 mol/kg'],['Na+','0.1 mol/kg'],['Cl-','0.7 mol/kg']],temperature='30 degC')
|
|
731
|
+
>>> s1.ionic_strength
|
|
732
|
+
<Quantity(1.0000001004383303, 'mole / kilogram')>
|
|
678
733
|
"""
|
|
679
|
-
|
|
734
|
+
# compute using magnitudes only, for performance reasons
|
|
735
|
+
ionic_strength = np.sum(
|
|
736
|
+
[mol * self.get_property(solute, "charge") ** 2 for solute, mol in self.components.items()]
|
|
737
|
+
)
|
|
738
|
+
ionic_strength /= self.solvent_mass.to("kg").magnitude # convert to mol/kg
|
|
739
|
+
ionic_strength *= 0.5
|
|
740
|
+
return ureg.Quantity(ionic_strength, "mol/kg")
|
|
680
741
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
742
|
+
@property
|
|
743
|
+
def charge_balance(self) -> float:
|
|
744
|
+
r"""
|
|
745
|
+
Return the charge balance of the solution.
|
|
684
746
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
The osmotic pressure of the solution relative to pure water in Pa
|
|
747
|
+
Return the charge balance of the solution. The charge balance represents the net electric charge
|
|
748
|
+
on the solution and SHOULD equal zero at all times, but due to numerical errors will usually
|
|
749
|
+
have a small nonzero value. It is calculated according to:
|
|
689
750
|
|
|
690
|
-
|
|
691
|
-
--------
|
|
692
|
-
get_water_activity
|
|
693
|
-
get_osmotic_coefficient
|
|
694
|
-
get_salt
|
|
751
|
+
.. math:: CB = \sum_i C_i z_i
|
|
695
752
|
|
|
696
|
-
|
|
697
|
-
-----
|
|
698
|
-
Osmotic pressure is calculated based on the water activity [#]_ [#]_ :
|
|
753
|
+
where :math:`C_i` is the molar concentration, and :math:`z_i` is the charge on species i.
|
|
699
754
|
|
|
700
|
-
|
|
755
|
+
Returns:
|
|
756
|
+
float :
|
|
757
|
+
The charge balance of the solution, in equivalents (mol of charge) per L.
|
|
701
758
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
759
|
+
"""
|
|
760
|
+
charge_balance = 0
|
|
761
|
+
for solute in self.components:
|
|
762
|
+
charge_balance += self.get_amount(solute, "eq/L").magnitude
|
|
705
763
|
|
|
764
|
+
return charge_balance
|
|
706
765
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
766
|
+
# TODO - consider adding guard statements to prevent alkalinity from being negative
|
|
767
|
+
@property
|
|
768
|
+
def alkalinity(self) -> Quantity:
|
|
769
|
+
r"""
|
|
770
|
+
Return the alkalinity or acid neutralizing capacity of a solution.
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
Quantity: The alkalinity of the solution in mg/L as CaCO3
|
|
774
|
+
|
|
775
|
+
Notes:
|
|
776
|
+
The alkalinity is calculated according to [stm]_
|
|
711
777
|
|
|
712
|
-
|
|
778
|
+
.. math:: Alk = \sum_{i} z_{i} C_{B} + \sum_{i} z_{i} C_{A}
|
|
713
779
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
780
|
+
Where :math:`C_{B}` and :math:`C_{A}` are conservative cations and anions, respectively
|
|
781
|
+
(i.e. ions that do not participate in acid-base reactions), and :math:`z_{i}` is their signed charge.
|
|
782
|
+
In this method, the set of conservative cations is all Group I and Group II cations, and the
|
|
783
|
+
conservative anions are all the anions of strong acids.
|
|
784
|
+
|
|
785
|
+
References:
|
|
786
|
+
.. [stm] Stumm, Werner and Morgan, James J. Aquatic Chemistry, 3rd ed, pp 165. Wiley Interscience, 1996.
|
|
719
787
|
|
|
720
|
-
>>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
|
|
721
|
-
>>> soln.get_osmotic_pressure()
|
|
722
|
-
<Quantity(906516.7318131207, 'pascal')>
|
|
723
788
|
"""
|
|
724
|
-
|
|
725
|
-
partial_molar_volume_water = 1.82e-5 * unit("m ** 3/mol")
|
|
789
|
+
alkalinity = ureg.Quantity(0, "mol/L")
|
|
726
790
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
"
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
791
|
+
base_cations = {
|
|
792
|
+
"Li[+1]",
|
|
793
|
+
"Na[+1]",
|
|
794
|
+
"K[+1]",
|
|
795
|
+
"Rb[+1]",
|
|
796
|
+
"Cs[+1]",
|
|
797
|
+
"Fr[+1]",
|
|
798
|
+
"Be[+2]",
|
|
799
|
+
"Mg[+2]",
|
|
800
|
+
"Ca[+2]",
|
|
801
|
+
"Sr[+2]",
|
|
802
|
+
"Ba[+2]",
|
|
803
|
+
"Ra[+2]",
|
|
804
|
+
}
|
|
805
|
+
acid_anions = {"Cl[-1]", "Br[-1]", "I[-1]", "SO4[-2]", "NO3[-1]", "ClO4[-1]", "ClO3[-1]"}
|
|
739
806
|
|
|
740
|
-
|
|
807
|
+
for item in self.components:
|
|
808
|
+
if item in base_cations.union(acid_anions):
|
|
809
|
+
z = self.get_property(item, "charge")
|
|
810
|
+
alkalinity += self.get_amount(item, "mol/L") * z
|
|
811
|
+
|
|
812
|
+
# convert the alkalinity to mg/L as CaCO3
|
|
813
|
+
return (alkalinity * EQUIV_WT_CACO3).to("mg/L")
|
|
741
814
|
|
|
742
|
-
|
|
815
|
+
@property
|
|
816
|
+
def hardness(self) -> Quantity:
|
|
743
817
|
"""
|
|
744
|
-
Return the
|
|
818
|
+
Return the hardness of a solution.
|
|
745
819
|
|
|
746
|
-
|
|
820
|
+
Hardness is defined as the sum of the equivalent concentrations
|
|
821
|
+
of multivalent cations as calcium carbonate.
|
|
747
822
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
solute : str
|
|
751
|
-
String representing the formula of the solute
|
|
752
|
-
activity: bool, optional
|
|
753
|
-
If False, the function will use the molar concentration rather
|
|
754
|
-
than the activity to calculate p. Defaults to True.
|
|
823
|
+
NOTE: at present pyEQL cannot distinguish between mg/L as CaCO3
|
|
824
|
+
and mg/L units. Use with caution.
|
|
755
825
|
|
|
756
|
-
Returns
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
826
|
+
Returns:
|
|
827
|
+
Quantity:
|
|
828
|
+
The hardness of the solution in mg/L as CaCO3
|
|
829
|
+
|
|
830
|
+
"""
|
|
831
|
+
hardness = ureg.Quantity(0, "mol/L")
|
|
832
|
+
|
|
833
|
+
for item in self.components:
|
|
834
|
+
z = self.get_property(item, "charge")
|
|
835
|
+
if z > 1:
|
|
836
|
+
hardness += z * self.get_amount(item, "mol/L")
|
|
761
837
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
TODO
|
|
838
|
+
# convert the hardness to mg/L as CaCO3
|
|
839
|
+
return (hardness * EQUIV_WT_CACO3).to("mg/L")
|
|
765
840
|
|
|
841
|
+
@property
|
|
842
|
+
def total_dissolved_solids(self) -> Quantity:
|
|
766
843
|
"""
|
|
767
|
-
|
|
768
|
-
if activity is True:
|
|
769
|
-
return -1 * math.log10(self.get_activity(solute))
|
|
770
|
-
elif activity is False:
|
|
771
|
-
return -1 * math.log10(self.get_amount(solute, "mol/L").magnitude)
|
|
772
|
-
# if the solute has zero concentration, the log will generate a ValueError
|
|
773
|
-
except ValueError:
|
|
774
|
-
return 0
|
|
844
|
+
Total dissolved solids in mg/L (equivalent to ppm) including both charged and uncharged species.
|
|
775
845
|
|
|
776
|
-
|
|
846
|
+
The TDS is defined as the sum of the concentrations of all aqueous solutes (not including the solvent),
|
|
847
|
+
except for H[+1] and OH[-1]].
|
|
777
848
|
"""
|
|
778
|
-
|
|
849
|
+
tds = ureg.Quantity(0, "mg/L")
|
|
850
|
+
for s in self.components:
|
|
851
|
+
# ignore pure water and dissolved gases, but not CO2
|
|
852
|
+
if s in ["H2O(aq)", "H[+1]", "OH[-1]"]:
|
|
853
|
+
continue
|
|
854
|
+
tds += self.get_amount(s, "mg/L")
|
|
779
855
|
|
|
780
|
-
|
|
781
|
-
1. substance per volume (e.g., 'mol/L')
|
|
782
|
-
1. substance per mass of solvent (e.g., 'mol/kg')
|
|
783
|
-
1. mass of substance (e.g., 'kg')
|
|
784
|
-
1. moles of substance ('mol')
|
|
785
|
-
1. mole fraction ('fraction')
|
|
786
|
-
1. percent by weight (%)
|
|
856
|
+
return tds
|
|
787
857
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
units : str
|
|
793
|
-
Units desired for the output. Examples of valid units are
|
|
794
|
-
'mol/L','mol/kg','mol', 'kg', and 'g/L'
|
|
795
|
-
Use 'fraction' to return the mole fraction.
|
|
796
|
-
Use '%' to return the mass percent
|
|
797
|
-
|
|
798
|
-
Returns
|
|
799
|
-
-------
|
|
800
|
-
The amount of the solute in question, in the specified units
|
|
858
|
+
@property
|
|
859
|
+
def TDS(self) -> Quantity:
|
|
860
|
+
"""Alias of :py:meth:`total_dissolved_solids`."""
|
|
861
|
+
return self.total_dissolved_solids
|
|
801
862
|
|
|
863
|
+
@property
|
|
864
|
+
def debye_length(self) -> Quantity:
|
|
865
|
+
r"""
|
|
866
|
+
Return the Debye length of a solution.
|
|
802
867
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
868
|
+
Debye length is calculated as [wk3]_
|
|
869
|
+
|
|
870
|
+
.. math::
|
|
871
|
+
|
|
872
|
+
\kappa^{-1} = \sqrt({\epsilon_r \epsilon_o k_B T \over (2 N_A e^2 I)})
|
|
873
|
+
|
|
874
|
+
where :math:`I` is the ionic strength, :math:`\epsilon_r` and :math:`\epsilon_r`
|
|
875
|
+
are the relative permittivity and vacuum permittivity, :math:`k_B` is the
|
|
876
|
+
Boltzmann constant, and :math:`T` is the temperature, :math:`e` is the
|
|
877
|
+
elementary charge, and :math:`N_A` is Avogadro's number.
|
|
878
|
+
|
|
879
|
+
Returns The Debye length, in nanometers.
|
|
880
|
+
|
|
881
|
+
References:
|
|
882
|
+
.. [wk3] https://en.wikipedia.org/wiki/Debye_length#Debye_length_in_an_electrolyte
|
|
883
|
+
|
|
884
|
+
See Also:
|
|
885
|
+
:attr:`ionic_strength`
|
|
886
|
+
:attr:`dielectric_constant`
|
|
887
|
+
|
|
888
|
+
"""
|
|
889
|
+
# to preserve dimensionality, convert the ionic strength into mol/L units
|
|
890
|
+
ionic_strength = ureg.Quantity(self.ionic_strength.magnitude, "mol/L")
|
|
891
|
+
dielectric_constant = self.dielectric_constant
|
|
892
|
+
|
|
893
|
+
debye_length = (
|
|
894
|
+
dielectric_constant
|
|
895
|
+
* ureg.epsilon_0
|
|
896
|
+
* ureg.k
|
|
897
|
+
* self.temperature
|
|
898
|
+
/ (2 * ureg.N_A * ureg.e**2 * ionic_strength)
|
|
899
|
+
) ** 0.5
|
|
900
|
+
|
|
901
|
+
return debye_length.to("nm")
|
|
902
|
+
|
|
903
|
+
@property
|
|
904
|
+
def bjerrum_length(self) -> Quantity:
|
|
905
|
+
r"""
|
|
906
|
+
Return the Bjerrum length of a solution.
|
|
907
|
+
|
|
908
|
+
Bjerrum length represents the distance at which electrostatic
|
|
909
|
+
interactions between particles become comparable in magnitude
|
|
910
|
+
to the thermal energy.:math:`\lambda_B` is calculated as
|
|
911
|
+
|
|
912
|
+
.. math::
|
|
913
|
+
|
|
914
|
+
\lambda_B = {e^2 \over (4 \pi \epsilon_r \epsilon_o k_B T)}
|
|
915
|
+
|
|
916
|
+
where :math:`e` is the fundamental charge, :math:`\epsilon_r` and :math:`\epsilon_r`
|
|
917
|
+
are the relative permittivity and vacuum permittivity, :math:`k_B` is the
|
|
918
|
+
Boltzmann constant, and :math:`T` is the temperature.
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
Quantity:
|
|
922
|
+
The Bjerrum length, in nanometers.
|
|
923
|
+
|
|
924
|
+
References:
|
|
925
|
+
https://en.wikipedia.org/wiki/Bjerrum_length
|
|
926
|
+
|
|
927
|
+
Examples:
|
|
928
|
+
>>> s1 = pyEQL.Solution()
|
|
929
|
+
>>> s1.bjerrum_length
|
|
930
|
+
<Quantity(0.7152793009386953, 'nanometer')>
|
|
931
|
+
|
|
932
|
+
See Also:
|
|
933
|
+
:attr:`dielectric_constant`
|
|
934
|
+
|
|
935
|
+
"""
|
|
936
|
+
bjerrum_length = ureg.e**2 / (4 * np.pi * self.dielectric_constant * ureg.epsilon_0 * ureg.k * self.temperature)
|
|
937
|
+
return bjerrum_length.to("nm")
|
|
938
|
+
|
|
939
|
+
@property
|
|
940
|
+
def osmotic_pressure(self) -> Quantity:
|
|
941
|
+
r"""
|
|
942
|
+
Return the osmotic pressure of the solution relative to pure water.
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
The osmotic pressure of the solution relative to pure water in Pa
|
|
946
|
+
|
|
947
|
+
See Also:
|
|
948
|
+
:attr:`get_water_activity`
|
|
949
|
+
:attr:`get_osmotic_coefficient`
|
|
950
|
+
:attr:`get_salt`
|
|
951
|
+
|
|
952
|
+
Notes:
|
|
953
|
+
Osmotic pressure is calculated based on the water activity [sata]_ [wk]_
|
|
954
|
+
|
|
955
|
+
.. math:: \Pi = -\frac{RT}{V_{w}} \ln a_{w}
|
|
956
|
+
|
|
957
|
+
Where :math:`\Pi` is the osmotic pressure, :math:`V_{w}` is the partial
|
|
958
|
+
molar volume of water (18.2 cm**3/mol), and :math:`a_{w}` is the water
|
|
959
|
+
activity.
|
|
960
|
+
|
|
961
|
+
References:
|
|
962
|
+
.. [sata] Sata, Toshikatsu. Ion Exchange Membranes: Preparation, Characterization, and Modification.
|
|
963
|
+
Royal Society of Chemistry, 2004, p. 10.
|
|
964
|
+
|
|
965
|
+
.. [wk] http://en.wikipedia.org/wiki/Osmotic_pressure#Derivation_of_osmotic_pressure
|
|
966
|
+
|
|
967
|
+
Examples:
|
|
968
|
+
>>> s1=pyEQL.Solution()
|
|
969
|
+
>>> s1.osmotic_pressure
|
|
970
|
+
<Quantity(0.495791416, 'pascal')>
|
|
971
|
+
|
|
972
|
+
>>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
|
|
973
|
+
>>> soln.osmotic_pressure
|
|
974
|
+
<Quantity(906516.7318131207, 'pascal')>
|
|
975
|
+
"""
|
|
976
|
+
partial_molar_volume_water = self.get_property(self.solvent, "size.molar_volume")
|
|
977
|
+
|
|
978
|
+
osmotic_pressure = (
|
|
979
|
+
-1 * ureg.R * self.temperature / partial_molar_volume_water * np.log(self.get_water_activity())
|
|
980
|
+
)
|
|
981
|
+
self.logger.debug(
|
|
982
|
+
f"Calculated osmotic pressure of solution as {osmotic_pressure} Pa at T= {self.temperature} degrees C"
|
|
983
|
+
)
|
|
984
|
+
return osmotic_pressure.to("Pa")
|
|
985
|
+
|
|
986
|
+
# Concentration Methods
|
|
987
|
+
|
|
988
|
+
def get_amount(self, solute: str, units: str = "mol/L") -> Quantity:
|
|
989
|
+
"""
|
|
990
|
+
Return the amount of 'solute' in the parent solution.
|
|
991
|
+
|
|
992
|
+
The amount of a solute can be given in a variety of unit types.
|
|
993
|
+
1. substance per volume (e.g., 'mol/L', 'M')
|
|
994
|
+
2. equivalents (i.e., moles of charge) per volume (e.g., 'eq/L', 'meq/L')
|
|
995
|
+
3. substance per mass of solvent (e.g., 'mol/kg', 'm')
|
|
996
|
+
4. mass of substance (e.g., 'kg')
|
|
997
|
+
5. moles of substance ('mol')
|
|
998
|
+
6. mole fraction ('fraction')
|
|
999
|
+
7. percent by weight (%)
|
|
1000
|
+
8. number of molecules ('count')
|
|
1001
|
+
9. "parts-per-x" units, where ppm = mg/L, ppb = ug/L ppt = ng/L
|
|
1002
|
+
|
|
1003
|
+
Args:
|
|
1004
|
+
solute : str
|
|
1005
|
+
String representing the name of the solute of interest
|
|
1006
|
+
units : str
|
|
1007
|
+
Units desired for the output. Examples of valid units are
|
|
1008
|
+
'mol/L','mol/kg','mol', 'kg', and 'g/L'
|
|
1009
|
+
Use 'fraction' to return the mole fraction.
|
|
1010
|
+
Use '%' to return the mass percent
|
|
1011
|
+
|
|
1012
|
+
Returns:
|
|
1013
|
+
The amount of the solute in question, in the specified units
|
|
1014
|
+
|
|
1015
|
+
See Also:
|
|
1016
|
+
:attr:`mass`
|
|
1017
|
+
:meth:`add_amount`
|
|
1018
|
+
:meth:`set_amount`
|
|
1019
|
+
:meth:`get_total_amount`
|
|
1020
|
+
:meth:`get_osmolarity`
|
|
1021
|
+
:meth:`get_osmolality`
|
|
1022
|
+
:meth:`get_total_moles_solute`
|
|
1023
|
+
:func:`pyEQL.utils.interpret_units`
|
|
1024
|
+
"""
|
|
1025
|
+
z = 1
|
|
1026
|
+
# sanitized unit to be passed to pint
|
|
1027
|
+
if "eq" in units:
|
|
1028
|
+
_units = units.replace("eq", "mol")
|
|
1029
|
+
z = self.get_property(solute, "charge")
|
|
1030
|
+
if z == 0: # uncharged solutes have zero equiv concentration
|
|
1031
|
+
return ureg.Quantity(0, _units)
|
|
1032
|
+
else:
|
|
1033
|
+
_units = interpret_units(units)
|
|
1034
|
+
|
|
1035
|
+
# retrieve the number of moles of solute and its molecular weight
|
|
1036
|
+
try:
|
|
1037
|
+
moles = ureg.Quantity(self.components[solute], "mol")
|
|
1038
|
+
# if the solute is not present in the solution, we'll get a KeyError
|
|
819
1039
|
# In that case, the amount is zero
|
|
820
1040
|
except KeyError:
|
|
821
1041
|
try:
|
|
822
|
-
return 0
|
|
823
|
-
except
|
|
824
|
-
logger.error(
|
|
825
|
-
|
|
1042
|
+
return ureg.Quantity(0, _units)
|
|
1043
|
+
except DimensionalityError:
|
|
1044
|
+
self.logger.error(
|
|
1045
|
+
f"Unsupported unit {units} specified for zero-concentration solute {solute}. Returned 0."
|
|
1046
|
+
)
|
|
1047
|
+
return ureg.Quantity(0, "dimensionless")
|
|
826
1048
|
|
|
827
1049
|
# with pint unit conversions enabled, we just pass the unit to pint
|
|
828
1050
|
# the logic tests here ensure that only the required arguments are
|
|
829
|
-
# passed to pint for the unit conversion. This avoids
|
|
1051
|
+
# passed to pint for the unit conversion. This avoids unnecessary
|
|
830
1052
|
# function calls.
|
|
1053
|
+
if units == "count":
|
|
1054
|
+
return round((moles * ureg.N_A).to("dimensionless"), 0)
|
|
831
1055
|
if units == "fraction":
|
|
832
1056
|
return moles / (self.get_moles_solvent() + self.get_total_moles_solute())
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1057
|
+
mw = self.get_property(solute, "molecular_weight").to("g/mol")
|
|
1058
|
+
if units == "%":
|
|
1059
|
+
return moles.to("kg", "chem", mw=mw) / self.mass.to("kg") * 100
|
|
1060
|
+
qty = ureg.Quantity(_units)
|
|
1061
|
+
if _units in ["eq", "mol", "moles"] or qty.check("[substance]"):
|
|
1062
|
+
return z * moles.to(_units)
|
|
1063
|
+
if (
|
|
1064
|
+
_units in ["mol/L", "eq/L", "g/L", "mg/L", "ug/L"]
|
|
1065
|
+
or qty.check("[substance]/[length]**3")
|
|
1066
|
+
or qty.check("[mass]/[length]**3")
|
|
838
1067
|
):
|
|
839
|
-
return moles.to(
|
|
840
|
-
|
|
841
|
-
return moles.to(
|
|
842
|
-
|
|
843
|
-
return moles.to(
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
else:
|
|
847
|
-
logger.error("Unsupported unit specified for get_amount")
|
|
848
|
-
return None
|
|
1068
|
+
return z * moles.to(_units, "chem", mw=mw, volume=self.volume)
|
|
1069
|
+
if _units in ["mol/kg"] or qty.check("[substance]/[mass]") or qty.check("[mass]/[mass]"):
|
|
1070
|
+
return z * moles.to(_units, "chem", mw=mw, solvent_mass=self.solvent_mass)
|
|
1071
|
+
if _units in ["kg", "g"] or qty.check("[mass]"):
|
|
1072
|
+
return moles.to(_units, "chem", mw=mw)
|
|
1073
|
+
|
|
1074
|
+
raise ValueError(f"Unsupported unit {units} specified for get_amount")
|
|
849
1075
|
|
|
850
|
-
def
|
|
1076
|
+
def get_components_by_element(self) -> dict[str, list]:
|
|
851
1077
|
"""
|
|
852
|
-
Return
|
|
1078
|
+
Return a list of all species associated with a given element.
|
|
853
1079
|
|
|
854
|
-
|
|
855
|
-
----------
|
|
856
|
-
element : str
|
|
857
|
-
String representing the name of the element of interest
|
|
858
|
-
units : str
|
|
859
|
-
Units desired for the output. Examples of valid units are
|
|
860
|
-
'mol/L','mol/kg','mol', 'kg', and 'g/L'
|
|
1080
|
+
Elements (keys) are suffixed with their oxidation state in parentheses, e.g.,
|
|
861
1081
|
|
|
862
|
-
|
|
863
|
-
-------
|
|
864
|
-
The total amount of the element in the solution, in the specified units
|
|
1082
|
+
{"Na(1.0)":["Na[+1]", "NaOH(aq)"]}
|
|
865
1083
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1084
|
+
Species associated with each element are sorted in descending order of the amount
|
|
1085
|
+
present (i.e., the first species listed is the most abundant).
|
|
1086
|
+
"""
|
|
1087
|
+
d = {}
|
|
1088
|
+
# by sorting the components according to amount, we ensure that the species
|
|
1089
|
+
# are sorted in descending order of concentration in the resulting dict
|
|
1090
|
+
for s in self.components:
|
|
1091
|
+
# determine the element and oxidation state
|
|
1092
|
+
elements = self.get_property(s, "elements")
|
|
871
1093
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1094
|
+
for el in elements:
|
|
1095
|
+
try:
|
|
1096
|
+
oxi_states = self.get_property(s, "oxi_state_guesses")
|
|
1097
|
+
oxi_state = oxi_states.get(el, UNKNOWN_OXI_STATE)
|
|
1098
|
+
except (TypeError, IndexError):
|
|
1099
|
+
self.logger.error(f"No oxidation state found for element {el}. Assigning '{UNKNOWN_OXI_STATE}'")
|
|
1100
|
+
oxi_state = UNKNOWN_OXI_STATE
|
|
1101
|
+
key = f"{el}({oxi_state})"
|
|
1102
|
+
if d.get(key):
|
|
1103
|
+
d[key].append(s)
|
|
1104
|
+
else:
|
|
1105
|
+
d[key] = [s]
|
|
1106
|
+
|
|
1107
|
+
return d
|
|
1108
|
+
|
|
1109
|
+
def get_el_amt_dict(self):
|
|
875
1110
|
"""
|
|
876
|
-
|
|
1111
|
+
Return a dict of Element: amount in mol.
|
|
877
1112
|
|
|
878
|
-
|
|
1113
|
+
Elements (keys) are suffixed with their oxidation state in parentheses,
|
|
1114
|
+
e.g. "Fe(2.0)", "Cl(-1.0)".
|
|
1115
|
+
"""
|
|
1116
|
+
d = {}
|
|
1117
|
+
for s, mol in self.components.items():
|
|
1118
|
+
elements = self.get_property(s, "elements")
|
|
1119
|
+
pmg_ion_dict = self.get_property(s, "pmg_ion")
|
|
1120
|
+
oxi_states = self.get_property(s, "oxi_state_guesses")
|
|
879
1121
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1122
|
+
for el in elements:
|
|
1123
|
+
# stoichiometric coefficient, mol element per mol solute
|
|
1124
|
+
stoich = pmg_ion_dict.get(el)
|
|
1125
|
+
try:
|
|
1126
|
+
oxi_states = self.get_property(s, "oxi_state_guesses")
|
|
1127
|
+
oxi_state = oxi_states.get(el, UNKNOWN_OXI_STATE)
|
|
1128
|
+
except (TypeError, IndexError):
|
|
1129
|
+
self.logger.error(f"No oxidation state found for element {el}. Assigning '{UNKNOWN_OXI_STATE}'")
|
|
1130
|
+
oxi_state = UNKNOWN_OXI_STATE
|
|
1131
|
+
key = f"{el}({oxi_state})"
|
|
1132
|
+
if d.get(key):
|
|
1133
|
+
d[key] += stoich * mol
|
|
1134
|
+
else:
|
|
1135
|
+
d[key] = stoich * mol
|
|
1136
|
+
|
|
1137
|
+
return d
|
|
1138
|
+
|
|
1139
|
+
def get_total_amount(self, element: str, units: str) -> Quantity:
|
|
1140
|
+
"""
|
|
1141
|
+
Return the total amount of 'element' (across all solutes) in the solution.
|
|
1142
|
+
|
|
1143
|
+
Args:
|
|
1144
|
+
element: The symbol of the element of interest. The symbol can optionally be followed by the
|
|
1145
|
+
oxidation state in parentheses, e.g., "Na(1.0)", "Fe(2.0)", or "O(0.0)". If no oxidation state
|
|
1146
|
+
is given, the total concentration of the element (over all oxidation states) is returned.
|
|
1147
|
+
units : str
|
|
1148
|
+
Units desired for the output. Any unit understood by `get_amount` can be used. Examples of valid
|
|
1149
|
+
units are 'mol/L','mol/kg','mol', 'kg', and 'g/L'.
|
|
1150
|
+
|
|
1151
|
+
Returns:
|
|
1152
|
+
The total amount of the element in the solution, in the specified units
|
|
1153
|
+
|
|
1154
|
+
See Also:
|
|
1155
|
+
:meth:`get_amount`
|
|
1156
|
+
:func:`pyEQL.utils.interpret_units`
|
|
1157
|
+
"""
|
|
1158
|
+
TOT: Quantity = 0
|
|
1159
|
+
|
|
1160
|
+
# standardize the element formula and units
|
|
1161
|
+
el = str(Element(element.split("(")[0]))
|
|
1162
|
+
units = interpret_units(units)
|
|
1163
|
+
|
|
1164
|
+
# enumerate the species whose concentrations we need
|
|
1165
|
+
comp_by_element = self.get_components_by_element()
|
|
1166
|
+
|
|
1167
|
+
# compile list of species in different ways depending whether there is an oxidation state
|
|
1168
|
+
if "(" in element and UNKNOWN_OXI_STATE not in element:
|
|
1169
|
+
ox = float(element.split("(")[-1].split(")")[0])
|
|
1170
|
+
key = f"{el}({ox})"
|
|
1171
|
+
species = comp_by_element.get(key)
|
|
1172
|
+
else:
|
|
1173
|
+
species = []
|
|
1174
|
+
for k, v in comp_by_element.items():
|
|
1175
|
+
if el in k:
|
|
1176
|
+
species.extend(v)
|
|
1177
|
+
|
|
1178
|
+
# loop through the species of interest, adding moles of element
|
|
1179
|
+
for item, amt in self.components.items():
|
|
1180
|
+
if item in species:
|
|
885
1181
|
amt = self.get_amount(item, units)
|
|
1182
|
+
ion = Ion.from_formula(item)
|
|
886
1183
|
|
|
887
1184
|
# convert the solute amount into the amount of element by
|
|
888
1185
|
# either the mole / mole or weight ratio
|
|
889
|
-
if
|
|
1186
|
+
if ureg.Quantity(units).dimensionality in (
|
|
890
1187
|
"[substance]",
|
|
891
1188
|
"[substance]/[length]**3",
|
|
892
1189
|
"[substance]/[mass]",
|
|
893
1190
|
):
|
|
894
|
-
TOT += amt *
|
|
1191
|
+
TOT += amt * ion.get_el_amt_dict()[el] # returns {el: mol per formula unit}
|
|
895
1192
|
|
|
896
|
-
elif
|
|
1193
|
+
elif ureg.Quantity(units).dimensionality in (
|
|
897
1194
|
"[mass]",
|
|
898
1195
|
"[mass]/[length]**3",
|
|
899
1196
|
"[mass]/[mass]",
|
|
900
1197
|
):
|
|
901
|
-
TOT += amt *
|
|
1198
|
+
TOT += amt * ion.to_weight_dict[el] # returns {el: wt fraction}
|
|
902
1199
|
|
|
903
1200
|
return TOT
|
|
904
1201
|
|
|
905
|
-
def
|
|
1202
|
+
def add_solute(self, formula: str, amount: str):
|
|
1203
|
+
"""Primary method for adding substances to a pyEQL solution.
|
|
1204
|
+
|
|
1205
|
+
Args:
|
|
1206
|
+
formula (str): Chemical formula for the solute. Charged species must contain a + or - and
|
|
1207
|
+
(for polyvalent solutes) a number representing the net charge (e.g. 'SO4-2').
|
|
1208
|
+
amount (str): The amount of substance in the specified unit system. The string should contain
|
|
1209
|
+
both a quantity and a pint-compatible representation of a ureg. e.g. '5 mol/kg' or '0.1 g/L'.
|
|
906
1210
|
"""
|
|
907
|
-
|
|
1211
|
+
# if units are given on a per-volume basis,
|
|
1212
|
+
# iteratively solve for the amount of solute that will preserve the
|
|
1213
|
+
# original volume and result in the desired concentration
|
|
1214
|
+
if ureg.Quantity(amount).dimensionality in (
|
|
1215
|
+
"[substance]/[length]**3",
|
|
1216
|
+
"[mass]/[length]**3",
|
|
1217
|
+
):
|
|
1218
|
+
# store the original volume for later
|
|
1219
|
+
orig_volume = self.volume
|
|
908
1220
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
String representing the concentration desired, e.g. '1 mol/kg'
|
|
915
|
-
If the units are given on a per-volume basis, the solution
|
|
916
|
-
volume is not recalculated
|
|
917
|
-
If the units are given on a mass, substance, per-mass, or
|
|
918
|
-
per-substance basis, then the solution volume is recalculated
|
|
919
|
-
based on the new composition
|
|
920
|
-
|
|
921
|
-
Returns
|
|
922
|
-
-------
|
|
923
|
-
Nothing. The concentration of solute is modified.
|
|
1221
|
+
# add the new solute
|
|
1222
|
+
quantity = ureg.Quantity(amount)
|
|
1223
|
+
mw = self.get_property(formula, "molecular_weight") # returns a quantity
|
|
1224
|
+
target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
|
|
1225
|
+
self.components[formula] = target_mol.to("moles").magnitude
|
|
924
1226
|
|
|
1227
|
+
# calculate the volume occupied by all the solutes
|
|
1228
|
+
solute_vol = self._get_solute_volume()
|
|
1229
|
+
|
|
1230
|
+
# determine the volume of solvent that will preserve the original volume
|
|
1231
|
+
target_vol = orig_volume - solute_vol
|
|
925
1232
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1233
|
+
# adjust the amount of solvent
|
|
1234
|
+
# density is returned in kg/m3 = g/L
|
|
1235
|
+
target_mass = target_vol * ureg.Quantity(self.water_substance.rho, "g/L")
|
|
1236
|
+
# mw = ureg.Quantity(self.get_property(self.solvent_name, "molecular_weight"))
|
|
1237
|
+
mw = self.get_property(self.solvent, "molecular_weight")
|
|
1238
|
+
if mw is None:
|
|
1239
|
+
raise ValueError(f"Molecular weight for solvent {self.solvent} not found in database. Cannot proceed.")
|
|
1240
|
+
target_mol = target_mass.to("g") / mw.to("g/mol")
|
|
1241
|
+
self.components[self.solvent] = target_mol.magnitude
|
|
1242
|
+
|
|
1243
|
+
else:
|
|
1244
|
+
# add the new solute
|
|
1245
|
+
quantity = ureg.Quantity(amount)
|
|
1246
|
+
mw = ureg.Quantity(self.get_property(formula, "molecular_weight"))
|
|
1247
|
+
target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
|
|
1248
|
+
self.components[formula] = target_mol.to("moles").magnitude
|
|
1249
|
+
|
|
1250
|
+
# update the volume to account for the space occupied by all the solutes
|
|
1251
|
+
# make sure that there is still solvent present in the first place
|
|
1252
|
+
if self.solvent_mass <= ureg.Quantity(0, "kg"):
|
|
1253
|
+
self.logger.error("All solvent has been depleted from the solution")
|
|
1254
|
+
return
|
|
1255
|
+
# set the volume recalculation flag
|
|
1256
|
+
self.volume_update_required = True
|
|
1257
|
+
|
|
1258
|
+
# TODO - deprecate this method. Solvent should be added to the dict like anything else
|
|
1259
|
+
# and solvent_name will track which component it is.
|
|
1260
|
+
def add_solvent(self, formula: str, amount: str):
|
|
1261
|
+
"""Same as add_solute but omits the need to pass solvent mass to pint."""
|
|
1262
|
+
quantity = ureg.Quantity(amount)
|
|
1263
|
+
mw = self.get_property(formula, "molecular_weight")
|
|
1264
|
+
target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
|
|
1265
|
+
self.components[formula] = target_mol.to("moles").magnitude
|
|
1266
|
+
|
|
1267
|
+
def add_amount(self, solute: str, amount: str):
|
|
929
1268
|
"""
|
|
1269
|
+
Add the amount of 'solute' to the parent solution.
|
|
1270
|
+
|
|
1271
|
+
Args:
|
|
1272
|
+
solute : str
|
|
1273
|
+
String representing the name of the solute of interest
|
|
1274
|
+
amount : str quantity
|
|
1275
|
+
String representing the concentration desired, e.g. '1 mol/kg'
|
|
1276
|
+
If the units are given on a per-volume basis, the solution
|
|
1277
|
+
volume is not recalculated
|
|
1278
|
+
If the units are given on a mass, substance, per-mass, or
|
|
1279
|
+
per-substance basis, then the solution volume is recalculated
|
|
1280
|
+
based on the new composition
|
|
930
1281
|
|
|
1282
|
+
Returns:
|
|
1283
|
+
Nothing. The concentration of solute is modified.
|
|
1284
|
+
"""
|
|
931
1285
|
# if units are given on a per-volume basis,
|
|
932
1286
|
# iteratively solve for the amount of solute that will preserve the
|
|
933
1287
|
# original volume and result in the desired concentration
|
|
934
|
-
if
|
|
1288
|
+
if ureg.Quantity(amount).dimensionality in (
|
|
935
1289
|
"[substance]/[length]**3",
|
|
936
1290
|
"[mass]/[length]**3",
|
|
937
1291
|
):
|
|
938
|
-
|
|
939
1292
|
# store the original volume for later
|
|
940
|
-
orig_volume = self.
|
|
1293
|
+
orig_volume = self.volume
|
|
941
1294
|
|
|
942
1295
|
# change the amount of the solute present to match the desired amount
|
|
943
|
-
self.
|
|
944
|
-
|
|
1296
|
+
self.components[solute] += (
|
|
1297
|
+
ureg.Quantity(amount)
|
|
1298
|
+
.to(
|
|
1299
|
+
"moles",
|
|
1300
|
+
"chem",
|
|
1301
|
+
mw=self.get_property(solute, "molecular_weight"),
|
|
1302
|
+
volume=self.volume,
|
|
1303
|
+
solvent_mass=self.solvent_mass,
|
|
1304
|
+
)
|
|
1305
|
+
.magnitude
|
|
945
1306
|
)
|
|
946
1307
|
|
|
947
1308
|
# set the amount to zero and log a warning if the desired amount
|
|
948
1309
|
# change would result in a negative concentration
|
|
949
1310
|
if self.get_amount(solute, "mol").magnitude < 0:
|
|
950
|
-
logger.
|
|
951
|
-
"Attempted to set a negative concentration for solute %s. Concentration set to 0"
|
|
952
|
-
% solute
|
|
1311
|
+
self.logger.error(
|
|
1312
|
+
"Attempted to set a negative concentration for solute %s. Concentration set to 0" % solute
|
|
953
1313
|
)
|
|
954
1314
|
self.set_amount(solute, "0 mol")
|
|
955
1315
|
|
|
@@ -960,87 +1320,93 @@ class Solution:
|
|
|
960
1320
|
target_vol = orig_volume - solute_vol
|
|
961
1321
|
|
|
962
1322
|
# adjust the amount of solvent
|
|
963
|
-
|
|
964
|
-
|
|
1323
|
+
# volume in L, density in kg/m3 = g/L
|
|
1324
|
+
target_mass = target_vol * ureg.Quantity(self.water_substance.rho, "g/L")
|
|
1325
|
+
|
|
1326
|
+
mw = self.get_property(self.solvent, "molecular_weight")
|
|
965
1327
|
target_mol = target_mass / mw
|
|
966
|
-
self.
|
|
1328
|
+
self.components[self.solvent] = target_mol.magnitude
|
|
967
1329
|
|
|
968
1330
|
else:
|
|
969
|
-
|
|
970
1331
|
# change the amount of the solute present
|
|
971
|
-
self.
|
|
972
|
-
|
|
1332
|
+
self.components[solute] += (
|
|
1333
|
+
ureg.Quantity(amount)
|
|
1334
|
+
.to(
|
|
1335
|
+
"moles",
|
|
1336
|
+
"chem",
|
|
1337
|
+
mw=self.get_property(solute, "molecular_weight"),
|
|
1338
|
+
volume=self.volume,
|
|
1339
|
+
solvent_mass=self.solvent_mass,
|
|
1340
|
+
)
|
|
1341
|
+
.magnitude
|
|
973
1342
|
)
|
|
974
1343
|
|
|
975
1344
|
# set the amount to zero and log a warning if the desired amount
|
|
976
1345
|
# change would result in a negative concentration
|
|
977
1346
|
if self.get_amount(solute, "mol").magnitude < 0:
|
|
978
|
-
logger.
|
|
979
|
-
"Attempted to set a negative concentration for solute %s. Concentration set to 0"
|
|
980
|
-
% solute
|
|
1347
|
+
self.logger.error(
|
|
1348
|
+
"Attempted to set a negative concentration for solute %s. Concentration set to 0" % solute
|
|
981
1349
|
)
|
|
982
1350
|
self.set_amount(solute, "0 mol")
|
|
983
1351
|
|
|
984
1352
|
# update the volume to account for the space occupied by all the solutes
|
|
985
1353
|
# make sure that there is still solvent present in the first place
|
|
986
|
-
if self.
|
|
987
|
-
logger.error("All solvent has been depleted from the solution")
|
|
988
|
-
return
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1354
|
+
if self.solvent_mass <= ureg.Quantity(0, "kg"):
|
|
1355
|
+
self.logger.error("All solvent has been depleted from the solution")
|
|
1356
|
+
return
|
|
1357
|
+
|
|
1358
|
+
# set the volume recalculation flag
|
|
1359
|
+
self.volume_update_required = True
|
|
992
1360
|
|
|
993
|
-
def set_amount(self, solute, amount):
|
|
1361
|
+
def set_amount(self, solute: str, amount: str):
|
|
994
1362
|
"""
|
|
995
1363
|
Set the amount of 'solute' in the parent solution.
|
|
996
1364
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
are modified.
|
|
1013
|
-
|
|
1014
|
-
Returns
|
|
1015
|
-
-------
|
|
1016
|
-
Nothing. The concentration of solute is modified.
|
|
1365
|
+
Args:
|
|
1366
|
+
solute : str
|
|
1367
|
+
String representing the name of the solute of interest
|
|
1368
|
+
amount : str Quantity
|
|
1369
|
+
String representing the concentration desired, e.g. '1 mol/kg'
|
|
1370
|
+
If the units are given on a per-volume basis, the solution
|
|
1371
|
+
volume is not recalculated and the molar concentrations of
|
|
1372
|
+
other components in the solution are not altered, while the
|
|
1373
|
+
molal concentrations are modified.
|
|
1374
|
+
|
|
1375
|
+
If the units are given on a mass, substance, per-mass, or
|
|
1376
|
+
per-substance basis, then the solution volume is recalculated
|
|
1377
|
+
based on the new composition and the molal concentrations of
|
|
1378
|
+
other components are not altered, while the molar concentrations
|
|
1379
|
+
are modified.
|
|
1017
1380
|
|
|
1381
|
+
Returns:
|
|
1382
|
+
Nothing. The concentration of solute is modified.
|
|
1018
1383
|
|
|
1019
|
-
See Also
|
|
1020
|
-
--------
|
|
1021
|
-
Solute.set_moles
|
|
1022
1384
|
"""
|
|
1023
1385
|
# raise an error if a negative amount is specified
|
|
1024
|
-
if
|
|
1025
|
-
|
|
1026
|
-
"Negative amount specified for solute %s. Concentration not changed."
|
|
1027
|
-
% solute
|
|
1028
|
-
)
|
|
1386
|
+
if ureg.Quantity(amount).magnitude < 0:
|
|
1387
|
+
raise ValueError(f"Negative amount specified for solute {solute}. Concentration not changed.")
|
|
1029
1388
|
|
|
1030
1389
|
# if units are given on a per-volume basis,
|
|
1031
1390
|
# iteratively solve for the amount of solute that will preserve the
|
|
1032
1391
|
# original volume and result in the desired concentration
|
|
1033
|
-
|
|
1392
|
+
if ureg.Quantity(amount).dimensionality in (
|
|
1034
1393
|
"[substance]/[length]**3",
|
|
1035
1394
|
"[mass]/[length]**3",
|
|
1036
1395
|
):
|
|
1037
|
-
|
|
1038
1396
|
# store the original volume for later
|
|
1039
|
-
orig_volume = self.
|
|
1397
|
+
orig_volume = self.volume
|
|
1040
1398
|
|
|
1041
1399
|
# change the amount of the solute present to match the desired amount
|
|
1042
|
-
self.
|
|
1043
|
-
|
|
1400
|
+
self.components[solute] = (
|
|
1401
|
+
ureg.Quantity(amount)
|
|
1402
|
+
.to(
|
|
1403
|
+
"moles",
|
|
1404
|
+
"chem",
|
|
1405
|
+
mw=ureg.Quantity(self.get_property(solute, "molecular_weight")),
|
|
1406
|
+
volume=self.volume,
|
|
1407
|
+
solvent_mass=self.solvent_mass,
|
|
1408
|
+
)
|
|
1409
|
+
.magnitude
|
|
1044
1410
|
)
|
|
1045
1411
|
|
|
1046
1412
|
# calculate the volume occupied by all the solutes
|
|
@@ -1050,105 +1416,85 @@ class Solution:
|
|
|
1050
1416
|
target_vol = orig_volume - solute_vol
|
|
1051
1417
|
|
|
1052
1418
|
# adjust the amount of solvent
|
|
1053
|
-
target_mass = target_vol *
|
|
1054
|
-
mw = self.
|
|
1419
|
+
target_mass = target_vol * ureg.Quantity(self.water_substance.rho, "g/L")
|
|
1420
|
+
mw = self.get_property(self.solvent, "molecular_weight")
|
|
1055
1421
|
target_mol = target_mass / mw
|
|
1056
|
-
self.
|
|
1422
|
+
self.components[self.solvent] = target_mol.to("mol").magnitude
|
|
1057
1423
|
|
|
1058
1424
|
else:
|
|
1059
|
-
|
|
1060
1425
|
# change the amount of the solute present
|
|
1061
|
-
self.
|
|
1062
|
-
|
|
1426
|
+
self.components[solute] = (
|
|
1427
|
+
ureg.Quantity(amount)
|
|
1428
|
+
.to(
|
|
1429
|
+
"moles",
|
|
1430
|
+
"chem",
|
|
1431
|
+
mw=ureg.Quantity(self.get_property(solute, "molecular_weight")),
|
|
1432
|
+
volume=self.volume,
|
|
1433
|
+
solvent_mass=self.solvent_mass,
|
|
1434
|
+
)
|
|
1435
|
+
.magnitude
|
|
1063
1436
|
)
|
|
1064
1437
|
|
|
1065
1438
|
# update the volume to account for the space occupied by all the solutes
|
|
1066
1439
|
# make sure that there is still solvent present in the first place
|
|
1067
|
-
if self.
|
|
1068
|
-
logger.
|
|
1069
|
-
return
|
|
1070
|
-
else:
|
|
1071
|
-
self._update_volume()
|
|
1440
|
+
if self.solvent_mass <= ureg.Quantity(0, "kg"):
|
|
1441
|
+
self.logger.critical("All solvent has been depleted from the solution")
|
|
1442
|
+
return
|
|
1072
1443
|
|
|
1073
|
-
|
|
1074
|
-
"""Return the osmolarity of the solution in Osm/L
|
|
1444
|
+
self._update_volume()
|
|
1075
1445
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1446
|
+
def get_total_moles_solute(self) -> Quantity:
|
|
1447
|
+
"""Return the total moles of all solute in the solution."""
|
|
1448
|
+
tot_mol = 0
|
|
1449
|
+
for item in self.components:
|
|
1450
|
+
if item != self.solvent:
|
|
1451
|
+
tot_mol += self.components[item]
|
|
1452
|
+
return ureg.Quantity(tot_mol, "mol")
|
|
1453
|
+
|
|
1454
|
+
def get_moles_solvent(self) -> Quantity:
|
|
1455
|
+
"""
|
|
1456
|
+
Return the moles of solvent present in the solution.
|
|
1457
|
+
|
|
1458
|
+
Returns:
|
|
1459
|
+
The moles of solvent in the solution.
|
|
1460
|
+
|
|
1461
|
+
"""
|
|
1462
|
+
return self.get_amount(self.solvent, "mol")
|
|
1463
|
+
|
|
1464
|
+
def get_osmolarity(self, activity_correction=False) -> Quantity:
|
|
1465
|
+
"""Return the osmolarity of the solution in Osm/L.
|
|
1466
|
+
|
|
1467
|
+
Args:
|
|
1468
|
+
activity_correction : bool
|
|
1079
1469
|
If TRUE, the osmotic coefficient is used to calculate the
|
|
1080
1470
|
osmolarity. This correction is appropriate when trying to predict
|
|
1081
1471
|
the osmolarity that would be measured from e.g. freezing point
|
|
1082
1472
|
depression. Defaults to FALSE if omitted.
|
|
1083
1473
|
"""
|
|
1084
|
-
if activity_correction is True
|
|
1085
|
-
|
|
1086
|
-
else:
|
|
1087
|
-
factor = 1
|
|
1088
|
-
return factor * self.get_total_moles_solute() / self.get_volume().to("L")
|
|
1474
|
+
factor = self.get_osmotic_coefficient() if activity_correction is True else 1
|
|
1475
|
+
return factor * self.get_total_moles_solute() / self.volume.to("L")
|
|
1089
1476
|
|
|
1090
|
-
def get_osmolality(self, activity_correction=False):
|
|
1091
|
-
"""Return the osmolality of the solution in Osm/kg
|
|
1477
|
+
def get_osmolality(self, activity_correction=False) -> Quantity:
|
|
1478
|
+
"""Return the osmolality of the solution in Osm/kg.
|
|
1092
1479
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
activity_correction : bool
|
|
1480
|
+
Args:
|
|
1481
|
+
activity_correction : bool
|
|
1096
1482
|
If TRUE, the osmotic coefficient is used to calculate the
|
|
1097
1483
|
osmolarity. This correction is appropriate when trying to predict
|
|
1098
1484
|
the osmolarity that would be measured from e.g. freezing point
|
|
1099
1485
|
depression. Defaults to FALSE if omitted.
|
|
1100
1486
|
"""
|
|
1101
|
-
if activity_correction is True
|
|
1102
|
-
|
|
1103
|
-
else:
|
|
1104
|
-
factor = 1
|
|
1105
|
-
return factor * self.get_total_moles_solute() / self.get_solvent_mass().to("kg")
|
|
1106
|
-
|
|
1107
|
-
def get_total_moles_solute(self):
|
|
1108
|
-
"""Return the total moles of all solute in the solution"""
|
|
1109
|
-
tot_mol = 0
|
|
1110
|
-
for item in self.components:
|
|
1111
|
-
if item != self.solvent_name:
|
|
1112
|
-
tot_mol += self.components[item].get_moles()
|
|
1113
|
-
return tot_mol
|
|
1114
|
-
|
|
1115
|
-
def get_mole_fraction(self, solute):
|
|
1116
|
-
"""
|
|
1117
|
-
Return the mole fraction of 'solute' in the solution
|
|
1118
|
-
|
|
1119
|
-
Notes
|
|
1120
|
-
-----
|
|
1121
|
-
This function is DEPRECATED and will raise a warning when called.
|
|
1122
|
-
Use get_amount() instead and specify 'fraction' as the unit type.
|
|
1123
|
-
"""
|
|
1124
|
-
logger.warning("get_mole_fraction is DEPRECATED! Use get_amount() instead.")
|
|
1125
|
-
return self.get_amount(solute, "fraction")
|
|
1126
|
-
|
|
1127
|
-
def get_moles_solvent(self):
|
|
1128
|
-
"""
|
|
1129
|
-
Return the moles of solvent present in the solution
|
|
1130
|
-
|
|
1131
|
-
Parameters
|
|
1132
|
-
----------
|
|
1133
|
-
None
|
|
1134
|
-
|
|
1135
|
-
Returns
|
|
1136
|
-
-------
|
|
1137
|
-
Quantity
|
|
1138
|
-
The moles of solvent in the solution.
|
|
1139
|
-
|
|
1140
|
-
"""
|
|
1141
|
-
|
|
1142
|
-
return self.get_amount(self.solvent_name, "mol")
|
|
1487
|
+
factor = self.get_osmotic_coefficient() if activity_correction is True else 1
|
|
1488
|
+
return factor * self.get_total_moles_solute() / self.solvent_mass.to("kg")
|
|
1143
1489
|
|
|
1144
|
-
def get_salt(self):
|
|
1490
|
+
def get_salt(self) -> Salt:
|
|
1145
1491
|
"""
|
|
1146
1492
|
Determine the predominant salt in a solution of ions.
|
|
1147
1493
|
|
|
1148
1494
|
Many empirical equations for solution properties such as activity coefficient,
|
|
1149
1495
|
partial molar volume, or viscosity are based on the concentration of
|
|
1150
1496
|
single salts (e.g., NaCl). When multiple ions are present (e.g., a solution
|
|
1151
|
-
containing Na+, Cl-, and Mg+2), it is generally not possible to
|
|
1497
|
+
containing Na+, Cl-, and Mg+2), it is generally not possible to directly model
|
|
1152
1498
|
these quantities. pyEQL works around this problem by treating such solutions
|
|
1153
1499
|
as single salt solutions.
|
|
1154
1500
|
|
|
@@ -1156,1444 +1502,1201 @@ class Solution:
|
|
|
1156
1502
|
an object that identifies the single most predominant salt in the solution, defined
|
|
1157
1503
|
by the cation and anion with the highest mole fraction. The Salt object contains
|
|
1158
1504
|
information about the stoichiometry of the salt to enable its effective concentration
|
|
1159
|
-
to be calculated (e.g.,
|
|
1505
|
+
to be calculated (e.g., if a solution contains 0.5 mol/kg of Na+ and Cl-, plus traces
|
|
1506
|
+
of H+ and OH-, the matched salt is 0.5 mol/kg NaCl).
|
|
1160
1507
|
|
|
1161
|
-
|
|
1162
|
-
----------
|
|
1163
|
-
None
|
|
1164
|
-
|
|
1165
|
-
Returns
|
|
1166
|
-
-------
|
|
1167
|
-
Salt
|
|
1508
|
+
Returns:
|
|
1168
1509
|
Salt object containing information about the parent salt.
|
|
1169
1510
|
|
|
1170
|
-
See Also
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
>>> s2.get_salt().z_cation
|
|
1197
|
-
2
|
|
1198
|
-
"""
|
|
1199
|
-
# identify the predominant salt in the solution
|
|
1200
|
-
import pyEQL.salt_ion_match as salt
|
|
1201
|
-
|
|
1202
|
-
return salt.identify_salt(self)
|
|
1203
|
-
|
|
1204
|
-
def get_salt_list(self):
|
|
1511
|
+
See Also:
|
|
1512
|
+
:py:meth:`get_activity`
|
|
1513
|
+
:py:meth:`get_activity_coefficient`
|
|
1514
|
+
:py:meth:`get_water_activity`
|
|
1515
|
+
:py:meth:`get_osmotic_coefficient`
|
|
1516
|
+
:py:attr:`osmotic_pressure`
|
|
1517
|
+
:py:attr:`viscosity_kinematic`
|
|
1518
|
+
|
|
1519
|
+
Examples:
|
|
1520
|
+
>>> s1 = Solution([['Na+','0.5 mol/kg'],['Cl-','0.5 mol/kg']])
|
|
1521
|
+
>>> s1.get_salt()
|
|
1522
|
+
<pyEQL.salt_ion_match.Salt object at 0x7fe6d3542048>
|
|
1523
|
+
>>> s1.get_salt().formula
|
|
1524
|
+
'NaCl'
|
|
1525
|
+
>>> s1.get_salt().nu_cation
|
|
1526
|
+
1
|
|
1527
|
+
>>> s1.get_salt().z_anion
|
|
1528
|
+
-1
|
|
1529
|
+
|
|
1530
|
+
>>> s2 = pyEQL.Solution([['Na+','0.1 mol/kg'],['Mg+2','0.2 mol/kg'],['Cl-','0.5 mol/kg']])
|
|
1531
|
+
>>> s2.get_salt().formula
|
|
1532
|
+
'MgCl2'
|
|
1533
|
+
>>> s2.get_salt().nu_anion
|
|
1534
|
+
2
|
|
1535
|
+
>>> s2.get_salt().z_cation
|
|
1536
|
+
2
|
|
1205
1537
|
"""
|
|
1206
|
-
|
|
1538
|
+
d = self.get_salt_dict()
|
|
1539
|
+
first_key = next(iter(d.keys()))
|
|
1540
|
+
return Salt(d[first_key]["cation"], d[first_key]["anion"])
|
|
1541
|
+
|
|
1542
|
+
# TODO - modify? deprecate? make a salts property?
|
|
1543
|
+
def get_salt_dict(self, cutoff: float = 0.01, use_totals: bool = True) -> dict[str, dict]:
|
|
1544
|
+
"""
|
|
1545
|
+
Returns a dict of salts that approximates the composition of the Solution. Like `components`, the dict is
|
|
1546
|
+
keyed by formula and the values are the total moles present in the solution, e.g., {"NaCl(aq)": 1}. If the
|
|
1547
|
+
Solution is pure water, the returned dict contains only 'HOH'.
|
|
1548
|
+
|
|
1549
|
+
Args:
|
|
1550
|
+
cutoff: Lowest salt concentration to consider. Analysis will stop once the concentrations of Salts being
|
|
1551
|
+
analyzed goes below this value. Useful for excluding analysis of trace anions.
|
|
1552
|
+
use_totals: Whether to base the analysis on total element concentrations or individual species
|
|
1553
|
+
concentrations.
|
|
1554
|
+
|
|
1555
|
+
Notes:
|
|
1556
|
+
Salts are identified by pairing the predominant cations and anions in the solution, in descending order
|
|
1557
|
+
of their respective equivalent amounts.
|
|
1207
1558
|
|
|
1208
1559
|
Many empirical equations for solution properties such as activity coefficient,
|
|
1209
1560
|
partial molar volume, or viscosity are based on the concentration of
|
|
1210
1561
|
single salts (e.g., NaCl). When multiple ions are present (e.g., a solution
|
|
1211
|
-
containing Na+, Cl-, and Mg+2), it is generally not possible to
|
|
1562
|
+
containing Na+, Cl-, and Mg+2), it is generally not possible to directly model
|
|
1212
1563
|
these quantities.
|
|
1213
1564
|
|
|
1214
|
-
The
|
|
1215
|
-
simplifies it into a list of salts. The method
|
|
1565
|
+
The get_salt_dict() method examines the ionic composition of a solution and
|
|
1566
|
+
simplifies it into a list of salts. The method returns a dictionary of
|
|
1216
1567
|
Salt objects where the keys are the salt formulas (e.g., 'NaCl'). The
|
|
1217
1568
|
Salt object contains information about the stoichiometry of the salt to
|
|
1218
1569
|
enable its effective concentration to be calculated
|
|
1219
1570
|
(e.g., 1 M MgCl2 yields 1 M Mg+2 and 2 M Cl-).
|
|
1220
1571
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
The ionic strength, activity coefficients, and activities are all
|
|
1259
|
-
calculated based on the molal (mol/kg) concentration scale. If a different
|
|
1260
|
-
scale is given as input, then the molal-scale activity coefficient :math:`\\gamma_\\pm` is
|
|
1261
|
-
converted according to [#]_
|
|
1262
|
-
|
|
1263
|
-
.. math:: f_\\pm = \\gamma_\\pm * (1 + M_w \\sum_i \\nu_i \\m_i)
|
|
1264
|
-
|
|
1265
|
-
.. math:: y_\\pm = m \\rho_w / C \\gamma_\\pm
|
|
1266
|
-
|
|
1267
|
-
where :math:`f_\\pm` is the rational activity coefficient, :math:`M_w` is
|
|
1268
|
-
the molecular weight of water, the summation represents the total molality of
|
|
1269
|
-
all solute species, :math:`y_\\pm` is the molar activity coefficient,
|
|
1270
|
-
:math:`\\rho_w` is the density of pure water, :math:`m` and :math:`C` are
|
|
1271
|
-
the molal and molar concentrations of the chosen salt (not individual solute),
|
|
1272
|
-
respectively.
|
|
1273
|
-
|
|
1274
|
-
Parameters
|
|
1275
|
-
----------
|
|
1276
|
-
solute : str
|
|
1277
|
-
String representing the name of the solute of interest
|
|
1278
|
-
scale : str, optional
|
|
1279
|
-
The concentration scale for the returned activity coefficient.
|
|
1280
|
-
Valid options are "molal", "molar", and "rational" (i.e., mole fraction).
|
|
1281
|
-
By default, the molal scale activity coefficient is returned.
|
|
1282
|
-
verbose : bool, optional
|
|
1283
|
-
If True, pyEQL will print a message indicating the parent salt
|
|
1284
|
-
that is being used for activity calculations. This option is
|
|
1285
|
-
useful when modeling multicomponent solutions. False by default.
|
|
1286
|
-
|
|
1287
|
-
Returns
|
|
1288
|
-
-------
|
|
1289
|
-
The mean ion activity coefficient of the solute in question on the selected scale.
|
|
1290
|
-
|
|
1291
|
-
See Also
|
|
1292
|
-
--------
|
|
1293
|
-
get_ionic_strength
|
|
1294
|
-
get_salt
|
|
1295
|
-
activity_correction.get_activity_coefficient_debyehuckel
|
|
1296
|
-
activity_correction.get_activity_coefficient_guntelberg
|
|
1297
|
-
activity_correction.get_activity_coefficient_davies
|
|
1298
|
-
activity_correction.get_activity_coefficient_pitzer
|
|
1299
|
-
|
|
1300
|
-
Notes
|
|
1301
|
-
-----
|
|
1302
|
-
For multicomponent mixtures, pyEQL implements the "effective Pitzer model"
|
|
1303
|
-
presented by Mistry et al. [#]_. In this model, the activity coefficient
|
|
1304
|
-
of a salt in a multicomponent mixture is calculated using an "effective
|
|
1305
|
-
molality," which is the molality that would result in a single-salt
|
|
1306
|
-
mixture with the same total ionic strength as the multicomponent solution.
|
|
1307
|
-
|
|
1308
|
-
.. math:: m_effective = 2 I \\over (\\nu_+ z_+^2 + \\nu_- z_- ^2)
|
|
1309
|
-
|
|
1310
|
-
References
|
|
1311
|
-
----------
|
|
1312
|
-
.. [#] May, P. M., Rowland, D., Hefter, G., & Königsberger, E. (2011).
|
|
1313
|
-
A Generic and Updatable Pitzer Characterization of Aqueous Binary Electrolyte Solutions at 1 bar and
|
|
1314
|
-
25 °C. *Journal of Chemical & Engineering Data*, 56(12), 5066–5077. doi:10.1021/je2009329
|
|
1315
|
-
|
|
1316
|
-
.. [#] Stumm, Werner and Morgan, James J. *Aquatic Chemistry*, 3rd ed,
|
|
1317
|
-
pp 165. Wiley Interscience, 1996.
|
|
1318
|
-
|
|
1319
|
-
.. [#] Robinson, R. A.; Stokes, R. H. Electrolyte Solutions: Second Revised
|
|
1320
|
-
Edition; Butterworths: London, 1968, p.32.
|
|
1321
|
-
|
|
1322
|
-
"""
|
|
1323
|
-
ion = self.components[solute]
|
|
1324
|
-
temperature = str(self.get_temperature())
|
|
1325
|
-
|
|
1326
|
-
# return zero activity if the concentration of the solute is zero
|
|
1327
|
-
if self.get_amount(solute, "mol").magnitude == 0:
|
|
1328
|
-
return unit("1 dimensionless")
|
|
1572
|
+
Returns:
|
|
1573
|
+
dict
|
|
1574
|
+
A dictionary of Salt objects, keyed to the salt formula
|
|
1575
|
+
|
|
1576
|
+
See Also:
|
|
1577
|
+
:py:attr:`osmotic_pressure`
|
|
1578
|
+
:py:attr:`viscosity_kinematic`
|
|
1579
|
+
:py:meth:`get_activity`
|
|
1580
|
+
:py:meth:`get_activity_coefficient`
|
|
1581
|
+
:py:meth:`get_water_activity`
|
|
1582
|
+
:py:meth:`get_osmotic_coefficient`
|
|
1583
|
+
"""
|
|
1584
|
+
"""
|
|
1585
|
+
Returns a dict of salts that approximates the composition of the Solution. Like `components`, the dict is
|
|
1586
|
+
keyed by formula and the values are the total moles of salt present in the solution, e.g., {"NaCl(aq)": 1}
|
|
1587
|
+
|
|
1588
|
+
Notes:
|
|
1589
|
+
Salts are identified by pairing the predominant cations and anions in the solution, in descending order
|
|
1590
|
+
of their respective equivalent amounts.
|
|
1591
|
+
|
|
1592
|
+
See Also:
|
|
1593
|
+
:attr:`components`
|
|
1594
|
+
:attr:`cations`
|
|
1595
|
+
:attr:`anions`
|
|
1596
|
+
"""
|
|
1597
|
+
salt_dict: dict[str, float] = {}
|
|
1598
|
+
|
|
1599
|
+
if use_totals:
|
|
1600
|
+
# # use only the predominant species for each element
|
|
1601
|
+
components = {}
|
|
1602
|
+
for el, lst in self.get_components_by_element().items():
|
|
1603
|
+
components[lst[0]] = self.get_total_amount(el, "mol").magnitude
|
|
1604
|
+
# add H+ and OH-, which would otherwise be excluded
|
|
1605
|
+
for k in ["H[+1]", "OH[-1]"]:
|
|
1606
|
+
if self.components.get(k):
|
|
1607
|
+
components[k] = self.components[k]
|
|
1329
1608
|
else:
|
|
1609
|
+
components = self.components
|
|
1610
|
+
components = dict(sorted(components.items(), key=lambda x: x[1], reverse=True))
|
|
1611
|
+
|
|
1612
|
+
# warn if something other than water is the predominant component
|
|
1613
|
+
if next(iter(components)) != "H2O(aq)":
|
|
1614
|
+
self.logger.warning("H2O(aq) is not the most prominent component in this Solution!")
|
|
1615
|
+
|
|
1616
|
+
# equivalents (charge-weighted moles) of cations and anions
|
|
1617
|
+
cations = set(self.cations.keys()).intersection(components.keys())
|
|
1618
|
+
anions = set(self.anions.keys()).intersection(components.keys())
|
|
1619
|
+
|
|
1620
|
+
# calculate the charge-weighted (equivalent) concentration of each ion
|
|
1621
|
+
cation_equiv = {k: self.get_property(k, "charge") * components[k] for k in cations}
|
|
1622
|
+
anion_equiv = {
|
|
1623
|
+
k: -1 * self.get_property(k, "charge") * components[k] for k in anions
|
|
1624
|
+
} # make sure amounts are positive
|
|
1625
|
+
|
|
1626
|
+
# sort in descending order of equivalent concentration
|
|
1627
|
+
cation_equiv = dict(sorted(cation_equiv.items(), key=lambda x: x[1], reverse=True))
|
|
1628
|
+
anion_equiv = dict(sorted(anion_equiv.items(), key=lambda x: x[1], reverse=True))
|
|
1629
|
+
|
|
1630
|
+
len_cat = len(cation_equiv)
|
|
1631
|
+
len_an = len(anion_equiv)
|
|
1632
|
+
|
|
1633
|
+
# Only ions are H+ and OH-; return a Salt represnting water (with no amount)
|
|
1634
|
+
if len_cat <= 1 and len_an <= 1 and self.solvent == "H2O(aq)":
|
|
1635
|
+
x = Salt("H[+1]", "OH[-1]")
|
|
1636
|
+
salt_dict.update({x.formula: x.as_dict()})
|
|
1637
|
+
salt_dict[x.formula]["mol"] = self.get_amount("H2O", "mol")
|
|
1638
|
+
return salt_dict
|
|
1639
|
+
|
|
1640
|
+
# start with the first cation and anion
|
|
1641
|
+
index_cat = 0
|
|
1642
|
+
index_an = 0
|
|
1643
|
+
|
|
1644
|
+
# list(dict) returns a list of [(key, value), ]
|
|
1645
|
+
cation_list = list(cation_equiv.items())
|
|
1646
|
+
anion_list = list(anion_equiv.items())
|
|
1647
|
+
|
|
1648
|
+
# calculate the equivalent concentrations of each ion
|
|
1649
|
+
c1 = cation_list[index_cat][-1]
|
|
1650
|
+
a1 = anion_list[index_an][-1]
|
|
1651
|
+
|
|
1652
|
+
while index_cat < len_cat and index_an < len_an:
|
|
1653
|
+
# if the cation concentration is greater, there will be leftover cations
|
|
1654
|
+
if c1 > a1:
|
|
1655
|
+
# create the salt
|
|
1656
|
+
x = Salt(cation_list[index_cat][0], anion_list[index_an][0])
|
|
1657
|
+
# there will be leftover cation, so use the anion amount
|
|
1658
|
+
salt_dict.update({x.formula: x.as_dict()})
|
|
1659
|
+
salt_dict[x.formula]["mol"] = a1 / abs(x.z_anion * x.nu_anion)
|
|
1660
|
+
# adjust the amounts of the respective ions
|
|
1661
|
+
c1 = c1 - a1
|
|
1662
|
+
# move to the next anion
|
|
1663
|
+
index_an += 1
|
|
1664
|
+
try:
|
|
1665
|
+
a1 = anion_list[index_an][-1]
|
|
1666
|
+
if a1 < cutoff:
|
|
1667
|
+
continue
|
|
1668
|
+
except IndexError:
|
|
1669
|
+
continue
|
|
1670
|
+
# if the anion concentration is greater, there will be leftover anions
|
|
1671
|
+
if c1 < a1:
|
|
1672
|
+
# create the salt
|
|
1673
|
+
x = Salt(cation_list[index_cat][0], anion_list[index_an][0])
|
|
1674
|
+
# there will be leftover anion, so use the cation amount
|
|
1675
|
+
salt_dict.update({x.formula: x.as_dict()})
|
|
1676
|
+
salt_dict[x.formula]["mol"] = c1 / x.z_cation * x.nu_cation
|
|
1677
|
+
# calculate the leftover cation amount
|
|
1678
|
+
a1 = a1 - c1
|
|
1679
|
+
# move to the next cation
|
|
1680
|
+
index_cat += 1
|
|
1681
|
+
try:
|
|
1682
|
+
a1 = cation_list[index_cat][-1]
|
|
1683
|
+
if a1 < cutoff:
|
|
1684
|
+
continue
|
|
1685
|
+
except IndexError:
|
|
1686
|
+
continue
|
|
1687
|
+
if np.isclose(c1, a1):
|
|
1688
|
+
# create the salt
|
|
1689
|
+
x = Salt(cation_list[index_cat][0], anion_list[index_an][0])
|
|
1690
|
+
# there will be nothing leftover, so it doesn't matter which ion you use
|
|
1691
|
+
salt_dict.update({x.formula: x.as_dict()})
|
|
1692
|
+
salt_dict[x.formula]["mol"] = c1 / x.z_cation * x.nu_cation
|
|
1693
|
+
# move to the next cation and anion
|
|
1694
|
+
index_an += 1
|
|
1695
|
+
index_cat += 1
|
|
1696
|
+
try:
|
|
1697
|
+
c1 = cation_list[index_cat][-1]
|
|
1698
|
+
a1 = anion_list[index_an][-1]
|
|
1699
|
+
if (c1 < cutoff) or (a1 < cutoff):
|
|
1700
|
+
continue
|
|
1701
|
+
except IndexError:
|
|
1702
|
+
continue
|
|
1330
1703
|
|
|
1331
|
-
|
|
1332
|
-
Salt = None
|
|
1333
|
-
salt_list = salt.generate_salt_list(self, unit="mol/kg")
|
|
1334
|
-
for item in salt_list:
|
|
1335
|
-
if solute == item.cation or solute == item.anion:
|
|
1336
|
-
Salt = item
|
|
1337
|
-
|
|
1338
|
-
# show an error if no salt can be found that contains the solute
|
|
1339
|
-
if Salt is None:
|
|
1340
|
-
logger.warning(
|
|
1341
|
-
"No salts found that contain solute %s. Returning unit activity coefficient."
|
|
1342
|
-
% solute
|
|
1343
|
-
)
|
|
1344
|
-
return unit("1 dimensionless")
|
|
1345
|
-
|
|
1346
|
-
# search the database for pitzer parameters for 'Salt'
|
|
1347
|
-
db.search_parameters(Salt.formula)
|
|
1348
|
-
|
|
1349
|
-
# use the Pitzer model for higher ionic strength, if the parameters are available
|
|
1350
|
-
|
|
1351
|
-
# search for Pitzer parameters
|
|
1352
|
-
if db.has_parameter(Salt.formula, "pitzer_parameters_activity"):
|
|
1353
|
-
if verbose is True:
|
|
1354
|
-
print(
|
|
1355
|
-
"Calculating activity coefficient based on parent salt %s"
|
|
1356
|
-
% Salt.formula
|
|
1357
|
-
)
|
|
1358
|
-
|
|
1359
|
-
param = db.get_parameter(Salt.formula, "pitzer_parameters_activity")
|
|
1360
|
-
|
|
1361
|
-
# determine alpha1 and alpha2 based on the type of salt
|
|
1362
|
-
# see the May reference for the rules used to determine
|
|
1363
|
-
# alpha1 and alpha2 based on charge
|
|
1364
|
-
if Salt.nu_cation >= 2 and Salt.nu_anion <= -2:
|
|
1365
|
-
if Salt.nu_cation >= 3 or Salt.nu_anion <= -3:
|
|
1366
|
-
alpha1 = 2
|
|
1367
|
-
alpha2 = 50
|
|
1368
|
-
else:
|
|
1369
|
-
alpha1 = 1.4
|
|
1370
|
-
alpha2 = 12
|
|
1371
|
-
else:
|
|
1372
|
-
alpha1 = 2
|
|
1373
|
-
alpha2 = 0
|
|
1374
|
-
|
|
1375
|
-
# determine the average molality of the salt
|
|
1376
|
-
# this is necessary for solutions inside e.g. an ion exchange
|
|
1377
|
-
# membrane, where the cation and anion concentrations may be
|
|
1378
|
-
# unequal
|
|
1379
|
-
# molality = (self.get_amount(Salt.cation,'mol/kg')/Salt.nu_cation+self.get_amount(Salt.anion,'mol/kg')
|
|
1380
|
-
# /Salt.nu_anion)/2
|
|
1381
|
-
|
|
1382
|
-
# determine the effective molality of the salt in the solution
|
|
1383
|
-
molality = Salt.get_effective_molality(self.get_ionic_strength())
|
|
1384
|
-
|
|
1385
|
-
activity_coefficient = ac.get_activity_coefficient_pitzer(
|
|
1386
|
-
self.get_ionic_strength(),
|
|
1387
|
-
molality,
|
|
1388
|
-
alpha1,
|
|
1389
|
-
alpha2,
|
|
1390
|
-
param.get_value()[0],
|
|
1391
|
-
param.get_value()[1],
|
|
1392
|
-
param.get_value()[2],
|
|
1393
|
-
param.get_value()[3],
|
|
1394
|
-
Salt.z_cation,
|
|
1395
|
-
Salt.z_anion,
|
|
1396
|
-
Salt.nu_cation,
|
|
1397
|
-
Salt.nu_anion,
|
|
1398
|
-
temperature,
|
|
1399
|
-
)
|
|
1400
|
-
|
|
1401
|
-
logger.info(
|
|
1402
|
-
"Calculated activity coefficient of species %s as %s based on salt %s using Pitzer model"
|
|
1403
|
-
% (solute, activity_coefficient, Salt)
|
|
1404
|
-
)
|
|
1405
|
-
molal = activity_coefficient
|
|
1406
|
-
|
|
1407
|
-
# for very low ionic strength, use the Debye-Huckel limiting law
|
|
1408
|
-
elif self.get_ionic_strength().magnitude <= 0.005:
|
|
1409
|
-
logger.info(
|
|
1410
|
-
"Ionic strength = %s. Using Debye-Huckel to calculate activity coefficient."
|
|
1411
|
-
% self.get_ionic_strength()
|
|
1412
|
-
)
|
|
1413
|
-
molal = ac.get_activity_coefficient_debyehuckel(
|
|
1414
|
-
self.get_ionic_strength(), ion.get_formal_charge(), temperature
|
|
1415
|
-
)
|
|
1416
|
-
|
|
1417
|
-
# use the Guntelberg approximation for 0.005 < I < 0.1
|
|
1418
|
-
elif self.get_ionic_strength().magnitude <= 0.1:
|
|
1419
|
-
logger.info(
|
|
1420
|
-
"Ionic strength = %s. Using Guntelberg to calculate activity coefficient."
|
|
1421
|
-
% self.get_ionic_strength()
|
|
1422
|
-
)
|
|
1423
|
-
molal = ac.get_activity_coefficient_guntelberg(
|
|
1424
|
-
self.get_ionic_strength(), ion.get_formal_charge(), temperature
|
|
1425
|
-
)
|
|
1426
|
-
|
|
1427
|
-
# use the Davies equation for 0.1 < I < 0.5
|
|
1428
|
-
elif self.get_ionic_strength().magnitude <= 0.5:
|
|
1429
|
-
logger.info(
|
|
1430
|
-
"Ionic strength = %s. Using Davies equation to calculate activity coefficient."
|
|
1431
|
-
% self.get_ionic_strength()
|
|
1432
|
-
)
|
|
1433
|
-
molal = ac.get_activity_coefficient_davies(
|
|
1434
|
-
self.get_ionic_strength(), ion.get_formal_charge(), temperature
|
|
1435
|
-
)
|
|
1436
|
-
|
|
1437
|
-
else:
|
|
1438
|
-
logger.warning(
|
|
1439
|
-
"Ionic strength too high to estimate activity for species %s. Specify parameters for Pitzer model."
|
|
1440
|
-
"Returning unit activity coefficient" % solute
|
|
1441
|
-
)
|
|
1442
|
-
|
|
1443
|
-
molal = unit("1 dimensionless")
|
|
1444
|
-
|
|
1445
|
-
# if necessary, convert the activity coefficient to another scale, and return the result
|
|
1446
|
-
if scale == "molal":
|
|
1447
|
-
return molal
|
|
1448
|
-
elif scale == "molar":
|
|
1449
|
-
total_molality = self.get_total_moles_solute() / self.get_solvent_mass()
|
|
1450
|
-
total_molarity = self.get_total_moles_solute() / self.get_volume()
|
|
1451
|
-
return (
|
|
1452
|
-
molal
|
|
1453
|
-
* h2o.water_density(self.get_temperature())
|
|
1454
|
-
* total_molality
|
|
1455
|
-
/ total_molarity
|
|
1456
|
-
).to("dimensionless")
|
|
1457
|
-
elif scale == "rational":
|
|
1458
|
-
return molal * (
|
|
1459
|
-
1
|
|
1460
|
-
+ unit("0.018 kg/mol")
|
|
1461
|
-
* self.get_total_moles_solute()
|
|
1462
|
-
/ self.get_solvent_mass()
|
|
1463
|
-
)
|
|
1464
|
-
else:
|
|
1465
|
-
logger.warning(
|
|
1466
|
-
"Invalid scale argument. Returning molal-scale activity coefficient"
|
|
1467
|
-
)
|
|
1468
|
-
return molal
|
|
1469
|
-
|
|
1470
|
-
def get_activity(self, solute, scale="molal", verbose=False):
|
|
1471
|
-
"""
|
|
1472
|
-
Return the thermodynamic activity of the solute in solution on the molal scale.
|
|
1473
|
-
|
|
1474
|
-
Parameters
|
|
1475
|
-
----------
|
|
1476
|
-
solute : str
|
|
1477
|
-
String representing the name of the solute of interest
|
|
1478
|
-
scale : str, optional
|
|
1479
|
-
The concentration scale for the returned activity.
|
|
1480
|
-
Valid options are "molal", "molar", and "rational" (i.e., mole fraction).
|
|
1481
|
-
By default, the molal scale activity is returned.
|
|
1482
|
-
verbose : bool, optional
|
|
1483
|
-
If True, pyEQL will print a message indicating the parent salt
|
|
1484
|
-
that is being used for activity calculations. This option is
|
|
1485
|
-
useful when modeling multicomponent solutions. False by default.
|
|
1486
|
-
|
|
1487
|
-
Returns
|
|
1488
|
-
-------
|
|
1489
|
-
The thermodynamic activity of the solute in question (dimensionless)
|
|
1490
|
-
|
|
1491
|
-
See Also
|
|
1492
|
-
--------
|
|
1493
|
-
get_activity_coefficient
|
|
1494
|
-
get_ionic_strength
|
|
1495
|
-
get_salt
|
|
1496
|
-
|
|
1497
|
-
Notes
|
|
1498
|
-
-----
|
|
1499
|
-
The thermodynamic activity depends on the concentration scale used [#].
|
|
1500
|
-
By default, the ionic strength, activity coefficients, and activities are all
|
|
1501
|
-
calculated based on the molal (mol/kg) concentration scale.
|
|
1502
|
-
|
|
1503
|
-
References
|
|
1504
|
-
----------
|
|
1505
|
-
.. [#] Robinson, R. A.; Stokes, R. H. Electrolyte Solutions: Second Revised
|
|
1506
|
-
Edition; Butterworths: London, 1968, p.32.
|
|
1507
|
-
|
|
1508
|
-
"""
|
|
1509
|
-
# switch to the water activity function if the species is H2O
|
|
1510
|
-
if solute == "H2O" or solute == "water":
|
|
1511
|
-
activity = self.get_water_activity()
|
|
1512
|
-
else:
|
|
1513
|
-
# determine the concentration units to use based on the desired scale
|
|
1514
|
-
if scale == "molal":
|
|
1515
|
-
unit = "mol/kg"
|
|
1516
|
-
elif scale == "molar":
|
|
1517
|
-
unit = "mol/L"
|
|
1518
|
-
elif scale == "rational":
|
|
1519
|
-
unit = "fraction"
|
|
1520
|
-
else:
|
|
1521
|
-
logger.error("Invalid scale argument. Returning molal-scale activity.")
|
|
1522
|
-
unit = "mol/kg"
|
|
1523
|
-
scale = "molal"
|
|
1524
|
-
|
|
1525
|
-
activity = (
|
|
1526
|
-
self.get_activity_coefficient(solute, scale=scale, verbose=verbose)
|
|
1527
|
-
* self.get_amount(solute, unit).magnitude
|
|
1528
|
-
)
|
|
1529
|
-
logger.info(
|
|
1530
|
-
"Calculated %s scale activity of solute %s as %s"
|
|
1531
|
-
% (scale, solute, activity)
|
|
1532
|
-
)
|
|
1533
|
-
|
|
1534
|
-
return activity
|
|
1535
|
-
|
|
1536
|
-
def get_osmotic_coefficient(self, scale="molal"):
|
|
1537
|
-
"""
|
|
1538
|
-
Return the osmotic coefficient of an aqueous solution.
|
|
1539
|
-
|
|
1540
|
-
Osmotic coefficient is calculated using the Pitzer model.[#]_ If appropriate parameters for
|
|
1541
|
-
the model are not available, then pyEQL raises a WARNING and returns an osmotic
|
|
1542
|
-
coefficient of 1.
|
|
1543
|
-
|
|
1544
|
-
If the 'rational' scale is given as input, then the molal-scale osmotic
|
|
1545
|
-
coefficient :math:`\\phi` is converted according to [#]_
|
|
1546
|
-
|
|
1547
|
-
.. math:: g = - \\phi * M_w \\sum_i \\nu_i \\m_i) / \\ln x_w
|
|
1548
|
-
|
|
1549
|
-
where :math:`g` is the rational osmotic coefficient, :math:`M_w` is
|
|
1550
|
-
the molecular weight of water, the summation represents the total molality of
|
|
1551
|
-
all solute species, and :math:`x_w` is the mole fraction of water.
|
|
1552
|
-
|
|
1553
|
-
Parameters
|
|
1554
|
-
----------
|
|
1555
|
-
scale : str, optional
|
|
1556
|
-
The concentration scale for the returned osmotic coefficient.
|
|
1557
|
-
Valid options are "molal", "rational" (i.e., mole fraction),
|
|
1558
|
-
and "fugacity". By default, the molal scale osmotic coefficient is returned.
|
|
1559
|
-
Returns
|
|
1560
|
-
-------
|
|
1561
|
-
Quantity :
|
|
1562
|
-
The osmotic coefficient
|
|
1563
|
-
|
|
1564
|
-
See Also
|
|
1565
|
-
--------
|
|
1566
|
-
get_water_activity
|
|
1567
|
-
get_ionic_strength
|
|
1568
|
-
get_salt
|
|
1569
|
-
|
|
1570
|
-
Notes
|
|
1571
|
-
-----
|
|
1572
|
-
For multicomponent mixtures, pyEQL adopts the "effective Pitzer model"
|
|
1573
|
-
presented by Mistry et al. [#]_. In this approach, the osmotic coefficient of
|
|
1574
|
-
each individual salt is calculated using the normal Pitzer model based
|
|
1575
|
-
on its respective concentration. Then, an effective osmotic coefficient
|
|
1576
|
-
is calculated as the concentration-weighted average of the individual
|
|
1577
|
-
osmotic coefficients.
|
|
1578
|
-
|
|
1579
|
-
For example, in a mixture of 0.5 M NaCl and 0.5 M KBr, one would calculate
|
|
1580
|
-
the osmotic coefficient for each salt using a concentration of 0.5 M and
|
|
1581
|
-
an ionic strength of 1 M. Then, one would average the two resulting
|
|
1582
|
-
osmotic coefficients to obtain an effective osmotic coefficient for the
|
|
1583
|
-
mixture.
|
|
1584
|
-
|
|
1585
|
-
(Note: in the paper referenced below, the effective
|
|
1586
|
-
osmotic coefficient is determined by weighting using the "effective molality"
|
|
1587
|
-
rather than the true molality. Subsequent checking and correspondence with
|
|
1588
|
-
the author confirmed that the weight factor should be the true molality, and
|
|
1589
|
-
that is what is implemented in pyEQL.)
|
|
1590
|
-
|
|
1591
|
-
References
|
|
1592
|
-
----------
|
|
1593
|
-
.. [#] May, P. M., Rowland, D., Hefter, G., & Königsberger, E. (2011).
|
|
1594
|
-
A Generic and Updatable Pitzer Characterization of Aqueous Binary Electrolyte Solutions at 1 bar
|
|
1595
|
-
and 25 °C. *Journal of Chemical & Engineering Data*, 56(12), 5066–5077. doi:10.1021/je2009329
|
|
1596
|
-
|
|
1597
|
-
.. [#] Robinson, R. A.; Stokes, R. H. Electrolyte Solutions: Second Revised
|
|
1598
|
-
Edition; Butterworths: London, 1968, p.32.
|
|
1599
|
-
|
|
1600
|
-
.. [#] Mistry, K. H.; Hunter, H. a.; Lienhard V, J. H. Effect of composition and nonideal solution behavior on
|
|
1601
|
-
desalination calculations for mixed
|
|
1602
|
-
electrolyte solutions with comparison to seawater. Desalination 2013, 318, 34–47.
|
|
1603
|
-
|
|
1604
|
-
Examples
|
|
1605
|
-
--------
|
|
1606
|
-
>>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
|
|
1607
|
-
>>> s1.get_osmotic_coefficient()
|
|
1608
|
-
<Quantity(0.9235996615888572, 'dimensionless')>
|
|
1609
|
-
|
|
1610
|
-
>>> s1 = pyEQL.Solution([['Mg+2','0.3 mol/kg'],['Cl-','0.6 mol/kg']],temperature='30 degC')
|
|
1611
|
-
>>> s1.get_osmotic_coefficient()
|
|
1612
|
-
<Quantity(0.891154788474231, 'dimensionless')>
|
|
1613
|
-
|
|
1614
|
-
"""
|
|
1615
|
-
temperature = str(self.get_temperature())
|
|
1616
|
-
ionic_strength = self.get_ionic_strength()
|
|
1617
|
-
|
|
1618
|
-
effective_osmotic_sum = 0
|
|
1619
|
-
molality_sum = 0
|
|
1620
|
-
|
|
1621
|
-
# organize the composition into a dictionary of salts
|
|
1622
|
-
salt_list = self.get_salt_list()
|
|
1623
|
-
|
|
1624
|
-
# loop through all the salts in the solution, calculate the osmotic
|
|
1625
|
-
# coefficint for reach, and average them into an effective osmotic
|
|
1626
|
-
# coefficient
|
|
1627
|
-
for item in salt_list:
|
|
1628
|
-
|
|
1629
|
-
# ignore HOH in the salt list
|
|
1630
|
-
if item.formula == "HOH":
|
|
1631
|
-
continue
|
|
1632
|
-
|
|
1633
|
-
# determine alpha1 and alpha2 based on the type of salt
|
|
1634
|
-
# see the May reference for the rules used to determine
|
|
1635
|
-
# alpha1 and alpha2 based on charge
|
|
1636
|
-
if item.z_cation >= 2 and item.z_anion <= -2:
|
|
1637
|
-
if item.z_cation >= 3 or item.z_anion <= -3:
|
|
1638
|
-
alpha1 = 2
|
|
1639
|
-
alpha2 = 50
|
|
1640
|
-
else:
|
|
1641
|
-
alpha1 = 1.4
|
|
1642
|
-
alpha2 = 12
|
|
1643
|
-
else:
|
|
1644
|
-
alpha1 = 2
|
|
1645
|
-
alpha2 = 0
|
|
1646
|
-
|
|
1647
|
-
# set the concentration as the average concentration of the cation and
|
|
1648
|
-
# anion in the salt, accounting for stoichiometry
|
|
1649
|
-
# concentration = (self.get_amount(Salt.cation,'mol/kg')/Salt.nu_cation + \
|
|
1650
|
-
# self.get_amount(Salt.anion,'mol/kg')/Salt.nu_anion)/2
|
|
1651
|
-
|
|
1652
|
-
# get the effective molality of the salt
|
|
1653
|
-
concentration = salt_list[item]
|
|
1654
|
-
|
|
1655
|
-
molality_sum += concentration
|
|
1656
|
-
|
|
1657
|
-
# search the database for pitzer parameters for 'salt'
|
|
1658
|
-
db.search_parameters(item.formula)
|
|
1659
|
-
|
|
1660
|
-
if db.has_parameter(item.formula, "pitzer_parameters_activity"):
|
|
1661
|
-
|
|
1662
|
-
param = db.get_parameter(item.formula, "pitzer_parameters_activity")
|
|
1663
|
-
|
|
1664
|
-
osmotic_coefficient = ac.get_osmotic_coefficient_pitzer(
|
|
1665
|
-
ionic_strength,
|
|
1666
|
-
concentration,
|
|
1667
|
-
alpha1,
|
|
1668
|
-
alpha2,
|
|
1669
|
-
param.get_value()[0],
|
|
1670
|
-
param.get_value()[1],
|
|
1671
|
-
param.get_value()[2],
|
|
1672
|
-
param.get_value()[3],
|
|
1673
|
-
item.z_cation,
|
|
1674
|
-
item.z_anion,
|
|
1675
|
-
item.nu_cation,
|
|
1676
|
-
item.nu_anion,
|
|
1677
|
-
temperature,
|
|
1678
|
-
)
|
|
1679
|
-
|
|
1680
|
-
logger.info(
|
|
1681
|
-
"Calculated osmotic coefficient of water as %s based on salt %s using Pitzer model"
|
|
1682
|
-
% (osmotic_coefficient, item.formula)
|
|
1683
|
-
)
|
|
1684
|
-
effective_osmotic_sum += concentration * osmotic_coefficient
|
|
1685
|
-
|
|
1686
|
-
else:
|
|
1687
|
-
logger.warning(
|
|
1688
|
-
"Cannot calculate osmotic coefficient because Pitzer parameters for salt %s "
|
|
1689
|
-
"are not specified. Returning unit osmotic coefficient"
|
|
1690
|
-
% item.formula
|
|
1691
|
-
)
|
|
1692
|
-
effective_osmotic_sum += concentration * unit("1 dimensionless")
|
|
1693
|
-
|
|
1694
|
-
molal_phi = effective_osmotic_sum / molality_sum
|
|
1695
|
-
|
|
1696
|
-
if scale == "molal":
|
|
1697
|
-
return molal_phi
|
|
1698
|
-
elif scale == "rational":
|
|
1699
|
-
solvent = self.get_solvent().formula
|
|
1700
|
-
return (
|
|
1701
|
-
-molal_phi
|
|
1702
|
-
* unit("0.018 kg/mol")
|
|
1703
|
-
* self.get_total_moles_solute()
|
|
1704
|
-
/ self.get_solvent_mass()
|
|
1705
|
-
/ math.log(self.get_amount(solvent, "fraction"))
|
|
1706
|
-
)
|
|
1707
|
-
elif scale == "fugacity":
|
|
1708
|
-
solvent = self.get_solvent().formula
|
|
1709
|
-
return math.exp(
|
|
1710
|
-
-molal_phi
|
|
1711
|
-
* unit("0.018 kg/mol")
|
|
1712
|
-
* self.get_total_moles_solute()
|
|
1713
|
-
/ self.get_solvent_mass()
|
|
1714
|
-
- math.log(self.get_amount(solvent, "fraction"))
|
|
1715
|
-
)
|
|
1716
|
-
else:
|
|
1717
|
-
logger.warning(
|
|
1718
|
-
"Invalid scale argument. Returning molal-scale osmotic coefficient"
|
|
1719
|
-
)
|
|
1720
|
-
return molal_phi
|
|
1704
|
+
return salt_dict
|
|
1721
1705
|
|
|
1722
|
-
def
|
|
1706
|
+
def equilibrate(self, **kwargs) -> None:
|
|
1723
1707
|
"""
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
Returns
|
|
1727
|
-
-------
|
|
1728
|
-
Quantity :
|
|
1729
|
-
The thermodynamic activity of water in the solution.
|
|
1730
|
-
|
|
1731
|
-
See Also
|
|
1732
|
-
--------
|
|
1733
|
-
get_osmotic_coefficient
|
|
1734
|
-
get_ionic_strength
|
|
1735
|
-
get_salt
|
|
1736
|
-
|
|
1737
|
-
Notes
|
|
1738
|
-
-----
|
|
1739
|
-
Water activity is related to the osmotic coefficient in a solution containing i solutes by: [#]_
|
|
1708
|
+
Update the composition of the Solution using the thermodynamic engine.
|
|
1740
1709
|
|
|
1741
|
-
|
|
1710
|
+
Any kwargs specified are passed through to self.engine.equilibrate()
|
|
1742
1711
|
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
If appropriate Pitzer model parameters are not available, the
|
|
1747
|
-
water activity is assumed equal to the mole fraction of water.
|
|
1748
|
-
|
|
1749
|
-
References
|
|
1750
|
-
----------
|
|
1751
|
-
.. [#] Blandamer, Mike J., Engberts, Jan B. F. N., Gleeson, Peter T., Reis, Joao Carlos R., 2005. "Activity of \
|
|
1752
|
-
water in aqueous systems: A frequently neglected property." *Chemical Society Review* 34, 440-458.
|
|
1753
|
-
|
|
1754
|
-
Examples
|
|
1755
|
-
--------
|
|
1756
|
-
>>> s1 = pyEQL.Solution([['Na+','0.3 mol/kg'],['Cl-','0.3 mol/kg']])
|
|
1757
|
-
>>> s1.get_water_activity()
|
|
1758
|
-
<Quantity(0.9900944932888518, 'dimensionless')>
|
|
1759
|
-
"""
|
|
1712
|
+
Returns:
|
|
1713
|
+
Nothing. The .components attribute of the Solution is updated.
|
|
1760
1714
|
"""
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
identify predominant salt for coefficients
|
|
1764
|
-
check if coefficients exist for that salt
|
|
1765
|
-
if so => calc osmotic coefficient and log an info message
|
|
1766
|
-
|
|
1767
|
-
if not = > return mole fraction and log a warning message
|
|
1715
|
+
self.engine.equilibrate(self, **kwargs)
|
|
1768
1716
|
|
|
1717
|
+
# Activity-related methods
|
|
1718
|
+
def get_activity_coefficient(
|
|
1719
|
+
self,
|
|
1720
|
+
solute: str,
|
|
1721
|
+
scale: Literal["molal", "molar", "fugacity", "rational"] = "molal",
|
|
1722
|
+
) -> Quantity:
|
|
1769
1723
|
"""
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
if osmotic_coefficient == 1:
|
|
1773
|
-
logger.warning(
|
|
1774
|
-
"Pitzer parameters not found. Water activity set equal to mole fraction"
|
|
1775
|
-
)
|
|
1776
|
-
return self.get_amount("H2O", "fraction").to("dimensionless")
|
|
1777
|
-
else:
|
|
1778
|
-
concentration_sum = unit("0 mol/kg")
|
|
1779
|
-
for item in self.components:
|
|
1780
|
-
if item == "H2O":
|
|
1781
|
-
pass
|
|
1782
|
-
else:
|
|
1783
|
-
concentration_sum += self.get_amount(item, "mol/kg")
|
|
1724
|
+
Return the activity coefficient of a solute in solution.
|
|
1784
1725
|
|
|
1785
|
-
|
|
1726
|
+
The model used to calculate the activity coefficient is determined by the Solution's equation of state
|
|
1727
|
+
engine.
|
|
1786
1728
|
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1729
|
+
Args:
|
|
1730
|
+
solute: The solute for which to retrieve the activity coefficient
|
|
1731
|
+
scale: The activity coefficient concentration scale
|
|
1732
|
+
verbose: If True, pyEQL will print a message indicating the parent salt
|
|
1733
|
+
that is being used for activity calculations. This option is
|
|
1734
|
+
useful when modeling multicomponent solutions. False by default.
|
|
1790
1735
|
|
|
1791
|
-
|
|
1736
|
+
Returns:
|
|
1737
|
+
Quantity: the activity coefficient as a dimensionless pint Quantity
|
|
1792
1738
|
"""
|
|
1793
|
-
|
|
1739
|
+
# return unit activity coefficient if the concentration of the solute is zero
|
|
1740
|
+
if self.get_amount(solute, "mol").magnitude == 0:
|
|
1741
|
+
return ureg.Quantity(1, "dimensionless")
|
|
1794
1742
|
|
|
1795
|
-
|
|
1796
|
-
|
|
1743
|
+
try:
|
|
1744
|
+
# get the molal-scale activity coefficient from the EOS engine
|
|
1745
|
+
molal = self.engine.get_activity_coefficient(solution=self, solute=solute)
|
|
1746
|
+
except (ValueError, ZeroDivisionError):
|
|
1747
|
+
self.logger.error("Calculation unsuccessful. Returning unit activity coefficient.", exc_info=True)
|
|
1748
|
+
return ureg.Quantity(1, "dimensionless")
|
|
1797
1749
|
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
get_water_activity
|
|
1807
|
-
|
|
1808
|
-
Notes
|
|
1809
|
-
-----
|
|
1810
|
-
The ionic strength is calculated according to:
|
|
1811
|
-
|
|
1812
|
-
.. math:: I = \sum_i m_i z_i^2
|
|
1813
|
-
|
|
1814
|
-
Where :math:`m_i` is the molal concentration and :math:`z_i` is the charge on species i.
|
|
1815
|
-
|
|
1816
|
-
Examples
|
|
1817
|
-
--------
|
|
1818
|
-
>>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
|
|
1819
|
-
>>> s1.get_ionic_strength()
|
|
1820
|
-
<Quantity(0.20000010029672785, 'mole / kilogram')>
|
|
1821
|
-
|
|
1822
|
-
>>> s1 = pyEQL.Solution([['Mg+2','0.3 mol/kg'],['Na+','0.1 mol/kg'],['Cl-','0.7 mol/kg']],temperature='30 degC')
|
|
1823
|
-
>>> s1.get_ionic_strength()
|
|
1824
|
-
<Quantity(1.0000001004383303, 'mole / kilogram')>
|
|
1825
|
-
"""
|
|
1826
|
-
self.ionic_strength = 0
|
|
1827
|
-
for solute in self.components.keys():
|
|
1828
|
-
self.ionic_strength += (
|
|
1829
|
-
0.5
|
|
1830
|
-
* self.get_amount(solute, "mol/kg")
|
|
1831
|
-
* self.components[solute].get_formal_charge() ** 2
|
|
1750
|
+
# if necessary, convert the activity coefficient to another scale, and return the result
|
|
1751
|
+
if scale == "molal":
|
|
1752
|
+
return molal
|
|
1753
|
+
if scale == "molar":
|
|
1754
|
+
total_molality = self.get_total_moles_solute() / self.solvent_mass
|
|
1755
|
+
total_molarity = self.get_total_moles_solute() / self.volume
|
|
1756
|
+
return (molal * ureg.Quantity(self.water_substance.rho, "g/L") * total_molality / total_molarity).to(
|
|
1757
|
+
"dimensionless"
|
|
1832
1758
|
)
|
|
1759
|
+
if scale == "rational":
|
|
1760
|
+
return molal * (1 + ureg.Quantity(0.018015, "kg/mol") * self.get_total_moles_solute() / self.solvent_mass)
|
|
1833
1761
|
|
|
1834
|
-
|
|
1762
|
+
raise ValueError("Invalid scale argument. Pass 'molal', 'molar', or 'rational'.")
|
|
1835
1763
|
|
|
1836
|
-
def
|
|
1764
|
+
def get_activity(
|
|
1765
|
+
self,
|
|
1766
|
+
solute: str,
|
|
1767
|
+
scale: Literal["molal", "molar", "rational"] = "molal",
|
|
1768
|
+
) -> Quantity:
|
|
1837
1769
|
"""
|
|
1838
|
-
Return the
|
|
1770
|
+
Return the thermodynamic activity of the solute in solution on the chosen concentration scale.
|
|
1839
1771
|
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1772
|
+
Args:
|
|
1773
|
+
solute:
|
|
1774
|
+
String representing the name of the solute of interest
|
|
1775
|
+
scale:
|
|
1776
|
+
The concentration scale for the returned activity.
|
|
1777
|
+
Valid options are "molal", "molar", and "rational" (i.e., mole fraction).
|
|
1778
|
+
By default, the molal scale activity is returned.
|
|
1779
|
+
verbose:
|
|
1780
|
+
If True, pyEQL will print a message indicating the parent salt
|
|
1781
|
+
that is being used for activity calculations. This option is
|
|
1782
|
+
useful when modeling multicomponent solutions. False by default.
|
|
1843
1783
|
|
|
1844
|
-
Returns
|
|
1845
|
-
|
|
1846
|
-
float :
|
|
1847
|
-
The charge balance of the solution, in equivalents.
|
|
1784
|
+
Returns:
|
|
1785
|
+
The thermodynamic activity of the solute in question (dimensionless Quantity)
|
|
1848
1786
|
|
|
1849
|
-
Notes
|
|
1850
|
-
|
|
1851
|
-
|
|
1787
|
+
Notes:
|
|
1788
|
+
The thermodynamic activity depends on the concentration scale used [rs]_ .
|
|
1789
|
+
By default, the ionic strength, activity coefficients, and activities are all
|
|
1790
|
+
calculated based on the molal (mol/kg) concentration scale.
|
|
1852
1791
|
|
|
1853
|
-
|
|
1792
|
+
References:
|
|
1793
|
+
.. [rs] Robinson, R. A.; Stokes, R. H. Electrolyte Solutions: Second Revised
|
|
1794
|
+
Edition; Butterworths: London, 1968, p.32.
|
|
1854
1795
|
|
|
1855
|
-
|
|
1856
|
-
|
|
1796
|
+
See Also:
|
|
1797
|
+
:attr:`ionic_strength`
|
|
1798
|
+
:py:meth:`get_activity_coefficient`
|
|
1799
|
+
:py:meth:`get_salt`
|
|
1857
1800
|
|
|
1858
1801
|
"""
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
self.
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1802
|
+
# switch to the water activity function if the species is H2O
|
|
1803
|
+
if solute in ["H2O(aq)", "water", "H2O", "HOH"]:
|
|
1804
|
+
activity = self.get_water_activity()
|
|
1805
|
+
else:
|
|
1806
|
+
# determine the concentration units to use based on the desired scale
|
|
1807
|
+
if scale == "molal":
|
|
1808
|
+
units = "mol/kg"
|
|
1809
|
+
elif scale == "molar":
|
|
1810
|
+
units = "mol/L"
|
|
1811
|
+
elif scale == "rational":
|
|
1812
|
+
units = "fraction"
|
|
1813
|
+
else:
|
|
1814
|
+
raise ValueError("Invalid scale argument. Pass 'molal', 'molar', or 'rational'.")
|
|
1867
1815
|
|
|
1868
|
-
|
|
1816
|
+
activity = (self.get_activity_coefficient(solute, scale=scale) * self.get_amount(solute, units)).magnitude
|
|
1817
|
+
self.logger.debug(f"Calculated {scale} scale activity of solute {solute} as {activity}")
|
|
1869
1818
|
|
|
1870
|
-
|
|
1819
|
+
return ureg.Quantity(activity, "dimensionless")
|
|
1820
|
+
|
|
1821
|
+
# TODO - engine method
|
|
1822
|
+
def get_osmotic_coefficient(self, scale: Literal["molal", "molar", "rational"] = "molal") -> Quantity:
|
|
1871
1823
|
"""
|
|
1872
|
-
Return the
|
|
1824
|
+
Return the osmotic coefficient of an aqueous solution.
|
|
1873
1825
|
|
|
1874
|
-
|
|
1875
|
-
-------
|
|
1876
|
-
Quantity :
|
|
1877
|
-
The alkalinity of the solution in mg/L as CaCO3
|
|
1826
|
+
The method used depends on the Solution object's equation of state engine.
|
|
1878
1827
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
The alkalinity is calculated according to: [#]_
|
|
1828
|
+
"""
|
|
1829
|
+
molal_phi = self.engine.get_osmotic_coefficient(self)
|
|
1882
1830
|
|
|
1883
|
-
|
|
1831
|
+
if scale == "molal":
|
|
1832
|
+
return molal_phi
|
|
1833
|
+
if scale == "rational":
|
|
1834
|
+
return (
|
|
1835
|
+
-molal_phi
|
|
1836
|
+
* ureg.Quantity(0.018015, "kg/mol")
|
|
1837
|
+
* self.get_total_moles_solute()
|
|
1838
|
+
/ self.solvent_mass
|
|
1839
|
+
/ np.log(self.get_amount(self.solvent, "fraction"))
|
|
1840
|
+
)
|
|
1841
|
+
if scale == "fugacity":
|
|
1842
|
+
return np.exp(
|
|
1843
|
+
-molal_phi * ureg.Quantity(0.018015, "kg/mol") * self.get_total_moles_solute() / self.solvent_mass
|
|
1844
|
+
- np.log(self.get_amount(self.solvent, "fraction"))
|
|
1845
|
+
) * ureg.Quantity(1, "dimensionless")
|
|
1884
1846
|
|
|
1885
|
-
|
|
1886
|
-
(i.e. ions that do not participate in acid-base reactions), and :math:`z_i` is their charge.
|
|
1887
|
-
In this method, the set of conservative cations is all Group I and Group II cations, and the conservative anions
|
|
1888
|
-
are all the anions of strong acids.
|
|
1847
|
+
raise ValueError("Invalid scale argument. Pass 'molal', 'rational', or 'fugacity'.")
|
|
1889
1848
|
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
pp 165. Wiley Interscience, 1996.
|
|
1894
|
-
"""
|
|
1895
|
-
alkalinity = 0 * unit("mol/L")
|
|
1896
|
-
equiv_wt_CaCO3 = 100.09 / 2 * unit("g/mol")
|
|
1897
|
-
|
|
1898
|
-
base_cations = [
|
|
1899
|
-
"Li+",
|
|
1900
|
-
"Na+",
|
|
1901
|
-
"K+",
|
|
1902
|
-
"Rb+",
|
|
1903
|
-
"Cs+",
|
|
1904
|
-
"Fr+",
|
|
1905
|
-
"Be+2",
|
|
1906
|
-
"Mg+2",
|
|
1907
|
-
"Ca+2",
|
|
1908
|
-
"Sr+2",
|
|
1909
|
-
"Ba+2",
|
|
1910
|
-
"Ra+2",
|
|
1911
|
-
]
|
|
1912
|
-
acid_anions = ["Cl-", "Br-", "I-", "SO4-2", "NO3-", "ClO4-", "ClO3-"]
|
|
1849
|
+
def get_water_activity(self) -> Quantity:
|
|
1850
|
+
r"""
|
|
1851
|
+
Return the water activity.
|
|
1913
1852
|
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
alkalinity += self.get_amount(item, "mol/L") * z
|
|
1918
|
-
if item in acid_anions:
|
|
1919
|
-
z = self.get_solute(item).get_formal_charge()
|
|
1920
|
-
alkalinity -= self.get_amount(item, "mol/L") * z
|
|
1853
|
+
Returns:
|
|
1854
|
+
Quantity:
|
|
1855
|
+
The thermodynamic activity of water in the solution.
|
|
1921
1856
|
|
|
1922
|
-
|
|
1923
|
-
|
|
1857
|
+
See Also:
|
|
1858
|
+
:attr:`ionic_strength`
|
|
1859
|
+
:py:meth:`get_activity_coefficient`
|
|
1860
|
+
:py:meth:`get_salt`
|
|
1924
1861
|
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
Return the hardness of a solution.
|
|
1862
|
+
Notes:
|
|
1863
|
+
Water activity is related to the osmotic coefficient in a solution containing i solutes by:
|
|
1928
1864
|
|
|
1929
|
-
|
|
1930
|
-
of multivalent cations as calcium carbonate.
|
|
1865
|
+
.. math:: \ln a_{w} = - \Phi M_{w} \sum_{i} m_{i}
|
|
1931
1866
|
|
|
1932
|
-
|
|
1933
|
-
|
|
1867
|
+
Where :math:`M_{w}` is the molar mass of water (0.018015 kg/mol) and :math:`m_{i}` is the molal
|
|
1868
|
+
concentration of each species.
|
|
1934
1869
|
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
None
|
|
1870
|
+
If appropriate Pitzer model parameters are not available, the
|
|
1871
|
+
water activity is assumed equal to the mole fraction of water.
|
|
1938
1872
|
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
The hardness of the solution in mg/L as CaCO3
|
|
1873
|
+
References:
|
|
1874
|
+
Blandamer, Mike J., Engberts, Jan B. F. N., Gleeson, Peter T., Reis, Joao Carlos R., 2005. "Activity of
|
|
1875
|
+
water in aqueous systems: A frequently neglected property." *Chemical Society Review* 34, 440-458.
|
|
1943
1876
|
|
|
1877
|
+
Examples:
|
|
1878
|
+
>>> s1 = pyEQL.Solution([['Na+','0.3 mol/kg'],['Cl-','0.3 mol/kg']])
|
|
1879
|
+
>>> s1.get_water_activity()
|
|
1880
|
+
<Quantity(0.9900944932888518, 'dimensionless')>
|
|
1944
1881
|
"""
|
|
1945
|
-
|
|
1946
|
-
equiv_wt_CaCO3 = 100.09 / 2 * unit("g/mol")
|
|
1947
|
-
|
|
1948
|
-
for item in self.components:
|
|
1949
|
-
z = self.get_solute(item).get_formal_charge()
|
|
1950
|
-
if z > 1:
|
|
1951
|
-
hardness += z * self.get_amount(item, "mol/L")
|
|
1882
|
+
osmotic_coefficient = self.get_osmotic_coefficient()
|
|
1952
1883
|
|
|
1953
|
-
|
|
1954
|
-
|
|
1884
|
+
if osmotic_coefficient == 1:
|
|
1885
|
+
self.logger.warning("Pitzer parameters not found. Water activity set equal to mole fraction")
|
|
1886
|
+
return self.get_amount("H2O", "fraction")
|
|
1955
1887
|
|
|
1956
|
-
|
|
1957
|
-
""
|
|
1958
|
-
Return the Debye length of a solution
|
|
1888
|
+
concentration_sum = np.sum([mol for item, mol in self.components.items() if item != "H2O(aq)"])
|
|
1889
|
+
concentration_sum /= self.solvent_mass.to("kg").magnitude # converts to mol/kg
|
|
1959
1890
|
|
|
1960
|
-
|
|
1891
|
+
self.logger.debug("Calculated water activity using osmotic coefficient")
|
|
1961
1892
|
|
|
1962
|
-
|
|
1893
|
+
return ureg.Quantity(np.exp(-osmotic_coefficient * 0.018015 * concentration_sum), "dimensionless")
|
|
1963
1894
|
|
|
1964
|
-
|
|
1895
|
+
def get_chemical_potential_energy(self, activity_correction: bool = True) -> Quantity:
|
|
1896
|
+
r"""
|
|
1897
|
+
Return the total chemical potential energy of a solution (not including
|
|
1898
|
+
pressure or electric effects).
|
|
1965
1899
|
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1900
|
+
Args:
|
|
1901
|
+
activity_correction : bool, optional
|
|
1902
|
+
If True, activities will be used to calculate the true chemical
|
|
1903
|
+
potential. If False, mole fraction will be used, resulting in
|
|
1904
|
+
a calculation of the ideal chemical potential.
|
|
1970
1905
|
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1906
|
+
Returns:
|
|
1907
|
+
Quantity
|
|
1908
|
+
The actual or ideal chemical potential energy of the solution, in Joules.
|
|
1974
1909
|
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
The Debye length, in nanometers.
|
|
1910
|
+
Notes:
|
|
1911
|
+
The chemical potential energy (related to the Gibbs mixing energy) is
|
|
1912
|
+
calculated as follows: [koga]_
|
|
1979
1913
|
|
|
1980
|
-
|
|
1981
|
-
----------
|
|
1982
|
-
.. [#] https://en.wikipedia.org/wiki/Debye_length#Debye_length_in_an_electrolyte
|
|
1914
|
+
.. math:: E = R T \sum_i n_i \ln a_i
|
|
1983
1915
|
|
|
1984
|
-
|
|
1985
|
-
--------
|
|
1986
|
-
get_ionic_strength
|
|
1987
|
-
get_dielectric_constant
|
|
1916
|
+
or
|
|
1988
1917
|
|
|
1989
|
-
|
|
1990
|
-
temperature = self.get_temperature()
|
|
1918
|
+
.. math:: E = R T \sum_i n_i \ln x_i
|
|
1991
1919
|
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1920
|
+
Where :math:`n` is the number of moles of substance, :math:`T` is the temperature in kelvin,
|
|
1921
|
+
:math:`R` the ideal gas constant, :math:`x` the mole fraction, and :math:`a` the activity of
|
|
1922
|
+
each component.
|
|
1995
1923
|
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
* unit.k
|
|
2000
|
-
* temperature
|
|
2001
|
-
/ (2 * unit.N_A * unit.e ** 2 * ionic_strength)
|
|
2002
|
-
) ** 0.5
|
|
1924
|
+
Note that dissociated ions must be counted as separate components,
|
|
1925
|
+
so a simple salt dissolved in water is a three component solution (cation,
|
|
1926
|
+
anion, and water).
|
|
2003
1927
|
|
|
2004
|
-
|
|
1928
|
+
References:
|
|
1929
|
+
.. [koga] Koga, Yoshikata, 2007. *Solution Thermodynamics and its Application to Aqueous Solutions:
|
|
1930
|
+
A differential approach.* Elsevier, 2007, pp. 23-37.
|
|
2005
1931
|
|
|
2006
|
-
def get_bjerrum_length(self):
|
|
2007
1932
|
"""
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
Bjerrum length representes the distance at which electrostatic
|
|
2011
|
-
interactions between particles become comparable in magnitude
|
|
2012
|
-
to the thermal energy.:math:`\\lambda_B` is calculated as [#]_
|
|
1933
|
+
E = ureg.Quantity(0, "J")
|
|
2013
1934
|
|
|
2014
|
-
|
|
1935
|
+
# loop through all the components and add their potential energy
|
|
1936
|
+
for item in self.components:
|
|
1937
|
+
try:
|
|
1938
|
+
if activity_correction is True:
|
|
1939
|
+
E += (
|
|
1940
|
+
ureg.R
|
|
1941
|
+
* self.temperature.to("K")
|
|
1942
|
+
* self.get_amount(item, "mol")
|
|
1943
|
+
* np.log(self.get_activity(item))
|
|
1944
|
+
)
|
|
1945
|
+
else:
|
|
1946
|
+
E += (
|
|
1947
|
+
ureg.R
|
|
1948
|
+
* self.temperature.to("K")
|
|
1949
|
+
* self.get_amount(item, "mol")
|
|
1950
|
+
* np.log(self.get_amount(item, "fraction"))
|
|
1951
|
+
)
|
|
1952
|
+
# If we have a solute with zero concentration, we will get a ValueError
|
|
1953
|
+
except ValueError:
|
|
1954
|
+
continue
|
|
2015
1955
|
|
|
2016
|
-
|
|
1956
|
+
return E.to("J")
|
|
2017
1957
|
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
1958
|
+
def _get_property(self, solute: str, name: str) -> Any | None:
|
|
1959
|
+
"""Retrieve a thermodynamic property (such as diffusion coefficient)
|
|
1960
|
+
for solute, and adjust it from the reference conditions to the conditions
|
|
1961
|
+
of the solution.
|
|
1962
|
+
|
|
1963
|
+
Args:
|
|
1964
|
+
solute: str
|
|
1965
|
+
String representing the chemical formula of the solute species
|
|
1966
|
+
name: str
|
|
1967
|
+
The name of the property needed, e.g.
|
|
1968
|
+
'diffusion coefficient'
|
|
1969
|
+
|
|
1970
|
+
Returns:
|
|
1971
|
+
Quantity: The desired parameter or None if not found
|
|
1972
|
+
|
|
1973
|
+
"""
|
|
1974
|
+
base_temperature = ureg.Quantity(25, "degC")
|
|
1975
|
+
# base_pressure = ureg.Quantity("1 atm")
|
|
1976
|
+
|
|
1977
|
+
# query the database using the standardized formula
|
|
1978
|
+
rform = standardize_formula(solute)
|
|
1979
|
+
# TODO - there seems to be a bug in mongomock / JSONStore wherein properties does
|
|
1980
|
+
# not properly return dot-notation fields, e.g. size.molar_volume will not be returned.
|
|
1981
|
+
# also $exists:True does not properly return dot notated fields.
|
|
1982
|
+
# for now, just set properties=[] to return everything
|
|
1983
|
+
# data = list(self.database.query({"formula": rform, name: {"$ne": None}}, properties=["formula", name]))
|
|
1984
|
+
data = list(self.database.query({"formula": rform, name: {"$ne": None}}))
|
|
1985
|
+
# formulas should always be unique in the database. len==0 indicates no
|
|
1986
|
+
# data. len>1 indicates duplicate data.
|
|
1987
|
+
if len(data) > 1:
|
|
1988
|
+
self.logger.warning(f"Duplicate database entries for solute {solute} found!")
|
|
1989
|
+
if len(data) == 0:
|
|
1990
|
+
# TODO - add molar volume of water to database?
|
|
1991
|
+
if name == "size.molar_volume" and rform == "H2O(aq)":
|
|
1992
|
+
# calculate the partial molar volume for water since it isn't in the database
|
|
1993
|
+
vol = ureg.Quantity(self.get_property("H2O", "molecular_weight")) / (
|
|
1994
|
+
ureg.Quantity(self.water_substance.rho, "g/L")
|
|
1995
|
+
)
|
|
2021
1996
|
|
|
2022
|
-
|
|
2023
|
-
----------
|
|
2024
|
-
None
|
|
1997
|
+
return vol.to("cm **3 / mol")
|
|
2025
1998
|
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
The Bjerrum length, in nanometers.
|
|
1999
|
+
# try to determine basic properties using pymatgen
|
|
2000
|
+
doc = Solute.from_formula(rform).as_dict()
|
|
2001
|
+
data = [doc]
|
|
2030
2002
|
|
|
2031
|
-
|
|
2032
|
-
----------
|
|
2033
|
-
.. [#] https://en.wikipedia.org/wiki/Bjerrum_length
|
|
2003
|
+
doc: dict = data[0]
|
|
2034
2004
|
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2005
|
+
try:
|
|
2006
|
+
# perform temperature-corrections or other adjustments for certain
|
|
2007
|
+
# parameter types
|
|
2008
|
+
if name == "transport.diffusion_coefficient":
|
|
2009
|
+
data = doc["transport"]["diffusion_coefficient"]
|
|
2010
|
+
if data is not None:
|
|
2011
|
+
return ureg.Quantity(data["value"]).to("m**2/s")
|
|
2012
|
+
|
|
2013
|
+
# just return the base-value molar volume for now; find a way to adjust for concentration later
|
|
2014
|
+
if name == "size.molar_volume":
|
|
2015
|
+
data = doc["size"]["molar_volume"]
|
|
2016
|
+
if data is not None:
|
|
2017
|
+
base_value = ureg.Quantity(doc["size"]["molar_volume"].get("value"))
|
|
2018
|
+
if self.temperature != base_temperature:
|
|
2019
|
+
self.logger.warning(f"Partial molar volume for species {solute} not corrected for temperature")
|
|
2020
|
+
return base_value
|
|
2021
|
+
return data
|
|
2040
2022
|
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
get_dielectric_constant
|
|
2023
|
+
if name == "model_parameters.dielectric_zuber":
|
|
2024
|
+
return ureg.Quantity(doc["model_parameters"]["dielectric_zuber"]["value"])
|
|
2044
2025
|
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2026
|
+
if name == "model_parameters.activity_pitzer":
|
|
2027
|
+
# return a dict
|
|
2028
|
+
if doc["model_parameters"]["activity_pitzer"].get("Beta0") is not None:
|
|
2029
|
+
return doc["model_parameters"]["activity_pitzer"]
|
|
2030
|
+
return None
|
|
2048
2031
|
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2032
|
+
if name == "model_parameters.molar_volume_pitzer":
|
|
2033
|
+
# return a dict
|
|
2034
|
+
if doc["model_parameters"]["molar_volume_pitzer"].get("Beta0") is not None:
|
|
2035
|
+
return doc["model_parameters"]["molar_volume_pitzer"]
|
|
2036
|
+
return None
|
|
2053
2037
|
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
Calculate the transport number of the solute in the solution
|
|
2038
|
+
if name == "molecular_weight":
|
|
2039
|
+
return ureg.Quantity(doc.get(name))
|
|
2057
2040
|
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
solute : str
|
|
2061
|
-
String identifying the solute for which the transport number is
|
|
2062
|
-
to be calculated.
|
|
2041
|
+
if name == "elements":
|
|
2042
|
+
return doc.get(name)
|
|
2063
2043
|
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
if omitted.
|
|
2044
|
+
if name == "oxi_state_guesses":
|
|
2045
|
+
# ensure that all oxi states are returned as floats
|
|
2046
|
+
return {k: float(v) for k, v in doc.get(name).items()}
|
|
2068
2047
|
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2048
|
+
# for parameters not named above, just return the base value
|
|
2049
|
+
if name == "pmg_ion" or not isinstance(doc.get(name), dict):
|
|
2050
|
+
# if the queried value is not a dict, it is a root level key and should be returned as is
|
|
2051
|
+
return doc.get(name)
|
|
2073
2052
|
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2053
|
+
val = doc[name].get("value")
|
|
2054
|
+
# self.logger.warning("%s has not been corrected for solution conditions" % name)
|
|
2055
|
+
if val is not None:
|
|
2056
|
+
return ureg.Quantity(val)
|
|
2077
2057
|
|
|
2078
|
-
|
|
2058
|
+
except KeyError:
|
|
2059
|
+
self.logger.error(f"Property {name} for solute {solute} not found in database. Returning None.")
|
|
2060
|
+
return None
|
|
2079
2061
|
|
|
2080
|
-
|
|
2062
|
+
if name == "model_parameters.molar_volume_pitzer":
|
|
2063
|
+
# return a dict
|
|
2064
|
+
if doc["model_parameters"]["molar_volume_pitzer"].get("Beta0") is not None:
|
|
2065
|
+
return doc["model_parameters"]["molar_volume_pitzer"]
|
|
2066
|
+
return None
|
|
2081
2067
|
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
over all species in the solution.
|
|
2068
|
+
if name == "molecular_weight":
|
|
2069
|
+
return ureg.Quantity(doc.get(name))
|
|
2085
2070
|
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
for get_conductivity() for an explanation of this correction.
|
|
2071
|
+
if name == "oxi_state_guesses":
|
|
2072
|
+
return doc.get(name)
|
|
2089
2073
|
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
*Phys. Chem. Chem. Phys.* 2014, 16, 21673–21681.
|
|
2074
|
+
# for parameters not named above, just return the base value
|
|
2075
|
+
if name == "pmg_ion" or not isinstance(doc.get(name), dict):
|
|
2076
|
+
# if the queried value is not a dict, it is a root level key and should be returned as is
|
|
2077
|
+
return doc.get(name)
|
|
2095
2078
|
|
|
2096
|
-
""
|
|
2097
|
-
|
|
2098
|
-
|
|
2079
|
+
val = doc[name].get("value")
|
|
2080
|
+
# self.logger.warning("%s has not been corrected for solution conditions" % name)
|
|
2081
|
+
if val is not None:
|
|
2082
|
+
return ureg.Quantity(val)
|
|
2083
|
+
return None
|
|
2099
2084
|
|
|
2100
|
-
|
|
2085
|
+
def get_transport_number(self, solute: str) -> Quantity:
|
|
2086
|
+
r"""Calculate the transport number of the solute in the solution.
|
|
2101
2087
|
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
* z ** 2
|
|
2106
|
-
* self.get_amount(item, "mol/L")
|
|
2107
|
-
)
|
|
2088
|
+
Args:
|
|
2089
|
+
solute: Formula of the solute for which the transport number is
|
|
2090
|
+
to be calculated.
|
|
2108
2091
|
|
|
2109
|
-
|
|
2110
|
-
|
|
2092
|
+
Returns:
|
|
2093
|
+
The transport number of `solute`, as a dimensionless Quantity.
|
|
2111
2094
|
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
else:
|
|
2115
|
-
alpha = self.get_ionic_strength().magnitude ** 0.5 / z
|
|
2095
|
+
Notes:
|
|
2096
|
+
Transport number is calculated according to :
|
|
2116
2097
|
|
|
2117
|
-
|
|
2118
|
-
numerator = term * gamma ** alpha
|
|
2098
|
+
.. math::
|
|
2119
2099
|
|
|
2120
|
-
|
|
2100
|
+
t_i = {D_i z_i^2 C_i \over \sum D_i z_i^2 C_i}
|
|
2121
2101
|
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2102
|
+
Where :math:`C_i` is the concentration in mol/L, :math:`D_i` is the diffusion
|
|
2103
|
+
coefficient, and :math:`z_i` is the charge, and the summation extends
|
|
2104
|
+
over all species in the solution.
|
|
2125
2105
|
|
|
2126
|
-
|
|
2106
|
+
Diffusion coefficients :math:`D_i` are adjusted for the effects of temperature
|
|
2107
|
+
and ionic strength using the method implemented in PHREEQC >= 3.4.
|
|
2108
|
+
See `get_diffusion_coefficient for` further details.
|
|
2127
2109
|
|
|
2128
|
-
return (numerator / denominator).to("dimensionless")
|
|
2129
2110
|
|
|
2130
|
-
|
|
2111
|
+
References:
|
|
2112
|
+
Geise, G. M.; Cassady, H. J.; Paul, D. R.; Logan, E.; Hickner, M. A. "Specific
|
|
2113
|
+
ion effects on membrane potential and the permselectivity of ion exchange membranes.""
|
|
2114
|
+
*Phys. Chem. Chem. Phys.* 2014, 16, 21673-21681.
|
|
2131
2115
|
|
|
2116
|
+
See Also:
|
|
2117
|
+
:py:meth:`get_diffusion_coefficient`
|
|
2118
|
+
:py:meth:`get_molar_conductivity`
|
|
2132
2119
|
"""
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
Parameters
|
|
2136
|
-
----------
|
|
2137
|
-
solute : str
|
|
2138
|
-
String identifying the solute for which the molar conductivity is
|
|
2139
|
-
to be calculated.
|
|
2120
|
+
solute = standardize_formula(solute)
|
|
2121
|
+
denominator = numerator = 0
|
|
2140
2122
|
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2123
|
+
for item, mol in self.components.items():
|
|
2124
|
+
# the molar conductivity of each species is F/RT D * z^2, and the F/RT factor
|
|
2125
|
+
# cancels out
|
|
2126
|
+
# using species amounts in mol is equivalent to using concentrations in mol/L
|
|
2127
|
+
# since there is only one solution volume, and it's much faster.
|
|
2128
|
+
term = self.get_molar_conductivity(item).magnitude * mol
|
|
2146
2129
|
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
Molar conductivity is calculated from the Nernst-Einstein relation [#]_
|
|
2130
|
+
if item == solute:
|
|
2131
|
+
numerator = term
|
|
2150
2132
|
|
|
2151
|
-
|
|
2133
|
+
denominator += term
|
|
2152
2134
|
|
|
2153
|
-
|
|
2135
|
+
return ureg.Quantity(numerator / denominator, "dimensionless")
|
|
2154
2136
|
|
|
2155
|
-
|
|
2137
|
+
def _get_molar_conductivity(self, solute: str) -> Quantity:
|
|
2138
|
+
r"""
|
|
2139
|
+
Calculate the molar (equivalent) conductivity for a solute.
|
|
2156
2140
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2141
|
+
Args:
|
|
2142
|
+
solute: String identifying the solute for which the molar conductivity is
|
|
2143
|
+
to be calculated.
|
|
2159
2144
|
|
|
2160
|
-
|
|
2145
|
+
Returns:
|
|
2146
|
+
The molar or equivalent conductivity of the species in the solution.
|
|
2147
|
+
Zero if the solute is not charged.
|
|
2161
2148
|
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
TODO
|
|
2149
|
+
Notes:
|
|
2150
|
+
Molar conductivity is calculated from the Nernst-Einstein relation [smed]_
|
|
2165
2151
|
|
|
2166
|
-
|
|
2167
|
-
temperature = self.get_temperature()
|
|
2152
|
+
.. math::
|
|
2168
2153
|
|
|
2169
|
-
|
|
2154
|
+
\lambda_i = \frac{F^2}{RT} D_i z_i^2
|
|
2170
2155
|
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
* self.get_solute(solute).get_formal_charge() ** 2
|
|
2175
|
-
/ (unit.R * temperature)
|
|
2176
|
-
)
|
|
2156
|
+
Diffusion coefficients :math:`D_i` are adjusted for the effects of temperature
|
|
2157
|
+
and ionic strength using the method implemented in PHREEQC >= 3.4. See `get_diffusion_coefficient`
|
|
2158
|
+
for further details.
|
|
2177
2159
|
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
% (molar_cond, str(D), temperature)
|
|
2181
|
-
)
|
|
2160
|
+
References:
|
|
2161
|
+
1. .. [smed] Smedley, Stuart. The Interpretation of Ionic Conductivity in Liquids, pp 1-9. Plenum Press, 1980.
|
|
2182
2162
|
|
|
2183
|
-
|
|
2163
|
+
2. https://www.hydrochemistry.eu/exmpls/sc.html
|
|
2184
2164
|
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2165
|
+
3. Appelo, C.A.J. Solute transport solved with the Nernst-Planck equation for concrete pores with `free'
|
|
2166
|
+
water and a double layer. Cement and Concrete Research 101, 2017.
|
|
2167
|
+
https://dx.doi.org/10.1016/j.cemconres.2017.08.030
|
|
2188
2168
|
|
|
2189
|
-
|
|
2190
|
-
----------
|
|
2191
|
-
solute : str
|
|
2192
|
-
String identifying the solute for which the mobility is
|
|
2193
|
-
to be calculated.
|
|
2169
|
+
4. CRC Handbook of Chemistry and Physics
|
|
2194
2170
|
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2171
|
+
See Also:
|
|
2172
|
+
:py:meth:`get_diffusion_coefficient`
|
|
2173
|
+
"""
|
|
2174
|
+
D = self.get_diffusion_coefficient(solute)
|
|
2198
2175
|
|
|
2176
|
+
if D != 0:
|
|
2177
|
+
molar_cond = (
|
|
2178
|
+
D * (ureg.e * ureg.N_A) ** 2 * self.get_property(solute, "charge") ** 2 / (ureg.R * self.temperature)
|
|
2179
|
+
)
|
|
2180
|
+
else:
|
|
2181
|
+
molar_cond = ureg.Quantity(0, "mS / cm / (mol/L)")
|
|
2199
2182
|
|
|
2200
|
-
|
|
2201
|
-
-----
|
|
2202
|
-
This function uses the Einstein relation to convert a diffusion coefficient
|
|
2203
|
-
into an ionic mobility [#]_
|
|
2183
|
+
self.logger.debug(f"Calculated molar conductivity as {molar_cond} from D = {D!s} at T={self.temperature}")
|
|
2204
2184
|
|
|
2205
|
-
|
|
2185
|
+
return molar_cond.to("mS / cm / (mol/L)")
|
|
2206
2186
|
|
|
2207
|
-
|
|
2187
|
+
def _get_diffusion_coefficient(self, solute: str, activity_correction: bool = True) -> Quantity:
|
|
2188
|
+
r"""
|
|
2189
|
+
Get the **temperature-adjusted** diffusion coefficient of a solute.
|
|
2208
2190
|
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2191
|
+
Args:
|
|
2192
|
+
solute: the solute for which to retrieve the diffusion coefficient.
|
|
2193
|
+
activity_correction: If True (default), adjusts the diffusion coefficient for the effects of ionic
|
|
2194
|
+
strength using a model from Ref 2.
|
|
2212
2195
|
|
|
2213
|
-
|
|
2214
|
-
|
|
2196
|
+
Notes:
|
|
2197
|
+
This method is equivalent to self.get_property(solute, "transport.diffusion_coefficient")
|
|
2198
|
+
ONLY when the Solution temperature is the same as the reference temperature for the diffusion coefficient
|
|
2199
|
+
in the database (usually 25 C).
|
|
2215
2200
|
|
|
2216
|
-
|
|
2201
|
+
Otherwise, the reference D value is adjusted based on the Solution temperature and (optionally),
|
|
2202
|
+
ionic strength. The adjustments are
|
|
2217
2203
|
|
|
2218
|
-
|
|
2219
|
-
unit.N_A
|
|
2220
|
-
* unit.e
|
|
2221
|
-
* abs(self.get_solute(solute).get_formal_charge())
|
|
2222
|
-
* D
|
|
2223
|
-
/ (unit.R * temperature)
|
|
2224
|
-
)
|
|
2204
|
+
.. math::
|
|
2225
2205
|
|
|
2226
|
-
|
|
2227
|
-
"Computed ionic mobility as %s from D = %s at T=%s"
|
|
2228
|
-
% (mobility, str(D), temperature)
|
|
2229
|
-
)
|
|
2206
|
+
D_T = D_{298} \exp(\frac{d}{T} - \frac{d}{298}) \frac{\nu_{298}}{\nu_T}
|
|
2230
2207
|
|
|
2231
|
-
|
|
2208
|
+
.. math::
|
|
2232
2209
|
|
|
2233
|
-
|
|
2234
|
-
"""Retrieve a thermodynamic property (such as diffusion coefficient)
|
|
2235
|
-
for solute, and adjust it from the reference conditions to the conditions
|
|
2236
|
-
of the solution
|
|
2210
|
+
D_{\gamma} = D^0 \exp(\frac{-a1 A |z_i| \sqrt{I}}{1+\kappa a}
|
|
2237
2211
|
|
|
2238
|
-
|
|
2239
|
-
----------
|
|
2240
|
-
solute: str
|
|
2241
|
-
String representing the chemical formula of the solute species
|
|
2242
|
-
name: str
|
|
2243
|
-
The name of the property needed, e.g.
|
|
2244
|
-
'diffusion coefficient'
|
|
2212
|
+
.. math::
|
|
2245
2213
|
|
|
2246
|
-
|
|
2247
|
-
-------
|
|
2248
|
-
Quantity: The desired parameter
|
|
2214
|
+
\kappa a = B \sqrt{I} \frac{a2}{1+I^{0.75}}
|
|
2249
2215
|
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2216
|
+
where a1, a2, and d are parameters from Ref. 2, A and B are the parameters used in the Debye Huckel
|
|
2217
|
+
equation, and I is the ionic strength. If the model parameters for a particular solute are not available,
|
|
2218
|
+
default values of d=0, a1=1.6, and a2=4.73 (as recommended in Ref. 2) are used instead.
|
|
2253
2219
|
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
base_pressure = unit("1 atm")
|
|
2261
|
-
|
|
2262
|
-
# perform temperature-corrections or other adjustments for certain
|
|
2263
|
-
# parameter types
|
|
2264
|
-
if name == "diffusion_coefficient":
|
|
2265
|
-
if base_value is not None:
|
|
2266
|
-
# correct for temperature and viscosity
|
|
2267
|
-
# .. math:: D_1 \\over D_2 = T_1 \\over T_2 * \\mu_2 \\over \\mu_1
|
|
2268
|
-
# where :math:`\\mu` is the dynamic viscosity
|
|
2269
|
-
# assume that the base viscosity is that of pure water
|
|
2270
|
-
return (
|
|
2271
|
-
base_value
|
|
2272
|
-
* self.get_temperature()
|
|
2273
|
-
/ base_temperature
|
|
2274
|
-
* h2o.water_viscosity_dynamic(base_temperature, base_pressure)
|
|
2275
|
-
/ self.get_viscosity_dynamic()
|
|
2276
|
-
)
|
|
2277
|
-
else:
|
|
2278
|
-
logger.warning(
|
|
2279
|
-
"Diffusion coefficient not found for species %s. Assuming zero."
|
|
2280
|
-
% (solute)
|
|
2281
|
-
)
|
|
2282
|
-
return unit("0 m**2/s")
|
|
2283
|
-
|
|
2284
|
-
# just return the base-value molar volume for now; find a way to adjust for
|
|
2285
|
-
# concentration later
|
|
2286
|
-
if name == "partial_molar_volume":
|
|
2287
|
-
# calculate the partial molar volume for water since it isn't in the database
|
|
2288
|
-
if solute == "H2O":
|
|
2289
|
-
vol = self.get_solute("H2O").get_molecular_weight() / h2o.water_density(
|
|
2290
|
-
self.get_temperature()
|
|
2291
|
-
)
|
|
2292
|
-
return vol.to("cm **3 / mol")
|
|
2293
|
-
else:
|
|
2294
|
-
if base_value is not None:
|
|
2295
|
-
return base_value
|
|
2296
|
-
if self.get_temperature() != base_temperature:
|
|
2297
|
-
logger.warning(
|
|
2298
|
-
"Partial molar volume for species %s not corrected for temperature"
|
|
2299
|
-
% solute
|
|
2300
|
-
)
|
|
2301
|
-
else:
|
|
2302
|
-
logger.warning(
|
|
2303
|
-
"Partial molar volume not found for species %s. Assuming zero."
|
|
2304
|
-
% solute
|
|
2305
|
-
)
|
|
2306
|
-
return unit("0 cm **3 / mol")
|
|
2220
|
+
References:
|
|
2221
|
+
1. https://www.hydrochemistry.eu/exmpls/sc.html
|
|
2222
|
+
2. Appelo, C.A.J. Solute transport solved with the Nernst-Planck equation for concrete pores with `free'
|
|
2223
|
+
water and a double layer. Cement and Concrete Research 101, 2017.
|
|
2224
|
+
https://dx.doi.org/10.1016/j.cemconres.2017.08.030
|
|
2225
|
+
3. CRC Handbook of Chemistry and Physics
|
|
2307
2226
|
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
return base_value
|
|
2227
|
+
See Also:
|
|
2228
|
+
pyEQL.activity_correction._debye_parameter_B
|
|
2229
|
+
pyEQL.activity_correction._debye_parameter_activity
|
|
2312
2230
|
|
|
2313
|
-
def get_chemical_potential_energy(self, activity_correction=True):
|
|
2314
2231
|
"""
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
a calculation of the ideal chemical potential.
|
|
2324
|
-
|
|
2325
|
-
Returns
|
|
2326
|
-
-------
|
|
2327
|
-
Quantity
|
|
2328
|
-
The actual or ideal chemical potential energy of the solution, in Joules.
|
|
2232
|
+
D = self.get_property(solute, "transport.diffusion_coefficient")
|
|
2233
|
+
rform = standardize_formula(solute)
|
|
2234
|
+
if D is None or D.magnitude == 0:
|
|
2235
|
+
self.logger.warning(
|
|
2236
|
+
f"Diffusion coefficient not found for species {rform}. Using default value of "
|
|
2237
|
+
f"{self.default_diffusion_coeff} m**2/s."
|
|
2238
|
+
)
|
|
2239
|
+
D = ureg.Quantity(self.default_diffusion_coeff, "m**2/s")
|
|
2329
2240
|
|
|
2330
|
-
|
|
2331
|
-
|
|
2241
|
+
# assume reference temperature is 298.15 K (this is the case for all current DB entries)
|
|
2242
|
+
T_ref = 298.15
|
|
2243
|
+
mu_ref = 0.0008900225512925807 # water viscosity from IAPWS97 at 298.15 K
|
|
2244
|
+
T_sol = self.temperature.to("K").magnitude
|
|
2245
|
+
mu = self.water_substance.mu
|
|
2332
2246
|
|
|
2333
|
-
|
|
2334
|
-
|
|
2247
|
+
# skip temperature correction if within 1 degree
|
|
2248
|
+
if abs(T_sol - T_ref) > 1 or activity_correction is True:
|
|
2249
|
+
# get the a1, a2, and d parameters required by the PHREEQC model
|
|
2250
|
+
try:
|
|
2251
|
+
doc = self.database.query_one({"formula": rform})
|
|
2252
|
+
d = doc["model_parameters"]["diffusion_temp_smolyakov"]["d"]["value"]
|
|
2253
|
+
a1 = doc["model_parameters"]["diffusion_temp_smolyakov"]["a1"]["value"]
|
|
2254
|
+
a2 = doc["model_parameters"]["diffusion_temp_smolyakov"]["a2"]["value"]
|
|
2255
|
+
# values will be a str, e.g. "1 dimensionless"
|
|
2256
|
+
d = float(d.split(" ")[0])
|
|
2257
|
+
a1 = float(a1.split(" ")[0])
|
|
2258
|
+
a2 = float(a2.split(" ")[0])
|
|
2259
|
+
except TypeError:
|
|
2260
|
+
# this means the database doesn't contain a d value.
|
|
2261
|
+
# according to Ref 2, the following are recommended default parameters
|
|
2262
|
+
self.logger.warning(
|
|
2263
|
+
f"Temperature and ionic strength correction parameters for solute {rform} diffusion "
|
|
2264
|
+
"coefficient not in database. Using recommended default values of a1=1.6, a2=4.73, and d=0."
|
|
2265
|
+
)
|
|
2266
|
+
d = 0
|
|
2267
|
+
a1 = 1.6
|
|
2268
|
+
a2 = 4.73
|
|
2269
|
+
|
|
2270
|
+
# use the PHREEQC model from Ref 2 to correct for temperature
|
|
2271
|
+
D_final = D * np.exp(d / T_sol - d / T_ref) * mu_ref / mu
|
|
2272
|
+
|
|
2273
|
+
if activity_correction:
|
|
2274
|
+
A = _debye_parameter_activity(str(self.temperature)).to("kg**0.5/mol**0.5").magnitude / 2.303
|
|
2275
|
+
B = _debye_parameter_B(str(self.temperature)).to("1/angstrom * kg**0.5/mol**0.5").magnitude
|
|
2276
|
+
z = self.get_property(solute, "charge")
|
|
2277
|
+
IS = self.ionic_strength.magnitude
|
|
2278
|
+
kappaa = B * IS**0.5 * a2 / (1 + IS**0.75)
|
|
2279
|
+
# correct for ionic strength
|
|
2280
|
+
D_final *= np.exp(-a1 * A * abs(z) * IS**0.5 / (1 + kappaa))
|
|
2281
|
+
# else:
|
|
2282
|
+
# # per CRC handbook, D increases by 2-3% per degree above 25 C
|
|
2283
|
+
# return D * (1 + 0.025 * (T_sol - T_ref))
|
|
2284
|
+
else:
|
|
2285
|
+
D_final = D
|
|
2335
2286
|
|
|
2336
|
-
|
|
2287
|
+
return D_final
|
|
2337
2288
|
|
|
2338
|
-
|
|
2289
|
+
def _get_mobility(self, solute: str) -> Quantity:
|
|
2290
|
+
r"""
|
|
2291
|
+
Calculate the ionic mobility of the solute.
|
|
2339
2292
|
|
|
2340
|
-
|
|
2293
|
+
Args:
|
|
2294
|
+
solute (str): String identifying the solute for which the mobility is to be calculated.
|
|
2341
2295
|
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
each component.
|
|
2296
|
+
Returns:
|
|
2297
|
+
float: The ionic mobility. Zero if the solute is not charged.
|
|
2345
2298
|
|
|
2346
|
-
Note
|
|
2347
|
-
|
|
2348
|
-
anion, and water).
|
|
2299
|
+
Note:
|
|
2300
|
+
This function uses the Einstein relation to convert a diffusion coefficient into an ionic mobility [smed]_
|
|
2349
2301
|
|
|
2350
|
-
|
|
2351
|
-
----------
|
|
2352
|
-
.. [#] Koga, Yoshikata, 2007. *Solution Thermodynamics and its Application to Aqueous Solutions: A differential
|
|
2353
|
-
approach.* Elsevier, 2007, pp. 23-37.
|
|
2302
|
+
.. math::
|
|
2354
2303
|
|
|
2355
|
-
|
|
2356
|
-
--------
|
|
2304
|
+
\mu_i = {F |z_i| D_i \over RT}
|
|
2357
2305
|
|
|
2306
|
+
References:
|
|
2307
|
+
Smedley, Stuart I. The Interpretation of Ionic Conductivity in Liquids. Plenum Press, 1980.
|
|
2358
2308
|
"""
|
|
2359
|
-
|
|
2309
|
+
D = self.get_diffusion_coefficient(solute)
|
|
2360
2310
|
|
|
2361
|
-
|
|
2311
|
+
mobility = ureg.N_A * ureg.e * abs(self.get_property(solute, "charge")) * D / (ureg.R * self.temperature)
|
|
2362
2312
|
|
|
2363
|
-
|
|
2364
|
-
for item in self.components:
|
|
2365
|
-
try:
|
|
2366
|
-
if activity_correction is True:
|
|
2367
|
-
E += (
|
|
2368
|
-
unit.R
|
|
2369
|
-
* temperature.to("K")
|
|
2370
|
-
* self.get_amount(item, "mol")
|
|
2371
|
-
* math.log(self.get_activity(item))
|
|
2372
|
-
)
|
|
2373
|
-
else:
|
|
2374
|
-
E += (
|
|
2375
|
-
unit.R
|
|
2376
|
-
* temperature.to("K")
|
|
2377
|
-
* self.get_amount(item, "mol")
|
|
2378
|
-
* math.log(self.get_amount(item, "fraction"))
|
|
2379
|
-
)
|
|
2380
|
-
# If we have a solute with zero concentration, we will get a ValueError
|
|
2381
|
-
except ValueError:
|
|
2382
|
-
continue
|
|
2313
|
+
self.logger.debug(f"Calculated ionic mobility as {mobility} from D = {D!s} at T={self.temperature}")
|
|
2383
2314
|
|
|
2384
|
-
return
|
|
2315
|
+
return mobility.to("m**2/V/s")
|
|
2385
2316
|
|
|
2386
|
-
def get_lattice_distance(self, solute):
|
|
2387
|
-
"""
|
|
2388
|
-
Calculate the average distance between molecules
|
|
2317
|
+
def get_lattice_distance(self, solute: str) -> Quantity:
|
|
2318
|
+
r"""
|
|
2319
|
+
Calculate the average distance between molecules.
|
|
2389
2320
|
|
|
2390
2321
|
Calculate the average distance between molecules of the given solute,
|
|
2391
2322
|
assuming that the molecules are uniformly distributed throughout the
|
|
2392
2323
|
solution.
|
|
2393
2324
|
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
String representing the name of the solute of interest
|
|
2325
|
+
Args:
|
|
2326
|
+
solute : str
|
|
2327
|
+
String representing the name of the solute of interest
|
|
2398
2328
|
|
|
2399
|
-
Returns
|
|
2400
|
-
|
|
2401
|
-
Quantity : The average distance between solute molecules
|
|
2329
|
+
Returns:
|
|
2330
|
+
Quantity: The average distance between solute molecules
|
|
2402
2331
|
|
|
2403
|
-
Examples
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
1.492964.... nanometer
|
|
2332
|
+
Examples:
|
|
2333
|
+
>>> soln = Solution([['Na+','0.5 mol/kg'],['Cl-','0.5 mol/kg']])
|
|
2334
|
+
>>> soln.get_lattice_distance('Na+')
|
|
2335
|
+
1.492964.... nanometer
|
|
2408
2336
|
|
|
2409
|
-
Notes
|
|
2410
|
-
|
|
2411
|
-
The lattice distance is related to the molar concentration as follows:
|
|
2337
|
+
Notes:
|
|
2338
|
+
The lattice distance is related to the molar concentration as follows:
|
|
2412
2339
|
|
|
2413
|
-
|
|
2340
|
+
.. math:: d = ( C_i N_A ) ^ {-{1 \over 3}}
|
|
2414
2341
|
|
|
2415
2342
|
"""
|
|
2416
2343
|
# calculate the volume per particle as the reciprocal of the molar concentration
|
|
2417
2344
|
# (times avogadro's number). Take the cube root of the volume to get
|
|
2418
2345
|
# the average distance between molecules
|
|
2419
|
-
distance = (self.get_amount(solute, "mol/L") *
|
|
2346
|
+
distance = (self.get_amount(solute, "mol/L") * ureg.N_A) ** (-1 / 3)
|
|
2420
2347
|
|
|
2421
2348
|
return distance.to("nm")
|
|
2422
2349
|
|
|
2423
2350
|
def _update_volume(self):
|
|
2424
|
-
"""
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
"""
|
|
2428
|
-
self.volume = self._get_solvent_volume() + self._get_solute_volume()
|
|
2351
|
+
"""Recalculate the solution volume based on composition."""
|
|
2352
|
+
self._volume = self._get_solvent_volume() + self._get_solute_volume()
|
|
2429
2353
|
|
|
2430
2354
|
def _get_solvent_volume(self):
|
|
2431
|
-
"""
|
|
2432
|
-
Return the volume of the pure solvent
|
|
2433
|
-
|
|
2434
|
-
"""
|
|
2355
|
+
"""Return the volume of the pure solvent."""
|
|
2435
2356
|
# calculate the volume of the pure solvent
|
|
2436
|
-
solvent_vol = self.
|
|
2437
|
-
self.get_temperature(), self.get_pressure()
|
|
2438
|
-
)
|
|
2357
|
+
solvent_vol = self.solvent_mass / ureg.Quantity(self.water_substance.rho, "g/L")
|
|
2439
2358
|
|
|
2440
2359
|
return solvent_vol.to("L")
|
|
2441
2360
|
|
|
2442
2361
|
def _get_solute_volume(self):
|
|
2443
|
-
"""
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
"""
|
|
2447
|
-
temperature = str(self.get_temperature())
|
|
2448
|
-
|
|
2449
|
-
# identify the predominant salt in the solution
|
|
2450
|
-
Salt = self.get_salt()
|
|
2451
|
-
|
|
2452
|
-
# search the database for pitzer parameters for 'salt'
|
|
2453
|
-
db.search_parameters(Salt.formula)
|
|
2454
|
-
|
|
2455
|
-
solute_vol = 0 * unit("L")
|
|
2456
|
-
|
|
2457
|
-
# use the pitzer approach if parameters are available
|
|
2458
|
-
|
|
2459
|
-
pitzer_calc = False
|
|
2460
|
-
|
|
2461
|
-
if db.has_parameter(Salt.formula, "pitzer_parameters_volume"):
|
|
2462
|
-
|
|
2463
|
-
param = db.get_parameter(Salt.formula, "pitzer_parameters_volume")
|
|
2464
|
-
|
|
2465
|
-
# determine the average molality of the salt
|
|
2466
|
-
# this is necessary for solutions inside e.g. an ion exchange
|
|
2467
|
-
# membrane, where the cation and anion concentrations may be
|
|
2468
|
-
# unequal
|
|
2469
|
-
molality = (
|
|
2470
|
-
self.get_amount(Salt.cation, "mol/kg")
|
|
2471
|
-
+ self.get_amount(Salt.anion, "mol/kg")
|
|
2472
|
-
) / 2
|
|
2362
|
+
"""Return the volume of only the solutes."""
|
|
2363
|
+
return self.engine.get_solute_volume(self)
|
|
2473
2364
|
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2365
|
+
def as_dict(self) -> dict:
|
|
2366
|
+
"""Convert the Solution into a dict representation that can be serialized to .json or other format."""
|
|
2367
|
+
# clear the volume update flag, if required
|
|
2368
|
+
if self.volume_update_required:
|
|
2369
|
+
self._update_volume()
|
|
2370
|
+
d = super().as_dict()
|
|
2371
|
+
for k, v in d.items():
|
|
2372
|
+
# convert all Quantity to str
|
|
2373
|
+
if isinstance(v, Quantity):
|
|
2374
|
+
d[k] = str(v)
|
|
2375
|
+
# replace solutes with the current composition
|
|
2376
|
+
d["solutes"] = {k: f"{v} mol" for k, v in self.components.items()}
|
|
2377
|
+
# replace the engine with the associated str
|
|
2378
|
+
d["engine"] = self._engine
|
|
2379
|
+
# d["logger"] = self.logger.__dict__
|
|
2380
|
+
return d
|
|
2381
|
+
|
|
2382
|
+
@classmethod
|
|
2383
|
+
def from_dict(cls, d: dict) -> Solution:
|
|
2384
|
+
"""Instantiate a Solution from a dictionary generated by as_dict()."""
|
|
2385
|
+
# because of the automatic volume updating that takes place during the __init__ process,
|
|
2386
|
+
# care must be taken here to recover the exact quantities of solute and volume
|
|
2387
|
+
# first we store the volume of the serialized solution
|
|
2388
|
+
orig_volume = ureg.Quantity(d["volume"])
|
|
2389
|
+
# then instantiate a new one
|
|
2390
|
+
decoded = {k: MontyDecoder().process_decoded(v) for k, v in d.items() if not k.startswith("@")}
|
|
2391
|
+
new_sol = cls(**decoded)
|
|
2392
|
+
# now determine how different the new solution volume is from the original
|
|
2393
|
+
scale_factor = (orig_volume / new_sol.volume).magnitude
|
|
2394
|
+
# reset the new solution volume to that of the original. In the process of
|
|
2395
|
+
# doing this, all the solute amounts are scaled by new_sol.volume / volume
|
|
2396
|
+
new_sol.volume = str(orig_volume)
|
|
2397
|
+
# undo the scaling by diving by that scale factor
|
|
2398
|
+
for sol in new_sol.components:
|
|
2399
|
+
new_sol.components[sol] /= scale_factor
|
|
2400
|
+
# ensure that another volume update won't be triggered by these changes
|
|
2401
|
+
# (this line should in principle be unnecessary, but it doesn't hurt anything)
|
|
2402
|
+
new_sol.volume_update_required = False
|
|
2403
|
+
return new_sol
|
|
2404
|
+
|
|
2405
|
+
@classmethod
|
|
2406
|
+
def from_preset(
|
|
2407
|
+
cls, preset: Literal["seawater", "rainwater", "wastewater", "urine", "normal saline", "Ringers lactate"]
|
|
2408
|
+
) -> Solution:
|
|
2409
|
+
"""Instantiate a solution from a preset composition.
|
|
2410
|
+
|
|
2411
|
+
Args:
|
|
2412
|
+
preset (str): String representing the desired solution.
|
|
2413
|
+
Valid entries are 'seawater', 'rainwater', 'wastewater',
|
|
2414
|
+
'urine', 'normal saline' and 'Ringers lactate'.
|
|
2415
|
+
|
|
2416
|
+
Returns:
|
|
2417
|
+
A pyEQL Solution object.
|
|
2418
|
+
|
|
2419
|
+
Raises:
|
|
2420
|
+
FileNotFoundError: If the given preset file doesn't exist on the file system.
|
|
2421
|
+
|
|
2422
|
+
Notes:
|
|
2423
|
+
The following sections explain the different solution options:
|
|
2424
|
+
|
|
2425
|
+
- 'rainwater' - pure water in equilibrium with atmospheric CO2 at pH 6
|
|
2426
|
+
- 'seawater' or 'SW'- Standard Seawater. See Table 4 of the Reference for Composition [1]_
|
|
2427
|
+
- 'wastewater' or 'WW' - medium strength domestic wastewater. See Table 3-18 of [2]_
|
|
2428
|
+
- 'urine' - typical human urine. See Table 3-15 of [2]_
|
|
2429
|
+
- 'normal saline' or 'NS' - normal saline solution used in medicine [3]_
|
|
2430
|
+
- 'Ringers lacatate' or 'RL' - Ringer's lactate solution used in medicine [4]_
|
|
2431
|
+
|
|
2432
|
+
References:
|
|
2433
|
+
.. [1] Millero, Frank J. "The composition of Standard Seawater and the definition of
|
|
2434
|
+
the Reference-Composition Salinity Scale." *Deep-sea Research. Part I* 55(1), 2008, 50-72.
|
|
2435
|
+
|
|
2436
|
+
.. [2] Metcalf & Eddy, Inc. et al. *Wastewater Engineering: Treatment and Resource Recovery*, 5th Ed.
|
|
2437
|
+
McGraw-Hill, 2013.
|
|
2438
|
+
|
|
2439
|
+
.. [3] https://en.wikipedia.org/wiki/Saline_(medicine)
|
|
2440
|
+
|
|
2441
|
+
.. [4] https://en.wikipedia.org/wiki/Ringer%27s_lactate_solution
|
|
2442
|
+
"""
|
|
2443
|
+
# preset_dir = files("pyEQL") / "presets"
|
|
2444
|
+
# Path to the YAML and JSON files corresponding to the preset
|
|
2445
|
+
yaml_path = files("pyEQL") / "presets" / f"{preset}.yaml"
|
|
2446
|
+
json_path = files("pyEQL") / "presets" / f"{preset}.json"
|
|
2447
|
+
|
|
2448
|
+
# Check if the file exists
|
|
2449
|
+
if yaml_path.exists():
|
|
2450
|
+
preset_path = yaml_path
|
|
2451
|
+
elif json_path.exists():
|
|
2452
|
+
preset_path = json_path
|
|
2453
|
+
else:
|
|
2454
|
+
raise FileNotFoundError(f"Invalid preset! File '{yaml_path}' or '{json_path} not found!")
|
|
2455
|
+
|
|
2456
|
+
# Create and return a Solution object
|
|
2457
|
+
return cls().from_file(preset_path)
|
|
2458
|
+
|
|
2459
|
+
def to_file(self, filename: str | Path) -> None:
|
|
2460
|
+
"""Saving to a .yaml or .json file.
|
|
2461
|
+
|
|
2462
|
+
Args:
|
|
2463
|
+
filename (str | Path): The path to the file to save Solution.
|
|
2464
|
+
Valid extensions are .json or .yaml.
|
|
2465
|
+
"""
|
|
2466
|
+
str_filename = str(filename)
|
|
2467
|
+
if not ("yaml" in str_filename.lower() or "json" in str_filename.lower()):
|
|
2468
|
+
self.logger.error("Invalid file extension entered - %s" % str_filename)
|
|
2469
|
+
raise ValueError("File extension must be .json or .yaml")
|
|
2470
|
+
if "yaml" in str_filename.lower():
|
|
2471
|
+
solution_dict = self.as_dict()
|
|
2472
|
+
solution_dict.pop("database")
|
|
2473
|
+
dumpfn(solution_dict, filename)
|
|
2474
|
+
else:
|
|
2475
|
+
dumpfn(self, filename)
|
|
2476
|
+
|
|
2477
|
+
@classmethod
|
|
2478
|
+
def from_file(self, filename: str | Path) -> Solution:
|
|
2479
|
+
"""Loading from a .yaml or .json file.
|
|
2480
|
+
|
|
2481
|
+
Args:
|
|
2482
|
+
filename (str | Path): Path to the .json or .yaml file (including extension) to load the Solution from.
|
|
2483
|
+
Valid extensions are .json or .yaml.
|
|
2484
|
+
|
|
2485
|
+
Returns:
|
|
2486
|
+
A pyEQL Solution object.
|
|
2487
|
+
|
|
2488
|
+
Raises:
|
|
2489
|
+
FileNotFoundError: If the given filename doesn't exist on the file system.
|
|
2490
|
+
"""
|
|
2491
|
+
if not os.path.exists(filename):
|
|
2492
|
+
raise FileNotFoundError(f"File '{filename}' not found!")
|
|
2493
|
+
str_filename = str(filename)
|
|
2494
|
+
if "yaml" in str_filename.lower():
|
|
2495
|
+
true_keys = [
|
|
2496
|
+
"solutes",
|
|
2497
|
+
"volume",
|
|
2498
|
+
"temperature",
|
|
2499
|
+
"pressure",
|
|
2500
|
+
"pH",
|
|
2501
|
+
"pE",
|
|
2502
|
+
"balance_charge",
|
|
2503
|
+
"solvent",
|
|
2504
|
+
"engine",
|
|
2505
|
+
# "database",
|
|
2506
|
+
]
|
|
2507
|
+
solution_dict = loadfn(filename)
|
|
2508
|
+
keys_to_delete = [key for key in solution_dict if key not in true_keys]
|
|
2509
|
+
for key in keys_to_delete:
|
|
2510
|
+
solution_dict.pop(key)
|
|
2511
|
+
return Solution(**solution_dict)
|
|
2512
|
+
return loadfn(filename)
|
|
2513
|
+
|
|
2514
|
+
# arithmetic operations
|
|
2515
|
+
def __add__(self, other: Solution) -> Solution:
|
|
2516
|
+
"""
|
|
2517
|
+
Solution addition: mix two solutions together.
|
|
2518
|
+
|
|
2519
|
+
Args:
|
|
2520
|
+
other: The Solutions to be mixed with this solution.
|
|
2521
|
+
|
|
2522
|
+
Returns:
|
|
2523
|
+
A Solution object that represents the result of mixing this solution and other.
|
|
2524
|
+
|
|
2525
|
+
Notes:
|
|
2526
|
+
The initial volume of the mixed solution is set as the sum of the volumes of this solution and other.
|
|
2527
|
+
The pressure and temperature are volume-weighted averages. The pH and pE values are currently APPROXIMATE
|
|
2528
|
+
because they are calculated assuming H+ and e- mix conservatively (i.e., the mixing process does not
|
|
2529
|
+
incorporate any equilibration reactions or buffering). Such support is planned in a future release.
|
|
2530
|
+
"""
|
|
2531
|
+
# check to see if the two solutions have the same solvent
|
|
2532
|
+
if self.solvent != other.solvent:
|
|
2533
|
+
raise ValueError("Cannot add Solution with different solvents!")
|
|
2534
|
+
|
|
2535
|
+
if self._engine != other._engine:
|
|
2536
|
+
raise ValueError("Cannot add Solution with different engines!")
|
|
2537
|
+
|
|
2538
|
+
if self.database != other.database:
|
|
2539
|
+
raise ValueError("Cannot add Solution with different databases!")
|
|
2540
|
+
|
|
2541
|
+
# set the pressure for the new solution
|
|
2542
|
+
p1 = self.pressure
|
|
2543
|
+
t1 = self.temperature
|
|
2544
|
+
v1 = self.volume
|
|
2545
|
+
p2 = other.pressure
|
|
2546
|
+
t2 = other.temperature
|
|
2547
|
+
v2 = other.volume
|
|
2548
|
+
|
|
2549
|
+
# set the initial volume as the sum of the volumes
|
|
2550
|
+
mix_vol = v1 + v2
|
|
2551
|
+
|
|
2552
|
+
# check to see if the solutions have the same temperature and pressure
|
|
2553
|
+
if p1 != p2:
|
|
2554
|
+
self.logger.info(
|
|
2555
|
+
"Adding two solutions of different pressure. Pressures will be averaged (weighted by volume)"
|
|
2512
2556
|
)
|
|
2513
2557
|
|
|
2514
|
-
|
|
2558
|
+
mix_pressure = (p1 * v1 + p2 * v2) / (mix_vol)
|
|
2515
2559
|
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2560
|
+
if t1 != t2:
|
|
2561
|
+
self.logger.info(
|
|
2562
|
+
"Adding two solutions of different temperature. Temperatures will be averaged (weighted by volume)"
|
|
2519
2563
|
)
|
|
2520
2564
|
|
|
2521
|
-
#
|
|
2522
|
-
|
|
2523
|
-
|
|
2565
|
+
# do all temperature conversions in Kelvin to avoid ambiguity associated with "offset units". See pint docs.
|
|
2566
|
+
mix_temperature = (t1.to("K") * v1 + t2.to("K") * v2) / (mix_vol)
|
|
2567
|
+
|
|
2568
|
+
# retrieve the amount of each component in the parent solution and
|
|
2569
|
+
# store in a list.
|
|
2570
|
+
mix_species = FormulaDict({})
|
|
2571
|
+
for sol, amt in self.components.items():
|
|
2572
|
+
mix_species.update({sol: f"{amt} mol"})
|
|
2573
|
+
for sol2, amt2 in other.components.items():
|
|
2574
|
+
if mix_species.get(sol2):
|
|
2575
|
+
orig_amt = float(mix_species[sol2].split(" ")[0])
|
|
2576
|
+
mix_species[sol2] = f"{orig_amt+amt2} mol"
|
|
2577
|
+
else:
|
|
2578
|
+
mix_species.update({sol2: f"{amt2} mol"})
|
|
2524
2579
|
|
|
2525
|
-
|
|
2580
|
+
# TODO - call equilibrate() here once the method is functional to get new pH and pE, instead of the below
|
|
2581
|
+
warnings.warn(
|
|
2582
|
+
"The pH and pE value of the mixed solution is approximate! More accurate addition (mixing) of"
|
|
2583
|
+
"this property is planned for a future release."
|
|
2584
|
+
)
|
|
2585
|
+
# calculate the new pH and pE (before reactions) by mixing
|
|
2586
|
+
mix_pH = -np.log10(float(mix_species["H+"].split(" ")[0]) / mix_vol.to("L").magnitude)
|
|
2526
2587
|
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2588
|
+
# pE = -log[e-], so calculate the moles of e- in each solution and mix them
|
|
2589
|
+
mol_e_self = 10 ** (-1 * self.pE) * self.volume.to("L").magnitude
|
|
2590
|
+
mol_e_other = 10 ** (-1 * other.pE) * other.volume.to("L").magnitude
|
|
2591
|
+
mix_pE = -np.log10((mol_e_self + mol_e_other) / mix_vol.to("L").magnitude)
|
|
2530
2592
|
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2593
|
+
# create a new solution
|
|
2594
|
+
return Solution(
|
|
2595
|
+
mix_species.data, # pass a regular dict instead of the FormulaDict
|
|
2596
|
+
volume=str(mix_vol),
|
|
2597
|
+
pressure=str(mix_pressure),
|
|
2598
|
+
temperature=str(mix_temperature.to("K")),
|
|
2599
|
+
pH=mix_pH,
|
|
2600
|
+
pE=mix_pE,
|
|
2601
|
+
)
|
|
2534
2602
|
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
solute.get_parameter("partial_molar_volume") * solute.get_moles()
|
|
2538
|
-
)
|
|
2539
|
-
logger.info(
|
|
2540
|
-
"Updated solution volume using direct partial molar volume for solute %s"
|
|
2541
|
-
% item
|
|
2542
|
-
)
|
|
2603
|
+
def __sub__(self, other: Solution) -> None:
|
|
2604
|
+
raise NotImplementedError("Subtraction of solutions is not implemented.")
|
|
2543
2605
|
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2606
|
+
def __mul__(self, factor: float) -> None:
|
|
2607
|
+
"""
|
|
2608
|
+
Solution multiplication: scale all components by a factor. For example, Solution * 2 will double the moles of
|
|
2609
|
+
every component (including solvent). No other properties will change.
|
|
2610
|
+
"""
|
|
2611
|
+
self.volume *= factor
|
|
2612
|
+
return self
|
|
2549
2613
|
|
|
2550
|
-
|
|
2614
|
+
def __truediv__(self, factor: float) -> None:
|
|
2615
|
+
"""
|
|
2616
|
+
Solution division: scale all components by a factor. For example, Solution / 2 will remove half of the moles
|
|
2617
|
+
of every compoonents (including solvent). No other properties will change.
|
|
2618
|
+
"""
|
|
2619
|
+
self.volume /= factor
|
|
2620
|
+
return self
|
|
2551
2621
|
|
|
2552
|
-
|
|
2553
|
-
"""Return a copy of the solution
|
|
2622
|
+
# informational methods
|
|
2554
2623
|
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2624
|
+
def print(
|
|
2625
|
+
self,
|
|
2626
|
+
mode: Literal["all", "ions", "cations", "anions", "neutrals"] = "all",
|
|
2627
|
+
units: Literal["ppm", "mol", "mol/kg", "mol/L", "%", "activity"] = "mol",
|
|
2628
|
+
places=4,
|
|
2629
|
+
):
|
|
2630
|
+
"""
|
|
2631
|
+
Print details about the Solution.
|
|
2632
|
+
|
|
2633
|
+
Args:
|
|
2634
|
+
mode: Whether to list the amounts of all solutes, or only anions, cations, any ion, or any neutral solute.
|
|
2635
|
+
units: The units to list solute amounts in. "activity" will list dimensionless activities instead of
|
|
2636
|
+
concentrations.
|
|
2637
|
+
places: The number of decimal places to round the solute amounts.
|
|
2638
|
+
"""
|
|
2639
|
+
print(self)
|
|
2640
|
+
str1 = "Activities" if units == "activity" else "Amounts"
|
|
2641
|
+
str2 = f" ({units})" if units != "activity" else ""
|
|
2642
|
+
header = f"\nComponent {str1}{str2}:"
|
|
2643
|
+
print(header)
|
|
2644
|
+
print("=" * (len(header) - 1))
|
|
2645
|
+
for i in self.components:
|
|
2646
|
+
if mode != "all":
|
|
2647
|
+
z = self.get_property(i, "charge")
|
|
2648
|
+
if (
|
|
2649
|
+
(z != 0 and mode == "neutrals")
|
|
2650
|
+
or (z >= 0 and mode == "anions")
|
|
2651
|
+
or (z <= 0 and mode == "cations")
|
|
2652
|
+
or (z == 0 and mode == "ions")
|
|
2653
|
+
):
|
|
2654
|
+
continue
|
|
2562
2655
|
|
|
2563
|
-
|
|
2564
|
-
new_solutes = []
|
|
2565
|
-
for item in self.components:
|
|
2566
|
-
# ignore the solvent
|
|
2567
|
-
if item == self.solvent_name:
|
|
2568
|
-
pass
|
|
2569
|
-
else:
|
|
2570
|
-
new_solutes.append([item, str(self.get_amount(item, "mol"))])
|
|
2656
|
+
amt = self.get_activity(i).magnitude if units == "activity" else self.get_amount(i, units).magnitude
|
|
2571
2657
|
|
|
2572
|
-
|
|
2573
|
-
return Solution(
|
|
2574
|
-
new_solutes,
|
|
2575
|
-
solvent=[new_solvent, new_solvent_mass],
|
|
2576
|
-
temperature=new_temperature,
|
|
2577
|
-
pressure=new_pressure,
|
|
2578
|
-
)
|
|
2658
|
+
print(f"{i}:\t {amt:0.{places}f}")
|
|
2579
2659
|
|
|
2580
|
-
|
|
2660
|
+
def __str__(self) -> str:
|
|
2661
|
+
# set output of the print() statement for the solution
|
|
2662
|
+
l1 = f"Volume: {self.volume:.3f~}"
|
|
2663
|
+
l2 = f"Temperature: {self.temperature:.3f~}"
|
|
2664
|
+
l3 = f"Pressure: {self.pressure:.3f~}"
|
|
2665
|
+
l4 = f"pH: {self.pH:.1f}"
|
|
2666
|
+
l5 = f"pE: {self.pE:.1f}"
|
|
2667
|
+
l6 = f"Solvent: {self.solvent}"
|
|
2668
|
+
l7 = f"Components: {self.list_solutes():}"
|
|
2669
|
+
return f"{l1}\n{l2}\n{l3}\n{l4}\n{l5}\n{l6}\n{l7}"
|
|
2581
2670
|
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2671
|
+
"""
|
|
2672
|
+
Legacy methods to be deprecated in a future release.
|
|
2673
|
+
"""
|
|
2585
2674
|
|
|
2586
|
-
|
|
2675
|
+
@deprecated(
|
|
2676
|
+
message="list_salts() is deprecated and will be removed in the next release! Use Solution.get_salt_dict() instead.)"
|
|
2677
|
+
)
|
|
2678
|
+
def list_salts(self, unit="mol/kg", decimals=4): # pragma: no cover
|
|
2679
|
+
for k, v in self.get_salt_dict().items():
|
|
2680
|
+
print(k + "\t {:0.{decimals}f}".format(v, decimals=decimals))
|
|
2681
|
+
|
|
2682
|
+
@deprecated(
|
|
2683
|
+
message="list_solutes() is deprecated and will be removed in the next release! Use Solution.components.keys() instead.)"
|
|
2684
|
+
)
|
|
2685
|
+
def list_solutes(self): # pragma: no cover
|
|
2686
|
+
"""List all the solutes in the solution."""
|
|
2587
2687
|
return list(self.components.keys())
|
|
2588
2688
|
|
|
2589
|
-
|
|
2689
|
+
@deprecated(
|
|
2690
|
+
message="list_concentrations() is deprecated and will be removed in the next release! Use Solution.print() instead.)"
|
|
2691
|
+
)
|
|
2692
|
+
def list_concentrations(self, unit="mol/kg", decimals=4, type="all"): # pragma: no cover
|
|
2590
2693
|
"""
|
|
2591
2694
|
List the concentration of each species in a solution.
|
|
2592
2695
|
|
|
2593
2696
|
Parameters
|
|
2594
2697
|
----------
|
|
2595
2698
|
unit: str
|
|
2596
|
-
String representing the desired concentration
|
|
2699
|
+
String representing the desired concentration ureg.
|
|
2597
2700
|
decimals: int
|
|
2598
2701
|
The number of decimal places to display. Defaults to 4.
|
|
2599
2702
|
type : str
|
|
@@ -2601,11 +2704,11 @@ class Solution:
|
|
|
2601
2704
|
solutes. Other valid arguments are 'cations' and 'anions' which
|
|
2602
2705
|
return lists of cations and anions, respectively.
|
|
2603
2706
|
|
|
2604
|
-
Returns
|
|
2707
|
+
Returns:
|
|
2605
2708
|
-------
|
|
2606
2709
|
dict
|
|
2607
2710
|
Dictionary containing a list of the species in solution paired with their amount in the specified units
|
|
2608
|
-
|
|
2711
|
+
:meta private:
|
|
2609
2712
|
"""
|
|
2610
2713
|
result_list = []
|
|
2611
2714
|
# populate a list with component names
|
|
@@ -2616,47 +2719,30 @@ class Solution:
|
|
|
2616
2719
|
for item in self.components:
|
|
2617
2720
|
amount = self.get_amount(item, unit)
|
|
2618
2721
|
result_list.append([item, amount])
|
|
2619
|
-
print(
|
|
2620
|
-
item
|
|
2621
|
-
+ ":"
|
|
2622
|
-
+ "\t {0:0.{decimals}f~}".format(amount, decimals=decimals)
|
|
2623
|
-
)
|
|
2722
|
+
print(item + ":" + "\t {0:0.{decimals}f~}".format(amount, decimals=decimals))
|
|
2624
2723
|
elif type == "cations":
|
|
2625
2724
|
print("Cation Concentrations:\n")
|
|
2626
2725
|
print("========================\n")
|
|
2627
2726
|
for item in self.components:
|
|
2628
|
-
if self.components[item].
|
|
2727
|
+
if self.components[item].charge > 0:
|
|
2629
2728
|
amount = self.get_amount(item, unit)
|
|
2630
2729
|
result_list.append([item, amount])
|
|
2631
|
-
print(
|
|
2632
|
-
item
|
|
2633
|
-
+ ":"
|
|
2634
|
-
+ "\t {0:0.{decimals}f~}".format(amount, decimals=decimals)
|
|
2635
|
-
)
|
|
2730
|
+
print(item + ":" + "\t {0:0.{decimals}f~}".format(amount, decimals=decimals))
|
|
2636
2731
|
elif type == "anions":
|
|
2637
2732
|
print("Anion Concentrations:\n")
|
|
2638
2733
|
print("========================\n")
|
|
2639
2734
|
for item in self.components:
|
|
2640
|
-
if self.components[item].
|
|
2735
|
+
if self.components[item].charge < 0:
|
|
2641
2736
|
amount = self.get_amount(item, unit)
|
|
2642
2737
|
result_list.append([item, amount])
|
|
2643
|
-
print(
|
|
2644
|
-
item
|
|
2645
|
-
+ ":"
|
|
2646
|
-
+ "\t {0:0.{decimals}f~}".format(amount, decimals=decimals)
|
|
2647
|
-
)
|
|
2738
|
+
print(item + ":" + "\t {0:0.{decimals}f~}".format(amount, decimals=decimals))
|
|
2648
2739
|
|
|
2649
2740
|
return result_list
|
|
2650
2741
|
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
item.formula
|
|
2656
|
-
+ "\t {:0.{decimals}f}".format(list[item], decimals=decimals)
|
|
2657
|
-
)
|
|
2658
|
-
|
|
2659
|
-
def list_activities(self, decimals=4):
|
|
2742
|
+
@deprecated(
|
|
2743
|
+
message="list_activities() is deprecated and will be removed in the next release! Use Solution.print() instead.)"
|
|
2744
|
+
)
|
|
2745
|
+
def list_activities(self, decimals=4): # pragma: no cover
|
|
2660
2746
|
"""
|
|
2661
2747
|
List the activity of each species in a solution.
|
|
2662
2748
|
|
|
@@ -2665,27 +2751,14 @@ class Solution:
|
|
|
2665
2751
|
decimals: int
|
|
2666
2752
|
The number of decimal places to display. Defaults to 4.
|
|
2667
2753
|
|
|
2668
|
-
Returns
|
|
2754
|
+
Returns:
|
|
2669
2755
|
-------
|
|
2670
2756
|
dict
|
|
2671
2757
|
Dictionary containing a list of the species in solution paired with their activity
|
|
2672
2758
|
|
|
2759
|
+
:meta private:
|
|
2673
2760
|
"""
|
|
2674
2761
|
print("Component Activities:\n")
|
|
2675
2762
|
print("=====================\n")
|
|
2676
|
-
for i in self.components
|
|
2677
|
-
print(
|
|
2678
|
-
i
|
|
2679
|
-
+ ":"
|
|
2680
|
-
+ "\t {0.magnitude:0.{decimals}f}".format(
|
|
2681
|
-
self.get_activity(i), decimals=decimals
|
|
2682
|
-
)
|
|
2683
|
-
)
|
|
2684
|
-
|
|
2685
|
-
def __str__(self):
|
|
2686
|
-
# set output of the print() statement for the solution
|
|
2687
|
-
str1 = "Volume: {0:.3f~}\n".format(self.get_volume())
|
|
2688
|
-
str2 = "Pressure: {0:.3f~}\n".format(self.get_pressure())
|
|
2689
|
-
str3 = "Temperature: {0:.3f~}\n".format(self.get_temperature())
|
|
2690
|
-
str4 = "Components: {0:}\n".format(self.list_solutes())
|
|
2691
|
-
return str1 + str2 + str3 + str4
|
|
2763
|
+
for i in self.components:
|
|
2764
|
+
print(i + ":" + "\t {0.magnitude:0.{decimals}f}".format(self.get_activity(i), decimals=decimals))
|