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.
Files changed (62) hide show
  1. pyEQL/__init__.py +50 -43
  2. pyEQL/activity_correction.py +481 -707
  3. pyEQL/database/geothermal.dat +5693 -0
  4. pyEQL/database/llnl.dat +19305 -0
  5. pyEQL/database/phreeqc_license.txt +54 -0
  6. pyEQL/database/pyeql_db.json +35902 -0
  7. pyEQL/engines.py +793 -0
  8. pyEQL/equilibrium.py +148 -228
  9. pyEQL/functions.py +121 -416
  10. pyEQL/pint_custom_units.txt +2 -2
  11. pyEQL/presets/Ringers lactate.yaml +20 -0
  12. pyEQL/presets/normal saline.yaml +17 -0
  13. pyEQL/presets/rainwater.yaml +17 -0
  14. pyEQL/presets/seawater.yaml +29 -0
  15. pyEQL/presets/urine.yaml +26 -0
  16. pyEQL/presets/wastewater.yaml +21 -0
  17. pyEQL/salt_ion_match.py +53 -284
  18. pyEQL/solute.py +126 -191
  19. pyEQL/solution.py +2163 -2090
  20. pyEQL/utils.py +211 -0
  21. pyEQL-1.0.3.dist-info/AUTHORS.md +13 -0
  22. {pyEQL-0.5.2.dist-info → pyEQL-1.0.3.dist-info}/COPYING +1 -1
  23. pyEQL-0.5.2.dist-info/LICENSE → pyEQL-1.0.3.dist-info/LICENSE.txt +3 -7
  24. pyEQL-1.0.3.dist-info/METADATA +131 -0
  25. pyEQL-1.0.3.dist-info/RECORD +27 -0
  26. {pyEQL-0.5.2.dist-info → pyEQL-1.0.3.dist-info}/WHEEL +1 -1
  27. pyEQL/chemical_formula.py +0 -1006
  28. pyEQL/database/Erying_viscosity.tsv +0 -18
  29. pyEQL/database/Jones_Dole_B.tsv +0 -32
  30. pyEQL/database/Jones_Dole_B_inorganic_Jenkins.tsv +0 -75
  31. pyEQL/database/LICENSE +0 -4
  32. pyEQL/database/dielectric_parameter.tsv +0 -30
  33. pyEQL/database/diffusion_coefficient.tsv +0 -116
  34. pyEQL/database/hydrated_radius.tsv +0 -35
  35. pyEQL/database/ionic_radius.tsv +0 -35
  36. pyEQL/database/partial_molar_volume.tsv +0 -22
  37. pyEQL/database/pitzer_activity.tsv +0 -169
  38. pyEQL/database/pitzer_volume.tsv +0 -132
  39. pyEQL/database/template.tsv +0 -14
  40. pyEQL/database.py +0 -300
  41. pyEQL/elements.py +0 -4552
  42. pyEQL/logging_system.py +0 -53
  43. pyEQL/parameter.py +0 -435
  44. pyEQL/tests/__init__.py +0 -32
  45. pyEQL/tests/test_activity.py +0 -578
  46. pyEQL/tests/test_bulk_properties.py +0 -86
  47. pyEQL/tests/test_chemical_formula.py +0 -279
  48. pyEQL/tests/test_debye_length.py +0 -79
  49. pyEQL/tests/test_density.py +0 -106
  50. pyEQL/tests/test_dielectric.py +0 -153
  51. pyEQL/tests/test_effective_pitzer.py +0 -276
  52. pyEQL/tests/test_mixed_electrolyte_activity.py +0 -154
  53. pyEQL/tests/test_osmotic_coeff.py +0 -99
  54. pyEQL/tests/test_pyeql_volume_concentration.py +0 -428
  55. pyEQL/tests/test_salt_matching.py +0 -337
  56. pyEQL/tests/test_solute_properties.py +0 -251
  57. pyEQL/water_properties.py +0 -352
  58. pyEQL-0.5.2.dist-info/AUTHORS +0 -7
  59. pyEQL-0.5.2.dist-info/METADATA +0 -72
  60. pyEQL-0.5.2.dist-info/RECORD +0 -47
  61. pyEQL-0.5.2.dist-info/entry_points.txt +0 -3
  62. {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-2020 by Ryan S. Kingsbury
4
+ :copyright: 2013-2024 by Ryan S. Kingsbury
5
5
  :license: LGPL, see LICENSE for more details.
6
6
 
7
7
  """
8
8
 
9
- # Dependencies
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
- logger = logging.getLogger(__name__)
33
-
34
- unique = Unique()
35
- logger.addFilter(unique)
36
-
37
- # add a handler for console output, since pyEQL is meant to be used interactively
38
- ch = logging.StreamHandler()
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
- # create formatter for the log
41
- formatter = logging.Formatter("(%(name)s) - %(levelname)s - %(message)s")
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
- # add formatter to the handler
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
- class Solution:
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
- volume : str, optional
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
- temperature : str, optional
63
- The solution temperature, including the unit. Defaults to '25 degC' if omitted.
64
- pressure : Quantity, optional
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
- pH : number, optional
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
- Returns
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 "volume" in kwargs:
96
- volume_set = True
97
- self.volume = unit(kwargs["volume"])
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.volume = unit("1 L")
101
-
102
- # set the temperature
103
- if "temperature" in kwargs:
104
- self.temperature = unit(kwargs["temperature"])
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.temperature = unit("25 degC")
107
-
108
- # set the pressure
109
- if "pressure" in kwargs:
110
- self.pressure = unit(kwargs["pressure"])
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
- self.pressure = unit("1 atm")
113
-
114
- # create an empty dictionary of components
115
- self.components = {}
116
-
117
- # initialize the volume recalculation flag
118
- self.volume_update_required = False
119
-
120
- # define the solvent
121
- if "solvent" in kwargs:
122
- self.solvent_name = kwargs["solvent"][0]
123
- # warn if the solvent is anything besides water
124
- if not kwargs["solvent"][0] == "H2O" or kwargs["solvent"][0] == "water":
125
- logger.error(
126
- "Non-aqueous solvent detected. These are not yet supported!"
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
- self.solvent_name = "H2O"
139
-
140
- # calculate the solvent (water) mass based on the density and the solution volume
141
- self.add_solvent(
142
- self.solvent_name,
143
- str(self.volume * h2o.water_density(self.temperature)),
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
- for item in solutes:
157
- self.add_solute(*item)
158
-
159
- def add_solute(self, formula, amount, parameters={}):
160
- """
161
- Primary method for adding substances to a pyEQL solution
162
-
163
- Parameters
164
- ----------
165
- formula : str
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.components.update({new_solute.get_name(): new_solute})
196
-
197
- # calculate the volume occupied by all the solutes
198
- solute_vol = self._get_solute_volume()
199
-
200
- # determine the volume of solvent that will preserve the original volume
201
- target_vol = orig_volume - solute_vol
202
-
203
- # adjust the amount of solvent
204
- target_mass = target_vol * h2o.water_density(self.get_temperature())
205
- mw = self.get_solvent().get_molecular_weight()
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.components.update({new_solute.get_name(): new_solute})
216
-
217
- # update the volume to account for the space occupied by all the solutes
218
- # make sure that there is still solvent present in the first place
219
- if self.get_solvent_mass() <= unit("0 kg"):
220
- logger.error("All solvent has been depleted from the solution")
221
- return None
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
- # set the volume recalculation flag
224
- self.volume_update_required = True
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
- def add_solvent(self, formula, amount):
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
- def get_solute(self, i):
288
+ @property
289
+ def mass(self) -> Quantity:
234
290
  """
235
- Return the specified solute object.
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
- def get_solvent(self):
241
- """
242
- Return the solvent object.
295
+ Returns: The mass of the solution, in kg
243
296
 
244
297
  """
245
- return self.components[self.solvent_name]
298
+ mass = np.sum([self.get_amount(item, "kg").magnitude for item in self.components])
299
+ return ureg.Quantity(mass, "kg")
246
300
 
247
- def get_temperature(self):
301
+ @property
302
+ def solvent_mass(self) -> Quantity:
248
303
  """
249
- Return the temperature of the solution.
304
+ Return the mass of the solvent.
250
305
 
251
- Parameters
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
- def set_temperature(self, temperature):
312
+ See Also:
313
+ :py:meth:`get_amount()`
262
314
  """
263
- Set the solution temperature.
315
+ return self.get_amount(self.solvent, "kg")
264
316
 
265
- Parameters
266
- ----------
267
- temperature : str
268
- String representing the temperature, e.g. '25 degC'
317
+ @property
318
+ def volume(self) -> Quantity:
269
319
  """
270
- self.temperature = unit(temperature)
271
-
272
- # recalculate the volume
273
- self._update_volume()
320
+ Return the volume of the solution.
274
321
 
275
- def get_pressure(self):
322
+ Returns:
323
+ Quantity: the volume of the solution, in L
276
324
  """
277
- Return the hydrostatic pressure of the solution.
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
- Returns
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
- def set_pressure(self, pressure):
286
- """
287
- Set the hydrostatic pressure of the solution.
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
- Parameters
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
- # recalculate the volume
297
- self._update_volume()
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
- Return the mass of the solvent.
353
+ # figure out the factor to multiply the old concentrations by
354
+ scale_factor = ureg.Quantity(volume) / self.volume
302
355
 
303
- This method is used whenever mol/kg (or similar) concentrations
304
- are requested by get_amount()
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
- Parameters
307
- ----------
308
- None
360
+ # update the solution volume
361
+ self._volume *= scale_factor.magnitude
309
362
 
310
- Returns
311
- -------
312
- Quantity: the mass of the solvent, in kg
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
- See Also
315
- --------
316
- get_amount
368
+ @temperature.setter
369
+ def temperature(self, temperature: str):
317
370
  """
318
- # return the total mass (kg) of the solvent
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
- def get_volume(self):
373
+ Args:
374
+ temperature: pint-compatible string, e.g. '25 degC'
325
375
  """
326
- Return the volume of the solution.
327
-
328
- Parameters
329
- ----------
330
- None
376
+ self._temperature = ureg.Quantity(temperature)
331
377
 
332
- Returns
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
- # if the composition has changed, recalculate the volume first
338
- if self.volume_update_required is True:
339
- self._update_volume()
340
- self.volume_update_required = False
381
+ # recalculate the volume
382
+ self.volume_update_required = True
341
383
 
342
- return self.volume.to("L")
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
- def set_volume(self, volume):
345
- """Change the total solution volume to volume, while preserving
346
- all component concentrations
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
- Parameters
349
- ----------
350
- volume : str quantity
351
- Total volume of the solution, including the unit, e.g. '1 L'
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
- # figure out the factor to multiply the old concentrations by
368
- scale_factor = unit(volume) / self.get_volume()
403
+ self._pressure = ureg.Quantity(pressure)
369
404
 
370
- # scale down the amount of all the solutes according to the factor
371
- for item in self.components:
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
- # update the solution volume
375
- self.volume = unit(volume)
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 get_mass(self):
416
+ def p(self, solute: str, activity=True) -> float | None:
378
417
  """
379
- Return the total mass of the solution.
418
+ Return the negative log of the activity of solute.
380
419
 
381
- The mass is calculated each time this method is called.
382
- Parameters
383
- ----------
384
- None
420
+ Generally used for expressing concentration of hydrogen ions (pH)
385
421
 
386
- Returns
387
- -------
388
- Quantity: the mass of the solution, in kg
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
- total_mass = 0
392
- for item in self.components:
393
- total_mass += self.get_amount(item, "kg")
394
- return total_mass.to("kg")
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
- def get_density(self):
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.get_mass() / self.get_volume()
456
+ return self.mass / self.volume
407
457
 
408
- def get_dielectric_constant(self):
409
- """
458
+ @property
459
+ def dielectric_constant(self) -> Quantity:
460
+ r"""
410
461
  Returns the dielectric constant of the solution.
411
462
 
412
- Parameters
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
- .. math:: \\epsilon = \\epsilon_solvent \\over 1 + \\sum_i \\alpha_i x_i
472
+ .. math:: \epsilon = \epsilon_{solvent} \over 1 + \sum_i \alpha_i x_i
425
473
 
426
- where :math:`\\alpha_i` is a coefficient specific to the solvent and ion, and :math:`x_i`
427
- is the mole fraction of the ion in solution.
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
- .. [#] [1] A. Zuber, L. Cardozo-Filho, V.F. Cabral, R.F. Checoni, M. Castier, \
433
- An empirical equation for the dielectric constant in aqueous and nonaqueous \
434
- electrolyte mixtures, Fluid Phase Equilib. 376 (2014) 116–123. \
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 = h2o.water_dielectric_constant(self.get_temperature())
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
- fraction = self.get_amount(item, "fraction")
446
- coefficient = self.get_solute(item).get_parameter(
447
- "dielectric_parameter_water"
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
- logger.warning(
452
- "No dielectric parameters found for species %s." % item
453
- )
454
- continue
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
- dielectric_constant = di_water / denominator
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
- return dielectric_constant
510
+ @property
511
+ def elements(self) -> list:
512
+ """
513
+ Return a list of elements that are present in the solution.
459
514
 
460
- def get_viscosity_relative(self):
515
+ For example, a solution containing CaCO3 would return ["C", "Ca", "H", "O"]
461
516
  """
462
- Return the viscosity of the solution relative to that of water
517
+ els = []
518
+ for s in self.components:
519
+ els.extend(self.get_property(s, "elements"))
520
+ return sorted(set(els))
463
521
 
464
- This is calculated using a simplified form of the Jones-Dole equation:
522
+ @property
523
+ def cations(self) -> dict[str, float]:
524
+ """
525
+ Returns the subset of `components` that are cations.
465
526
 
466
- .. math:: \\eta_{rel} = 1 + \\sum_i B_i m_i
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
- Where :math:`m` is the molal concentration and :math:`B` is an empirical parameter.
531
+ @property
532
+ def anions(self) -> dict[str, float]:
533
+ """
534
+ Returns the subset of `components` that are anions.
469
535
 
470
- See
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
- # if self.get_ionic_strength().magnitude > 0.2:
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
- # viscosity_rel = 1
479
- # for item in self.components:
480
- # # ignore water
481
- # if item != 'H2O':
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
- return viscosity_rel
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
- def get_viscosity_dynamic(self):
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
- get_viscosity_kinematic
505
- get_viscosity_relative
506
- """
507
- return self.get_viscosity_kinematic() * self.get_density()
508
-
509
- def get_viscosity_kinematic(self):
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
- The calculation is based on a model derived from the Eyring equation
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
- .. math::
592
+ .. math::
519
593
 
520
- \\ln \\nu = \\ln {\\nu_w MW_w \over \sum_i x_i MW_i } +
521
- 15 x_+^2 + x_+^3 \delta G^*_{123} + 3 x_+ \delta G^*_{23} (1-0.05x_+)
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
- Where:
597
+ Where:
524
598
 
525
- .. math:: \delta G^*_{123} = a_o + a_1 (T)^{0.75}
526
- .. math:: \delta G^*_{23} = b_o + b_1 (T)^{0.5}
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
- In which :math: `\\nu` is the kinematic viscosity, MW is the molecular weight,
529
- `x_+` is the mole fraction of cations, and T is the temperature in degrees C.
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
- The a and b fitting parameters for a variety of common salts are included in the
532
- database.
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
- .. [#] Vásquez-Castillo, G.; Iglesias-Silva, G. a.; Hall, K. R. An extension \
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
- if db.has_parameter(salt.formula, "erying_viscosity_coefficients"):
556
- params = db.get_parameter(salt.formula, "erying_viscosity_coefficients")
557
-
558
- a0 = params.get_value()[0]
559
- a1 = params.get_value()[1]
560
- b0 = params.get_value()[2]
561
- b1 = params.get_value()[3]
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
- "Viscosity coefficients for %s not found. Viscosity will be approximate."
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
- MW = self.get_mass() / (
583
- self.get_moles_solvent() + self.get_total_moles_solute()
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.get_solvent().get_molecular_weight()
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 math.exp(nu) * unit("m**2 / s")
655
+ return ureg.Quantity(np.exp(nu), "m**2 / s")
601
656
 
602
- def get_conductivity(self):
603
- """
657
+ @property
658
+ def conductivity(self) -> Quantity:
659
+ r"""
604
660
  Compute the electrical conductivity of the solution.
605
661
 
606
- Parameters
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
- Conductivity is calculated by summing the molar conductivities of the respective
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
- .. math::
669
+ .. math::
622
670
 
623
- EC = {F^2 \\over R T} \\sum_i D_i z_i ^ 2 \\gamma_i ^ {\\alpha} m_i
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
- Where:
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
- .. math::
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
- \\alpha = \\begin{cases} {0.6 \\over \\sqrt{|z_i|}} & {I < 0.36|z_i|} \\\ {\\sqrt{I} \\over |z_i|} &
630
- otherwise \\end{cases}
681
+ References:
682
+ .. [aq] https://www.aqion.de/site/electrical-conductivity
683
+ .. [hc] http://www.hydrochemistry.eu/exmpls/sc.html
631
684
 
632
- Note: PHREEQC uses the molal rather than molar concentration according to
633
- http://wwwbrr.cr.usgs.gov/projects/GWC_coupled/phreeqc/phreeqc3-html/phreeqc3-43.htm
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
- References
636
- ----------
637
- .. [#] http://www.aqion.de/site/77
638
- .. [#] http://www.hydrochemistry.eu/exmpls/sc.html
701
+ @property
702
+ def ionic_strength(self) -> Quantity:
703
+ r"""
704
+ Return the ionic strength of the solution.
639
705
 
640
- See Also
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
- for item in self.components:
651
- z = abs(self.get_solute(item).get_formal_charge())
652
- # ignore uncharged species
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
- diffusion_coefficient = self.get_property(item, "diffusion_coefficient")
714
+ See Also:
715
+ :py:meth:`get_activity`
716
+ :py:meth:`get_water_activity`
661
717
 
662
- molar_cond = (
663
- diffusion_coefficient
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
- EC += (
670
- molar_cond
671
- * self.get_activity_coefficient(item) ** alpha
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
- return EC.to("S/m")
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
- def get_osmotic_pressure(self):
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
- Return the osmotic pressure of the solution relative to pure water.
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
- Parameters
682
- ----------
683
- None
742
+ @property
743
+ def charge_balance(self) -> float:
744
+ r"""
745
+ Return the charge balance of the solution.
684
746
 
685
- Returns
686
- -------
687
- Quantity
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
- See Also
691
- --------
692
- get_water_activity
693
- get_osmotic_coefficient
694
- get_salt
751
+ .. math:: CB = \sum_i C_i z_i
695
752
 
696
- Notes
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
- .. math:: \\Pi = {RT \\over V_w} \ln a_w
755
+ Returns:
756
+ float :
757
+ The charge balance of the solution, in equivalents (mol of charge) per L.
701
758
 
702
- Where :math:`\\Pi` is the osmotic pressure, :math:`V_w` is the partial
703
- molar volume of water (18.2 cm**3/mol), and :math:`a_w` is the water
704
- activity.
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
- References
708
- ----------
709
- .. [#] Sata, Toshikatsu. Ion Exchange Membranes: Preparation, Characterization, and Modification. \
710
- Royal Society of Chemistry, 2004, p. 10.
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
- .. [#] http://en.wikipedia.org/wiki/Osmotic_pressure#Derivation_of_osmotic_pressure
778
+ .. math:: Alk = \sum_{i} z_{i} C_{B} + \sum_{i} z_{i} C_{A}
713
779
 
714
- Examples
715
- --------
716
- >>> s1=pyEQL.Solution()
717
- >>> s1.get_osmotic_pressure()
718
- 0.0
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
- # TODO - tie this into parameter() and solvent() objects
725
- partial_molar_volume_water = 1.82e-5 * unit("m ** 3/mol")
789
+ alkalinity = ureg.Quantity(0, "mol/L")
726
790
 
727
- osmotic_pressure = (
728
- -1
729
- * unit.R
730
- * self.get_temperature()
731
- / partial_molar_volume_water
732
- * math.log(self.get_water_activity())
733
- )
734
- logger.info(
735
- "Computed osmotic pressure of solution as %s Pa at T= %s degrees C"
736
- % (osmotic_pressure, self.get_temperature())
737
- )
738
- return osmotic_pressure.to("Pa")
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
- # Concentration Methods
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
- def p(self, solute, activity=True):
815
+ @property
816
+ def hardness(self) -> Quantity:
743
817
  """
744
- Return the negative log of the activity of solute.
818
+ Return the hardness of a solution.
745
819
 
746
- Generally used for expressing concentration of hydrogen ions (pH)
820
+ Hardness is defined as the sum of the equivalent concentrations
821
+ of multivalent cations as calcium carbonate.
747
822
 
748
- Parameters
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
- Quantity
759
- The negative log10 of the activity (or molar concentration if
760
- activity = False) of the solute.
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
- Examples
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
- try:
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
- def get_amount(self, solute, units):
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
- Return the amount of 'solute' in the parent solution.
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
- The amount of a solute can be given in a variety of unit types.
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
- Parameters
789
- ----------
790
- solute : str
791
- String representing the name of the solute of interest
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
- See Also
804
- --------
805
- add_amount
806
- set_amount
807
- get_total_amount
808
- get_osmolarity
809
- get_osmolality
810
- get_solvent_mass
811
- get_mass
812
- get_total_moles_solute
813
- """
814
- # retrieve the number of moles of solute and its molecular weight
815
- try:
816
- moles = self.get_solute(solute).get_moles()
817
- mw = self.get_solute(solute).get_molecular_weight()
818
- # if the solute is not present in the solution, we'll get a KeyError
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 * unit(units)
823
- except UndefinedUnitError:
824
- logger.error("Unsupported unit specified for get_amount")
825
- return 0
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 unecessary
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
- elif units == "%":
834
- return moles.to("kg", "chem", mw=mw) / self.get_mass().to("kg") * 100
835
- elif unit(units).dimensionality in (
836
- "[substance]/[length]**3",
837
- "[mass]/[length]**3",
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(units, "chem", mw=mw, volume=self.get_volume())
840
- elif unit(units).dimensionality in ("[substance]/[mass]", "[mass]/[mass]"):
841
- return moles.to(units, "chem", mw=mw, solvent_mass=self.get_solvent_mass())
842
- elif unit(units).dimensionality == "[mass]":
843
- return moles.to(units, "chem", mw=mw)
844
- elif unit(units).dimensionality == "[substance]":
845
- return moles.to(units)
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 get_total_amount(self, element, units):
1076
+ def get_components_by_element(self) -> dict[str, list]:
851
1077
  """
852
- Return the total amount of 'element' (across all solutes) in the solution.
1078
+ Return a list of all species associated with a given element.
853
1079
 
854
- Parameters
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
- Returns
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
- Notes
867
- -----
868
- There is currently no way to distinguish between different oxidation
869
- states of the same element (e.g. TOTFe(II) vs. TOTFe(III)). This
870
- is planned for a future release. (TODO)
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
- See Also
873
- --------
874
- get_amount
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
- import pyEQL.chemical_formula as ch
1111
+ Return a dict of Element: amount in mol.
877
1112
 
878
- TOT = 0 * unit(units)
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
- # loop through all the solutes, process each one containing element
881
- for item in self.components:
882
- # check whether the solute contains the element
883
- if ch.contains(item, element):
884
- # start with the amount of the solute in the desired units
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 unit(units).dimensionality in (
1186
+ if ureg.Quantity(units).dimensionality in (
890
1187
  "[substance]",
891
1188
  "[substance]/[length]**3",
892
1189
  "[substance]/[mass]",
893
1190
  ):
894
- TOT += amt * ch.get_element_mole_ratio(item, element)
1191
+ TOT += amt * ion.get_el_amt_dict()[el] # returns {el: mol per formula unit}
895
1192
 
896
- elif unit(units).dimensionality in (
1193
+ elif ureg.Quantity(units).dimensionality in (
897
1194
  "[mass]",
898
1195
  "[mass]/[length]**3",
899
1196
  "[mass]/[mass]",
900
1197
  ):
901
- TOT += amt * ch.get_element_weight_fraction(item, element)
1198
+ TOT += amt * ion.to_weight_dict[el] # returns {el: wt fraction}
902
1199
 
903
1200
  return TOT
904
1201
 
905
- def add_amount(self, solute, amount):
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
- Add the amount of 'solute' to the parent solution.
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
- Parameters
910
- ----------
911
- solute : str
912
- String representing the name of the solute of interest
913
- amount : str quantity
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
- See Also
927
- --------
928
- Solute.add_moles
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 unit(amount).dimensionality in (
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.get_volume()
1293
+ orig_volume = self.volume
941
1294
 
942
1295
  # change the amount of the solute present to match the desired amount
943
- self.get_solute(solute).add_moles(
944
- amount, self.get_volume(), self.get_solvent_mass()
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.warning(
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
- target_mass = target_vol * h2o.water_density(self.get_temperature())
964
- mw = self.get_solvent().get_molecular_weight()
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.get_solvent().moles = target_mol
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.get_solute(solute).add_moles(
972
- amount, self.get_volume(), self.get_solvent_mass()
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.warning(
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.get_solvent_mass() <= unit("0 kg"):
987
- logger.error("All solvent has been depleted from the solution")
988
- return None
989
- else:
990
- # set the volume recalculation flag
991
- self.volume_update_required = True
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
- Parameters
998
- ----------
999
- solute : str
1000
- String representing the name of the solute of interest
1001
- amount : str Quantity
1002
- String representing the concentration desired, e.g. '1 mol/kg'
1003
- If the units are given on a per-volume basis, the solution
1004
- volume is not recalculated and the molar concentrations of
1005
- other components in the solution are not altered, while the
1006
- molal concentrations are modified.
1007
-
1008
- If the units are given on a mass, substance, per-mass, or
1009
- per-substance basis, then the solution volume is recalculated
1010
- based on the new composition and the molal concentrations of
1011
- other components are not altered, while the molar concentrations
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 unit(amount).magnitude < 0:
1025
- logger.error(
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
- elif unit(amount).dimensionality in (
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.get_volume()
1397
+ orig_volume = self.volume
1040
1398
 
1041
1399
  # change the amount of the solute present to match the desired amount
1042
- self.get_solute(solute).set_moles(
1043
- amount, self.get_volume(), self.get_solvent_mass()
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 * h2o.water_density(self.get_temperature())
1054
- mw = self.get_solvent().get_molecular_weight()
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.get_solvent().moles = target_mol
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.get_solute(solute).set_moles(
1062
- amount, self.get_volume(), self.get_solvent_mass()
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.get_solvent_mass() <= unit("0 kg"):
1068
- logger.error("All solvent has been depleted from the solution")
1069
- return None
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
- def get_osmolarity(self, activity_correction=False):
1074
- """Return the osmolarity of the solution in Osm/L
1444
+ self._update_volume()
1075
1445
 
1076
- Parameters
1077
- ----------
1078
- activity_correction : bool
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
- factor = self.get_osmotic_coefficient()
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
- Parameters
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
- factor = self.get_osmotic_coefficient()
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 direclty model
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., 1 M MgCl2 yields 1 M Mg+2 and 2 M Cl-).
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
- Parameters
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
- get_activity
1173
- get_activity_coefficient
1174
- get_water_activity
1175
- get_osmotic_coefficient
1176
- get_osmotic_pressure
1177
- get_viscosity_kinematic
1178
-
1179
- Examples
1180
- --------
1181
- >>> s1 = Solution([['Na+','0.5 mol/kg'],['Cl-','0.5 mol/kg']])
1182
- >>> s1.get_salt()
1183
- <pyEQL.salt_ion_match.Salt object at 0x7fe6d3542048>
1184
- >>> s1.get_salt().formula
1185
- 'NaCl'
1186
- >>> s1.get_salt().nu_cation
1187
- 1
1188
- >>> s1.get_salt().z_anion
1189
- -1
1190
-
1191
- >>> s2 = pyEQL.Solution([['Na+','0.1 mol/kg'],['Mg+2','0.2 mol/kg'],['Cl-','0.5 mol/kg']])
1192
- >>> s2.get_salt().formula
1193
- 'MgCl2'
1194
- >>> s2.get_salt().nu_anion
1195
- 2
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
- Determine the predominant salt in a solution of ions.
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 direclty model
1562
+ containing Na+, Cl-, and Mg+2), it is generally not possible to directly model
1212
1563
  these quantities.
1213
1564
 
1214
- The get_salt_list() method examines the ionic composition of a solution and
1215
- simplifies it into a list of salts. The method retuns a dictionary of
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
- Parameters
1222
- ----------
1223
- None
1224
-
1225
- Returns
1226
- -------
1227
- dict
1228
- A dictionary of Salt objects, keyed to the salt formula
1229
-
1230
- See Also
1231
- --------
1232
- get_activity
1233
- get_activity_coefficient
1234
- get_water_activity
1235
- get_osmotic_coefficient
1236
- get_osmotic_pressure
1237
- get_viscosity_kinematic
1238
-
1239
- """
1240
- # identify the predominant salt in the solution
1241
- import pyEQL.salt_ion_match as salt
1242
-
1243
- return salt.generate_salt_list(self, unit="mol/kg")
1244
-
1245
- # Activity-related methods
1246
- def get_activity_coefficient(self, solute, scale="molal", verbose=False):
1247
- """Return the activity coefficient of a solute in solution.
1248
-
1249
- Whenever the appropriate parameters are available, the Pitzer model [#]_ is used.
1250
- If no Pitzer parameters are available, then the appropriate equations are selected
1251
- according to the following logic: [#]_
1252
-
1253
- I <= 0.0005: Debye-Huckel equation
1254
- 0.005 < I <= 0.1: Guntelberg approximation
1255
- 0.1 < I <= 0.5: Davies equation
1256
- I > 0.5: Raises a warning and returns activity coefficient = 1
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
- # identify the predominant salt that this ion is a member of
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 get_water_activity(self):
1706
+ def equilibrate(self, **kwargs) -> None:
1723
1707
  """
1724
- Return the water activity.
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
- .. math:: \ln a_w = - \\Phi M_w \\sum_i m_i
1710
+ Any kwargs specified are passed through to self.engine.equilibrate()
1742
1711
 
1743
- Where :math:`M_w` is the molar mass of water (0.018015 kg/mol) and :math:`m_i` is the molal concentration
1744
- of each species.
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
- pseudo code
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
- osmotic_coefficient = self.get_osmotic_coefficient()
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
- logger.info("Calculated water activity using osmotic coefficient")
1726
+ The model used to calculate the activity coefficient is determined by the Solution's equation of state
1727
+ engine.
1786
1728
 
1787
- return math.exp(
1788
- -osmotic_coefficient * 0.018015 * unit("kg/mol") * concentration_sum
1789
- ) * unit("1 dimensionless")
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
- def get_ionic_strength(self):
1736
+ Returns:
1737
+ Quantity: the activity coefficient as a dimensionless pint Quantity
1792
1738
  """
1793
- Return the ionic strength of the solution.
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
- Return the ionic strength of the solution, calculated as 1/2 * sum ( molality * charge ^2) over all the ions.
1796
- Molal (mol/kg) scale concentrations are used for compatibility with the activity correction formulas.
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
- Returns
1799
- -------
1800
- Quantity :
1801
- The ionic strength of the parent solution, mol/kg.
1802
-
1803
- See Also
1804
- --------
1805
- get_activity
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
- return self.ionic_strength
1762
+ raise ValueError("Invalid scale argument. Pass 'molal', 'molar', or 'rational'.")
1835
1763
 
1836
- def get_charge_balance(self):
1764
+ def get_activity(
1765
+ self,
1766
+ solute: str,
1767
+ scale: Literal["molal", "molar", "rational"] = "molal",
1768
+ ) -> Quantity:
1837
1769
  """
1838
- Return the charge balance of the solution.
1770
+ Return the thermodynamic activity of the solute in solution on the chosen concentration scale.
1839
1771
 
1840
- Return the charge balance of the solution. The charge balance represents the net electric charge
1841
- on the solution and SHOULD equal zero at all times, but due to numerical errors will usually
1842
- have a small nonzero value.
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
- The charge balance is calculated according to:
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
- .. math:: CB = F \sum_i n_i z_i
1792
+ References:
1793
+ .. [rs] Robinson, R. A.; Stokes, R. H. Electrolyte Solutions: Second Revised
1794
+ Edition; Butterworths: London, 1968, p.32.
1854
1795
 
1855
- Where :math:`n_i` is the number of moles, :math:`z_i` is the charge on species i, and :math:`F` is the Faraday
1856
- constant.
1796
+ See Also:
1797
+ :attr:`ionic_strength`
1798
+ :py:meth:`get_activity_coefficient`
1799
+ :py:meth:`get_salt`
1857
1800
 
1858
1801
  """
1859
- self.charge_balance = 0
1860
- for solute in self.components.keys():
1861
- self.charge_balance += (
1862
- self.get_amount(solute, "mol")
1863
- * self.components[solute].get_formal_charge()
1864
- * unit.e
1865
- * unit.N_A
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
- return self.charge_balance.magnitude
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
- def get_alkalinity(self):
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 alkalinity or acid neutralizing capacity of a solution
1824
+ Return the osmotic coefficient of an aqueous solution.
1873
1825
 
1874
- Returns
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
- Notes
1880
- -----
1881
- The alkalinity is calculated according to: [#]_
1828
+ """
1829
+ molal_phi = self.engine.get_osmotic_coefficient(self)
1882
1830
 
1883
- .. math:: Alk = F \sum_i z_i C_B - \sum_i z_i C_A
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
- Where :math:`C_B` and :math:`C_A` are conservative cations and anions, respectively
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
- References
1891
- ----------
1892
- .. [#] Stumm, Werner and Morgan, James J. Aquatic Chemistry, 3rd ed,
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
- for item in self.components:
1915
- if item in base_cations:
1916
- z = self.get_solute(item).get_formal_charge()
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
- # convert the alkalinity to mg/L as CaCO3
1923
- return (alkalinity * equiv_wt_CaCO3).to("mg/L")
1857
+ See Also:
1858
+ :attr:`ionic_strength`
1859
+ :py:meth:`get_activity_coefficient`
1860
+ :py:meth:`get_salt`
1924
1861
 
1925
- def get_hardness(self):
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
- Hardness is defined as the sum of the equivalent concentrations
1930
- of multivalent cations as calcium carbonate.
1865
+ .. math:: \ln a_{w} = - \Phi M_{w} \sum_{i} m_{i}
1931
1866
 
1932
- NOTE: at present pyEQL cannot distinguish between mg/L as CaCO3
1933
- and mg/L units. Use with caution.
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
- Parameters
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
- Returns
1940
- -------
1941
- Quantity
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
- hardness = 0 * unit("mol/L")
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
- # convert the hardness to mg/L as CaCO3
1954
- return (hardness * equiv_wt_CaCO3).to("mg/L")
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
- def get_debye_length(self):
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
- Debye length is calculated as [#]_
1891
+ self.logger.debug("Calculated water activity using osmotic coefficient")
1961
1892
 
1962
- .. math::
1893
+ return ureg.Quantity(np.exp(-osmotic_coefficient * 0.018015 * concentration_sum), "dimensionless")
1963
1894
 
1964
- \\kappa^{-1} = \\sqrt({\\epsilon_r \\epsilon_o k_B T \\over (2 N_A e^2 I)})
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
- where :math:`I` is the ionic strength, :math:`epsilon_r` and :math:`epsilon_r`
1967
- are the relative permittivity and vacuum permittivity, :math:`k_B` is the
1968
- Boltzmann constant, and :math:`T` is the temperature, :math:`e` is the
1969
- elementary charge, and :math:`N_A` is Avogadro's number.
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
- Parameters
1972
- ----------
1973
- None
1906
+ Returns:
1907
+ Quantity
1908
+ The actual or ideal chemical potential energy of the solution, in Joules.
1974
1909
 
1975
- Returns
1976
- -------
1977
- Quantity
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
- References
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
- See Also
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
- # to preserve dimensionality, convert the ionic strength into mol/L units
1993
- ionic_strength = self.get_ionic_strength().magnitude * unit("mol/L")
1994
- dielectric_constant = self.get_dielectric_constant()
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
- debye_length = (
1997
- dielectric_constant
1998
- * unit.epsilon_0
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
- return debye_length.to("nm")
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
- Return the Bjerrum length of a solution
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
- .. math::
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
- \\lambda_B = {e^2 \\over (4 \\pi \\epsilon_r \\epsilon_o k_B T)}
1956
+ return E.to("J")
2017
1957
 
2018
- where :math:`e` is the fundamental charge, :math:`epsilon_r` and :math:`epsilon_r`
2019
- are the relative permittivity and vacuum permittivity, :math:`k_B` is the
2020
- Boltzmann constant, and :math:`T` is the temperature.
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
- Parameters
2023
- ----------
2024
- None
1997
+ return vol.to("cm **3 / mol")
2025
1998
 
2026
- Returns
2027
- -------
2028
- Quantity
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
- References
2032
- ----------
2033
- .. [#] https://en.wikipedia.org/wiki/Bjerrum_length
2003
+ doc: dict = data[0]
2034
2004
 
2035
- Examples
2036
- --------
2037
- >>> s1 = pyEQL.Solution()
2038
- >>> s1.get_bjerrum_length()
2039
- <Quantity(0.7152793009386953, 'nanometer')>
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
- See Also
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
- temperature = self.get_temperature()
2047
- dielectric_constant = self.get_dielectric_constant()
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
- bjerrum_length = unit.e ** 2 / (
2050
- 4 * math.pi * dielectric_constant * unit.epsilon_0 * unit.k * temperature
2051
- )
2052
- return bjerrum_length.to("nm")
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
- def get_transport_number(self, solute, activity_correction=False):
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
- Parameters
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
- activity_correction: bool
2065
- If True, the transport number will be corrected for activity following
2066
- the same method used for solution conductivity. Defaults to False
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
- Returns
2070
- -------
2071
- float
2072
- The transport number of `solute`
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
- Notes
2075
- -----
2076
- Transport number is calculated according to [#]_ :
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
- .. math::
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
- t_i = {D_i z_i^2 C_i \\over \\sum D_i z_i^2 C_i}
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
- Where :math:`C_i` is the concentration in mol/L, :math:`D_i` is the diffusion
2083
- coefficient, and :math:`z_i` is the charge, and the summation extends
2084
- over all species in the solution.
2068
+ if name == "molecular_weight":
2069
+ return ureg.Quantity(doc.get(name))
2085
2070
 
2086
- If `activity_correction` is True, the contribution of each ion to the
2087
- transport number is corrected with an activity factor. See the documentation
2088
- for get_conductivity() for an explanation of this correction.
2071
+ if name == "oxi_state_guesses":
2072
+ return doc.get(name)
2089
2073
 
2090
- References
2091
- ----------
2092
- .. [#] Geise, G. M.; Cassady, H. J.; Paul, D. R.; Logan, E.; Hickner, M. A. "Specific
2093
- ion effects on membrane potential and the permselectivity of ion exchange membranes.""
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
- denominator = 0
2098
- numerator = 0
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
- for item in self.components:
2085
+ def get_transport_number(self, solute: str) -> Quantity:
2086
+ r"""Calculate the transport number of the solute in the solution.
2101
2087
 
2102
- z = self.get_solute(item).get_formal_charge()
2103
- term = (
2104
- self.get_property(item, "diffusion_coefficient")
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
- if activity_correction is True:
2110
- gamma = self.get_activity_coefficient(item)
2092
+ Returns:
2093
+ The transport number of `solute`, as a dimensionless Quantity.
2111
2094
 
2112
- if self.get_ionic_strength().magnitude < 0.36 * z:
2113
- alpha = 0.6 / z ** 0.5
2114
- else:
2115
- alpha = self.get_ionic_strength().magnitude ** 0.5 / z
2095
+ Notes:
2096
+ Transport number is calculated according to :
2116
2097
 
2117
- if item == solute:
2118
- numerator = term * gamma ** alpha
2098
+ .. math::
2119
2099
 
2120
- denominator += term * gamma ** alpha
2100
+ t_i = {D_i z_i^2 C_i \over \sum D_i z_i^2 C_i}
2121
2101
 
2122
- else:
2123
- if item == solute:
2124
- numerator = term
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
- denominator += term
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
- def get_molar_conductivity(self, solute):
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
- Calculate the molar (equivalent) conductivity for a solute
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
- Returns
2142
- -------
2143
- float
2144
- The molar or equivalent conductivity of the species in the solution.
2145
- Zero if the solute is not charged.
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
- Notes
2148
- -----
2149
- Molar conductivity is calculated from the Nernst-Einstein relation [#]_
2130
+ if item == solute:
2131
+ numerator = term
2150
2132
 
2151
- .. math::
2133
+ denominator += term
2152
2134
 
2153
- \\kappa_i = {z_i^2 D_i F^2 \\over RT}
2135
+ return ureg.Quantity(numerator / denominator, "dimensionless")
2154
2136
 
2155
- Note that the diffusion coefficient is strongly variable with temperature.
2137
+ def _get_molar_conductivity(self, solute: str) -> Quantity:
2138
+ r"""
2139
+ Calculate the molar (equivalent) conductivity for a solute.
2156
2140
 
2157
- References
2158
- ----------
2141
+ Args:
2142
+ solute: String identifying the solute for which the molar conductivity is
2143
+ to be calculated.
2159
2144
 
2160
- .. [#] Smedley, Stuart. The Interpretation of Ionic Conductivity in Liquids, pp 1-9. Plenum Press, 1980.
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
- Examples
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
- D = self.get_property(solute, "diffusion_coefficient")
2154
+ \lambda_i = \frac{F^2}{RT} D_i z_i^2
2170
2155
 
2171
- molar_cond = (
2172
- D
2173
- * (unit.e * unit.N_A) ** 2
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
- logger.info(
2179
- "Computed molar conductivity as %s from D = %s at T=%s"
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
- return molar_cond.to("mS / cm / (mol/L)")
2163
+ 2. https://www.hydrochemistry.eu/exmpls/sc.html
2184
2164
 
2185
- def get_mobility(self, solute):
2186
- """
2187
- Calculate the ionic mobility of the solute
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
- Parameters
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
- Returns
2196
- -------
2197
- float : The ionic mobility. Zero if the solute is not charged.
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
- Notes
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
- .. math::
2185
+ return molar_cond.to("mS / cm / (mol/L)")
2206
2186
 
2207
- \\mu_i = {F |z_i| D_i \\over RT}
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
- References
2210
- ----------
2211
- .. [#] Smedley, Stuart I. The Interpretation of Ionic Conductivity in Liquids. Plenum Press, 1980.
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
- temperature = self.get_temperature()
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
- D = self.get_property(solute, "diffusion_coefficient")
2201
+ Otherwise, the reference D value is adjusted based on the Solution temperature and (optionally),
2202
+ ionic strength. The adjustments are
2217
2203
 
2218
- mobility = (
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
- logger.info(
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
- return mobility.to("m**2/V/s")
2208
+ .. math::
2232
2209
 
2233
- def get_property(self, solute, name):
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
- Parameters
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
- Returns
2247
- -------
2248
- Quantity: The desired parameter
2214
+ \kappa a = B \sqrt{I} \frac{a2}{1+I^{0.75}}
2249
2215
 
2250
- """
2251
- # retrieve the base value and the conditions of measurement from the
2252
- # database
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
- if db.has_parameter(solute, name):
2255
- base_value = self.get_solute(solute).get_parameter(name)
2256
- else:
2257
- base_value = None
2258
-
2259
- base_temperature = unit("25 degC")
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
- # for parameters not named above, just return the base value
2309
- else:
2310
- logger.warning("%s has not been corrected for solution conditions" % name)
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
- Return the total chemical potential energy of a solution (not including
2316
- pressure or electric effects)
2317
-
2318
- Parameters
2319
- ----------
2320
- activity_correction : bool, optional
2321
- If True, activities will be used to calculate the true chemical
2322
- potential. If False, mole fraction will be used, resulting in
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
- Notes
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
- The chemical potential energy (related to the Gibbs mixing energy) is
2334
- calculated as follows: [#]_
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
- .. math:: E = R T \\sum_i n_i \\ln a_i
2287
+ return D_final
2337
2288
 
2338
- or
2289
+ def _get_mobility(self, solute: str) -> Quantity:
2290
+ r"""
2291
+ Calculate the ionic mobility of the solute.
2339
2292
 
2340
- .. math:: E = R T \\sum_i n_i \\ln x_i
2293
+ Args:
2294
+ solute (str): String identifying the solute for which the mobility is to be calculated.
2341
2295
 
2342
- Where :math:`n` is the number of moles of substance, :math:`T` is the temperature in kelvin,
2343
- :math:`R` the ideal gas constant, :math:`x` the mole fraction, and :math:`a` the activity of
2344
- each component.
2296
+ Returns:
2297
+ float: The ionic mobility. Zero if the solute is not charged.
2345
2298
 
2346
- Note that dissociated ions must be counted as separate components,
2347
- so a simple salt dissolved in water is a three component solution (cation,
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
- References
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
- Examples
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
- temperature = self.get_temperature()
2309
+ D = self.get_diffusion_coefficient(solute)
2360
2310
 
2361
- E = unit("0 J")
2311
+ mobility = ureg.N_A * ureg.e * abs(self.get_property(solute, "charge")) * D / (ureg.R * self.temperature)
2362
2312
 
2363
- # loop through all the components and add their potential energy
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 E.to("J")
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
- Parameters
2395
- ----------
2396
- solute : str
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
- >>> soln = Solution([['Na+','0.5 mol/kg'],['Cl-','0.5 mol/kg']])
2406
- >>> soln.get_lattice_distance('Na+')
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
- .. math:: d = ( C_i N_A ) ^ {-{1\\over3}}
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") * unit.N_A) ** (-1 / 3)
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
- Recalculate the solution volume based on composition
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.get_solvent_mass() / h2o.water_density(
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
- Return the volume of only the solutes
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
- # determine alpha1 and alpha2 based on the type of salt
2475
- # see the May reference for the rules used to determine
2476
- # alpha1 and alpha2 based on charge
2477
- if Salt.nu_cation >= 2 and Salt.nu_anion >= 2:
2478
- if Salt.nu_cation >= 3 or Salt.nu_anion >= 3:
2479
- alpha1 = 2
2480
- alpha2 = 50
2481
- else:
2482
- alpha1 = 1.4
2483
- alpha2 = 12
2484
- else:
2485
- alpha1 = 2
2486
- alpha2 = 0
2487
-
2488
- apparent_vol = ac.get_apparent_volume_pitzer(
2489
- self.get_ionic_strength(),
2490
- molality,
2491
- alpha1,
2492
- alpha2,
2493
- param.get_value()[0],
2494
- param.get_value()[1],
2495
- param.get_value()[2],
2496
- param.get_value()[3],
2497
- param.get_value()[4],
2498
- Salt.z_cation,
2499
- Salt.z_anion,
2500
- Salt.nu_cation,
2501
- Salt.nu_anion,
2502
- temperature,
2503
- )
2504
-
2505
- solute_vol += (
2506
- apparent_vol
2507
- * (
2508
- self.get_amount(Salt.cation, "mol") / Salt.nu_cation
2509
- + self.get_amount(Salt.anion, "mol") / Salt.nu_anion
2510
- )
2511
- / 2
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
- pitzer_calc = True
2558
+ mix_pressure = (p1 * v1 + p2 * v2) / (mix_vol)
2515
2559
 
2516
- logger.info(
2517
- "Updated solution volume using Pitzer model for solute %s"
2518
- % Salt.formula
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
- # add the partial molar volume of any other solutes, except for water
2522
- # or the parent salt, which is already accounted for by the Pitzer parameters
2523
- for item in self.components:
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
- solute = self.get_solute(item)
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
- # ignore water
2528
- if item in ["H2O", "HOH"]:
2529
- continue
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
- # ignore the salt cation and anion, if already accounted for by Pitzer
2532
- if pitzer_calc is True and item in [Salt.anion, Salt.cation]:
2533
- continue
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
- if db.has_parameter(item, "partial_molar_volume"):
2536
- solute_vol += (
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
- else:
2545
- logger.warning(
2546
- "Partial molar volume data not available for solute %s. Solution volume will not be corrected."
2547
- % item
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
- return solute_vol.to("L")
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
- def copy(self):
2553
- """Return a copy of the solution
2622
+ # informational methods
2554
2623
 
2555
- TODO - clarify whether this is a deep or shallow copy
2556
- """
2557
- # prepare to copy the bulk properties
2558
- new_temperature = str(self.get_temperature())
2559
- new_pressure = str(self.pressure)
2560
- new_solvent = self.solvent_name
2561
- new_solvent_mass = str(self.get_solvent_mass())
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
- # create a list of solutes
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
- # create the new solution
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
- # informational methods
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
- def list_solutes(self):
2583
- """
2584
- List all the solutes in the solution.
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
- def list_concentrations(self, unit="mol/kg", decimals=4, type="all"):
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 unit.
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].get_formal_charge() > 0:
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].get_formal_charge() < 0:
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
- def list_salts(self, unit="mol/kg", decimals=4):
2652
- list = salt.generate_salt_list(self, unit)
2653
- for item in list:
2654
- print(
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.keys():
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))