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/engines.py ADDED
@@ -0,0 +1,793 @@
1
+ """
2
+ pyEQL engines for computing aqueous equilibria (e.g., speciation, redox, etc.).
3
+
4
+ :copyright: 2013-2024 by Ryan S. Kingsbury
5
+ :license: LGPL, see LICENSE for more details.
6
+
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import warnings
12
+ from abc import ABC, abstractmethod
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from phreeqpython import PhreeqPython
17
+
18
+ import pyEQL.activity_correction as ac
19
+ from pyEQL import ureg
20
+ from pyEQL.salt_ion_match import Salt
21
+ from pyEQL.utils import standardize_formula
22
+
23
+ # These are the only elements that are allowed to have parenthetical oxidation states
24
+ # PHREEQC will ignore others (e.g., 'Na(1)')
25
+ SPECIAL_ELEMENTS = ["S", "C", "N", "Cu", "Fe", "Mn"]
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class EOS(ABC):
31
+ """
32
+ Abstract base class for pyEQL equation of state classes.
33
+
34
+ The intent is that concrete implementations of this class make use of the
35
+ standalone functions available in pyEQL.activity_correction and pyEQL.equilibrium
36
+ as much as possible. This facilitates robust unit testing while allowing users
37
+ to "mix and match" or customize the various models as needed.
38
+ """
39
+
40
+ @abstractmethod
41
+ def get_activity_coefficient(self, solution, solute):
42
+ """
43
+ Return the *molal scale* activity coefficient of solute, given a Solution
44
+ object.
45
+
46
+ Args:
47
+ solution: pyEQL Solution object
48
+ solute: str identifying the solute of interest
49
+
50
+ Returns:
51
+ Quantity: dimensionless quantity object
52
+
53
+ Raises:
54
+ ValueError if the calculation cannot be completed, e.g. due to insufficient number of parameters.
55
+ """
56
+
57
+ @abstractmethod
58
+ def get_osmotic_coefficient(self, solution):
59
+ """
60
+ Return the *molal scale* osmotic coefficient of a Solution.
61
+
62
+ Args:
63
+ solution: pyEQL Solution object
64
+
65
+ Returns:
66
+ Quantity: dimensionless molal scale osmotic coefficient
67
+
68
+ Raises:
69
+ ValueError if the calculation cannot be completed, e.g. due to insufficient number of parameters.
70
+ """
71
+
72
+ @abstractmethod
73
+ def get_solute_volume(self):
74
+ """
75
+ Return the volume of only the solutes.
76
+
77
+ Args:
78
+ solution: pyEQL Solution object
79
+
80
+ Returns:
81
+ Quantity: solute volume in L
82
+
83
+ Raises:
84
+ ValueError if the calculation cannot be completed, e.g. due to insufficient number of parameters.
85
+ """
86
+
87
+ @abstractmethod
88
+ def equilibrate(self, solution):
89
+ """
90
+ Adjust the speciation and pH of a Solution object to achieve chemical equilibrium.
91
+
92
+ The Solution should be modified in-place, likely using add_moles / set_moles, etc.
93
+
94
+ Args:
95
+ solution: pyEQL Solution object
96
+
97
+ Returns:
98
+ Nothing. The speciation of the Solution is modified in-place.
99
+
100
+ Raises:
101
+ ValueError if the calculation cannot be completed, e.g. due to insufficient number of parameters or lack of convergence.
102
+ """
103
+
104
+
105
+ class IdealEOS(EOS):
106
+ """Ideal solution equation of state engine."""
107
+
108
+ def get_activity_coefficient(self, solution, solute):
109
+ """
110
+ Return the *molal scale* activity coefficient of solute, given a Solution
111
+ object.
112
+ """
113
+ return ureg.Quantity(1, "dimensionless")
114
+
115
+ def get_osmotic_coefficient(self, solution):
116
+ """
117
+ Return the *molal scale* osmotic coefficient of solute, given a Solution
118
+ object.
119
+ """
120
+ return ureg.Quantity(1, "dimensionless")
121
+
122
+ def get_solute_volume(self, solution):
123
+ """Return the volume of the solutes."""
124
+ return ureg.Quantity(0, "L")
125
+
126
+ def equilibrate(self, solution):
127
+ """Adjust the speciation of a Solution object to achieve chemical equilibrium."""
128
+ warnings.warn("equilibrate() has no effect in IdealEOS!")
129
+ return
130
+
131
+
132
+ class NativeEOS(EOS):
133
+ """
134
+ pyEQL's native EOS. Uses the Pitzer model when possible, falls
135
+ back to other models (e.g. Debye-Huckel) based on ionic strength
136
+ if sufficient parameters are not available.
137
+ """
138
+
139
+ def __init__(
140
+ self,
141
+ phreeqc_db: Literal["vitens.dat", "wateq4f_PWN.dat", "pitzer.dat", "llnl.dat", "geothermal.dat"] = "llnl.dat",
142
+ ) -> None:
143
+ """
144
+ Args:
145
+ phreeqc_db: Name of the PHREEQC database file to use for solution thermodynamics
146
+ and speciation calculations. Generally speaking, `llnl.dat` is recommended
147
+ for moderate salinity water and prediction of mineral solubilities,
148
+ `wateq4f_PWN.dat` is recommended for low to moderate salinity waters. It is
149
+ similar to vitens.dat but has many more species. `pitzer.dat` is recommended
150
+ when accurate activity coefficients in solutions above 1 M TDS are desired, but
151
+ it has fewer species than the other databases. `llnl.dat` and `geothermal.dat`
152
+ may offer improved prediction of LSI but currently these databases are not
153
+ usable because they do not allow for conductivity calculations.
154
+ """
155
+ self.phreeqc_db = phreeqc_db
156
+ # database files in this list are not distributed with phreeqpython
157
+ self.db_path = (
158
+ Path(os.path.dirname(__file__)) / "database" if self.phreeqc_db in ["llnl.dat", "geothermal.dat"] else None
159
+ )
160
+ # create the PhreeqcPython instance
161
+ # try/except added to catch unsupported architectures, such as Apple Silicon
162
+ try:
163
+ self.pp = PhreeqPython(database=self.phreeqc_db, database_directory=self.db_path)
164
+ except OSError:
165
+ logger.error(
166
+ "OSError encountered when trying to instantiate phreeqpython. Most likely this means you"
167
+ " are running on an architecture that is not supported by PHREEQC, such as Apple M1/M2 chips."
168
+ " pyEQL will work, but equilibrate() will have no effect."
169
+ )
170
+ # attributes to hold the PhreeqPython solution.
171
+ self.ppsol = None
172
+ # store the solution composition to see whether we need to re-instantiate the solution
173
+ self._stored_comp = None
174
+
175
+ def _setup_ppsol(self, solution):
176
+ """Helper method to set up a PhreeqPython solution for subsequent analysis."""
177
+ self._stored_comp = solution.components.copy()
178
+ solv_mass = solution.solvent_mass.to("kg").magnitude
179
+ # inherit bulk solution properties
180
+ d = {
181
+ "temp": solution.temperature.to("degC").magnitude,
182
+ "units": "mol/kgw", # to avoid confusion about volume, use mol/kgw which seems more robust in PHREEQC
183
+ "pH": solution.pH,
184
+ "pe": solution.pE,
185
+ "redox": "pe", # hard-coded to use the pe
186
+ # PHREEQC will assume 1 kg if not specified, there is also no direct way to specify volume, so we
187
+ # really have to specify the solvent mass in 1 liter of solution
188
+ "water": solv_mass,
189
+ }
190
+ if solution.balance_charge == "pH":
191
+ d["pH"] = str(d["pH"]) + " charge"
192
+ if solution.balance_charge == "pE":
193
+ d["pe"] = str(d["pe"]) + " charge"
194
+
195
+ # add the composition to the dict
196
+ # also, skip H and O
197
+ for el, mol in solution.get_el_amt_dict().items():
198
+ # CAUTION - care must be taken to avoid unintended behavior here. get_el_amt_dict() will return
199
+ # all distinct oxi states of each element present. If there are elements present whose oxi states
200
+ # are NOT recognized by PHREEQC (via SPECIAL_ELEMENTS) then the amount of only 1 oxi state will be
201
+ # entered into the composition dict. This can especially cause problems after equilibrate() has already
202
+ # been called once. For example, equilibrating a simple NaCl solution generates Cl species that are assigned
203
+ # various oxidations states, -1 mostly, but also 1, 2, and 3. Since the concentrations of everything
204
+ # except the -1 oxi state are tiny, this can result in Cl "disappearing" from the solution if
205
+ # equlibrate is called again. It also causes non-determinism, because the amount is taken from whatever
206
+ # oxi state happens to be iterated through last.
207
+
208
+ # strip off the oxi state
209
+ bare_el = el.split("(")[0]
210
+ if bare_el in SPECIAL_ELEMENTS:
211
+ # PHREEQC will ignore float-formatted oxi states. Need to make sure we are
212
+ # passing, e.g. 'C(4)' and not 'C(4.0)'
213
+ key = f'{bare_el}({int(float(el.split("(")[-1].split(")")[0]))})'
214
+ elif bare_el in ["H", "O"]:
215
+ continue
216
+ else:
217
+ key = bare_el
218
+
219
+ if key in d:
220
+ # when multiple oxi states for the same (non-SPECIAL) element are present, make sure to
221
+ # add all their amounts together
222
+ d[key] += str(mol / solv_mass)
223
+ else:
224
+ d[key] = str(mol / solv_mass)
225
+
226
+ # tell PHREEQC which species to use for charge balance
227
+ if (
228
+ solution.balance_charge is not None
229
+ and solution.balance_charge in solution.get_components_by_element()[el]
230
+ ):
231
+ d[key] += " charge"
232
+
233
+ # create the PHREEQC solution object
234
+ try:
235
+ ppsol = self.pp.add_solution(d)
236
+ except Exception as e:
237
+ print(d)
238
+ # catch problems with the input to phreeqc
239
+ raise ValueError(
240
+ "There is a problem with your input. The error message received from "
241
+ f" phreeqpython is:\n\n {e}\n Check your input arguments, especially "
242
+ "the composition dictionary, and try again."
243
+ )
244
+
245
+ self.ppsol = ppsol
246
+
247
+ def _destroy_ppsol(self):
248
+ """Remove the PhreeqPython solution from memory"""
249
+ if self.ppsol is not None:
250
+ self.ppsol.forget()
251
+ self.ppsol = None
252
+
253
+ def get_activity_coefficient(self, solution, solute):
254
+ r"""
255
+ Whenever the appropriate parameters are available, the Pitzer model [may]_ is used.
256
+ If no Pitzer parameters are available, then the appropriate equations are selected
257
+ according to the following logic: [stumm]_.
258
+
259
+ I <= 0.0005: Debye-Huckel equation
260
+ 0.005 < I <= 0.1: Guntelberg approximation
261
+ 0.1 < I <= 0.5: Davies equation
262
+ I > 0.5: Raises a warning and returns activity coefficient = 1
263
+
264
+ The ionic strength, activity coefficients, and activities are all
265
+ calculated based on the molal (mol/kg) concentration scale. If a different
266
+ scale is given as input, then the molal-scale activity coefficient :math:`\gamma_\pm` is
267
+ converted according to [rbs]_
268
+
269
+ .. math:: f_\pm = \gamma_\pm * (1 + M_w \sum_i \nu_i m_i)
270
+
271
+ .. math:: y_\pm = \frac{m \rho_w}{C \gamma_\pm}
272
+
273
+ where :math:`f_\pm` is the rational activity coefficient, :math:`M_w` is
274
+ the molecular weight of water, the summation represents the total molality of
275
+ all solute species, :math:`y_\pm` is the molar activity coefficient,
276
+ :math:`\rho_w` is the density of pure water, :math:`m` and :math:`C` are
277
+ the molal and molar concentrations of the chosen salt (not individual solute), respectively.
278
+
279
+ Args:
280
+ solute: String representing the name of the solute of interest
281
+ scale: The concentration scale for the returned activity coefficient.
282
+ Valid options are "molal", "molar", and "rational" (i.e., mole fraction).
283
+ By default, the molal scale activity coefficient is returned.
284
+
285
+ Returns:
286
+ The mean ion activity coefficient of the solute in question on the selected scale.
287
+
288
+
289
+ Notes:
290
+ For multicomponent mixtures, pyEQL implements the "effective Pitzer model"
291
+ presented by Mistry et al. [mistry]_. In this model, the activity coefficient
292
+ of a salt in a multicomponent mixture is calculated using an "effective
293
+ molality," which is the molality that would result in a single-salt
294
+ mixture with the same total ionic strength as the multicomponent solution.
295
+
296
+ .. math:: m_{effective} = \frac{2 I}{(\nu_{+} z_{+}^2 + \nu_{-}- z_{-}^2)}
297
+
298
+ References:
299
+ .. [may] May, P. M., Rowland, D., Hefter, G., & Königsberger, E. (2011).
300
+ A Generic and Updatable Pitzer Characterization of Aqueous Binary Electrolyte Solutions at 1 bar and 25 °C.
301
+ *Journal of Chemical & Engineering Data*, 56(12), 5066-5077. doi:10.1021/je2009329
302
+
303
+ .. [stumm] Stumm, Werner and Morgan, James J. *Aquatic Chemistry*, 3rd ed,
304
+ pp 165. Wiley Interscience, 1996.
305
+
306
+ .. [rbs] Robinson, R. A.; Stokes, R. H. Electrolyte Solutions: Second Revised
307
+ Edition; Butterworths: London, 1968, p.32.
308
+
309
+ .. [mistry] Mistry, K. H.; Hunter, H. a.; Lienhard V, J. H. Effect of composition and nonideal solution behavior on
310
+ desalination calculations for mixed electrolyte solutions with comparison to seawater. Desalination 2013, 318, 34-47.
311
+
312
+ See Also:
313
+ :attr:`pyEQL.solution.Solution.ionic_strength`
314
+ :func:`pyEQL.activity_correction.get_activity_coefficient_debyehuckel`
315
+ :func:`pyEQL.activity_correction.get_activity_coefficient_guntelberg`
316
+ :func:`pyEQL.activity_correction.get_activity_coefficient_davies`
317
+ :func:`pyEQL.activity_correction.get_activity_coefficient_pitzer`
318
+ """
319
+ # identify the predominant salt that this ion is a member of
320
+ salt = None
321
+ rform = standardize_formula(solute)
322
+ for v in solution.get_salt_dict().values():
323
+ if v == "HOH":
324
+ continue
325
+ if rform == v["cation"] or rform == v["anion"]:
326
+ del v["mol"]
327
+ salt = Salt.from_dict(v)
328
+ break
329
+
330
+ # show an error if no salt can be found that contains the solute
331
+ if salt is None:
332
+ logger.error("No salts found that contain solute %s. Returning unit activity coefficient." % solute)
333
+ return ureg.Quantity(1, "dimensionless")
334
+
335
+ # use the Pitzer model for higher ionic strength, if the parameters are available
336
+ # search for Pitzer parameters
337
+ param = solution.get_property(salt.formula, "model_parameters.activity_pitzer")
338
+ if param is not None:
339
+ # TODO - consider re-enabling a log message recording what salt(s) are used as basis for activity calculation
340
+ logger.info(f"Calculating activity coefficient based on parent salt {salt.formula}")
341
+
342
+ # determine alpha1 and alpha2 based on the type of salt
343
+ # see the May reference for the rules used to determine
344
+ # alpha1 and alpha2 based on charge
345
+ if salt.nu_cation >= 2 and salt.nu_anion <= -2:
346
+ if salt.nu_cation >= 3 or salt.nu_anion <= -3:
347
+ alpha1 = 2
348
+ alpha2 = 50
349
+ else:
350
+ alpha1 = 1.4
351
+ alpha2 = 12
352
+ else:
353
+ alpha1 = 2
354
+ alpha2 = 0
355
+
356
+ # determine the average molality of the salt
357
+ # this is necessary for solutions inside e.g. an ion exchange
358
+ # membrane, where the cation and anion concentrations may be
359
+ # unequal
360
+ # molality = (solution.get_amount(salt.cation,'mol/kg')/salt.nu_cation+solution.get_amount(salt.anion,'mol/kg')/salt.nu_anion)/2
361
+
362
+ # determine the effective molality of the salt in the solution
363
+ molality = salt.get_effective_molality(solution.ionic_strength)
364
+
365
+ activity_coefficient = ac.get_activity_coefficient_pitzer(
366
+ solution.ionic_strength,
367
+ molality,
368
+ alpha1,
369
+ alpha2,
370
+ ureg.Quantity(param["Beta0"]["value"]).magnitude,
371
+ ureg.Quantity(param["Beta1"]["value"]).magnitude,
372
+ ureg.Quantity(param["Beta2"]["value"]).magnitude,
373
+ ureg.Quantity(param["Cphi"]["value"]).magnitude,
374
+ salt.z_cation,
375
+ salt.z_anion,
376
+ salt.nu_cation,
377
+ salt.nu_anion,
378
+ str(solution.temperature),
379
+ )
380
+
381
+ logger.debug(
382
+ f"Calculated activity coefficient of species {solute} as {activity_coefficient} based on salt"
383
+ f" {salt} using Pitzer model"
384
+ )
385
+ molal = activity_coefficient
386
+
387
+ # for very low ionic strength, use the Debye-Huckel limiting law
388
+ elif solution.ionic_strength.magnitude <= 0.005:
389
+ logger.debug(
390
+ f"Ionic strength = {solution.ionic_strength}. Using Debye-Huckel to calculate activity coefficient."
391
+ )
392
+ molal = ac.get_activity_coefficient_debyehuckel(
393
+ solution.ionic_strength,
394
+ solution.get_property(solute, "charge"),
395
+ str(solution.temperature),
396
+ )
397
+
398
+ # use the Guntelberg approximation for 0.005 < I < 0.1
399
+ elif solution.ionic_strength.magnitude <= 0.1:
400
+ logger.debug(
401
+ f"Ionic strength = {solution.ionic_strength}. Using Guntelberg to calculate activity coefficient."
402
+ )
403
+ molal = ac.get_activity_coefficient_guntelberg(
404
+ solution.ionic_strength,
405
+ solution.get_property(solute, "charge"),
406
+ str(solution.temperature),
407
+ )
408
+
409
+ # use the Davies equation for 0.1 < I < 0.5
410
+ elif solution.ionic_strength.magnitude <= 0.5:
411
+ logger.debug(
412
+ f"Ionic strength = {solution.ionic_strength}. Using Davies equation to calculate activity coefficient."
413
+ )
414
+ molal = ac.get_activity_coefficient_davies(
415
+ solution.ionic_strength,
416
+ solution.get_property(solute, "charge"),
417
+ str(solution.temperature),
418
+ )
419
+
420
+ else:
421
+ logger.error(
422
+ f"Ionic strength too high to estimate activity for species {solute}. Specify parameters for Pitzer "
423
+ "model. Returning unit activity coefficient"
424
+ )
425
+
426
+ molal = ureg.Quantity(1, "dimensionless")
427
+
428
+ return molal
429
+
430
+ def get_osmotic_coefficient(self, solution):
431
+ r"""
432
+ Return the *molal scale* osmotic coefficient of solute, given a Solution
433
+ object.
434
+
435
+ Osmotic coefficient is calculated using the Pitzer model. [may]_ If appropriate parameters for
436
+ the model are not available, then pyEQL raises a WARNING and returns an osmotic
437
+ coefficient of 1.
438
+
439
+ If the 'rational' scale is given as input, then the molal-scale osmotic
440
+ coefficient :math:`\phi` is converted according to [rbs]_
441
+
442
+ .. math:: g = - \phi M_{w} \frac{\sum_{i} \nu_{i} m_{i}}{\ln x_{w}}
443
+
444
+ where :math:`g` is the rational osmotic coefficient, :math:`M_{w}` is
445
+ the molecular weight of water, the summation represents the total molality of
446
+ all solute species, and :math:`x_{w}` is the mole fraction of water.
447
+
448
+ Args:
449
+ scale: The concentration scale for the returned osmotic coefficient. Valid options are "molal",
450
+ "rational" (i.e., mole fraction), and "fugacity". By default, the molal scale osmotic
451
+ coefficient is returned.
452
+
453
+ Returns:
454
+ Quantity:
455
+ The osmotic coefficient
456
+
457
+ See Also:
458
+ :meth:`pyEQL.solution.Solution.get_water_activity`
459
+ :meth:`pyEQL.solution.Solution.get_salt`
460
+ :attr:`pyEQL.solution.Solution.ionic_strength`
461
+
462
+ Notes:
463
+ For multicomponent mixtures, pyEQL adopts the "effective Pitzer model"
464
+ presented by Mistry et al. [mstry]_. In this approach, the osmotic coefficient of
465
+ each individual salt is calculated using the normal Pitzer model based
466
+ on its respective concentration. Then, an effective osmotic coefficient
467
+ is calculated as the concentration-weighted average of the individual
468
+ osmotic coefficients.
469
+
470
+ For example, in a mixture of 0.5 M NaCl and 0.5 M KBr, one would calculate
471
+ the osmotic coefficient for each salt using a concentration of 0.5 M and
472
+ an ionic strength of 1 M. Then, one would average the two resulting
473
+ osmotic coefficients to obtain an effective osmotic coefficient for the
474
+ mixture.
475
+
476
+ (Note: in the paper referenced below, the effective osmotic coefficient is determined by weighting
477
+ using the "effective molality" rather than the true molality. Subsequent checking and correspondence with
478
+ the author confirmed that the weight factor should be the true molality, and that is what is implemented
479
+ in pyEQL.)
480
+
481
+ References:
482
+ .. [may] May, P. M., Rowland, D., Hefter, G., & Königsberger, E. (2011).
483
+ A Generic and Updatable Pitzer Characterization of Aqueous Binary Electrolyte Solutions at 1 bar and
484
+ 25 °C. Journal of Chemical & Engineering Data, 56(12), 5066-5077. doi:10.1021/je2009329
485
+
486
+ .. [rbs] Robinson, R. A.; Stokes, R. H. Electrolyte Solutions: Second Revised
487
+ Edition; Butterworths: London, 1968, p.32.
488
+
489
+ .. [mstry] Mistry, K. H.; Hunter, H. a.; Lienhard V, J. H. Effect of composition and nonideal solution
490
+ behavior on desalination calculations for mixed electrolyte solutions with comparison to seawater. Desalination 2013, 318, 34-47.
491
+
492
+ Examples:
493
+ >>> s1 = pyEQL.Solution({'Na+': '0.2 mol/kg', 'Cl-': '0.2 mol/kg'})
494
+ >>> s1.get_osmotic_coefficient()
495
+ <Quantity(0.923715281, 'dimensionless')>
496
+
497
+ >>> s1 = pyEQL.Solution({'Mg+2': '0.3 mol/kg', 'Cl-': '0.6 mol/kg'},temperature='30 degC')
498
+ >>> s1.get_osmotic_coefficient()
499
+ <Quantity(0.891409618, 'dimensionless')>
500
+
501
+ """
502
+ ionic_strength = solution.ionic_strength
503
+
504
+ effective_osmotic_sum = 0
505
+ molality_sum = 0
506
+
507
+ # loop through all the salts in the solution, calculate the osmotic
508
+ # coefficint for each, and average them into an effective osmotic
509
+ # coefficient
510
+ for d in solution.get_salt_dict().values():
511
+ item = Salt(d["cation"], d["anion"])
512
+ # ignore HOH in the salt list
513
+ if item.formula == "HOH":
514
+ continue
515
+
516
+ # determine alpha1 and alpha2 based on the type of salt
517
+ # see the May reference for the rules used to determine
518
+ # alpha1 and alpha2 based on charge
519
+ if item.z_cation >= 2 and item.z_anion <= -2:
520
+ if item.z_cation >= 3 or item.z_anion <= -3:
521
+ alpha1 = 2.0
522
+ alpha2 = 50.0
523
+ else:
524
+ alpha1 = 1.4
525
+ alpha2 = 12.0
526
+ else:
527
+ alpha1 = 2.0
528
+ alpha2 = 0
529
+
530
+ # set the concentration as the average concentration of the cation and
531
+ # anion in the salt, accounting for stoichiometry
532
+ # concentration = (solution.get_amount(Salt.cation,'mol/kg')/Salt.nu_cation + \
533
+ # solution.get_amount(Salt.anion,'mol/kg')/Salt.nu_anion)/2
534
+
535
+ # get the effective molality of the salt
536
+ concentration = ureg.Quantity(d["mol"], "mol") / solution.solvent_mass
537
+
538
+ molality_sum += concentration
539
+
540
+ param = solution.get_property(item.formula, "model_parameters.activity_pitzer")
541
+ if param is not None:
542
+ osmotic_coefficient = ac.get_osmotic_coefficient_pitzer(
543
+ ionic_strength,
544
+ concentration,
545
+ alpha1,
546
+ alpha2,
547
+ ureg.Quantity(param["Beta0"]["value"]).magnitude,
548
+ ureg.Quantity(param["Beta1"]["value"]).magnitude,
549
+ ureg.Quantity(param["Beta2"]["value"]).magnitude,
550
+ ureg.Quantity(param["Cphi"]["value"]).magnitude,
551
+ item.z_cation,
552
+ item.z_anion,
553
+ item.nu_cation,
554
+ item.nu_anion,
555
+ str(solution.temperature),
556
+ )
557
+
558
+ logger.debug(
559
+ f"Calculated osmotic coefficient of water as {osmotic_coefficient} based on salt "
560
+ f"{item.formula} using Pitzer model"
561
+ )
562
+ effective_osmotic_sum += concentration * osmotic_coefficient
563
+
564
+ else:
565
+ logger.debug(
566
+ f"Returning unit osmotic coefficient for salt {item.formula} because Pitzer parameters are not"
567
+ "available in database."
568
+ )
569
+ effective_osmotic_sum += concentration * 1
570
+
571
+ try:
572
+ return effective_osmotic_sum / molality_sum
573
+ except ZeroDivisionError:
574
+ # this means the solution is empty
575
+ return 1
576
+
577
+ def get_solute_volume(self, solution):
578
+ """Return the volume of the solutes."""
579
+ # identify the predominant salt in the solution
580
+ salt = solution.get_salt()
581
+ solute_vol = ureg.Quantity(0, "L")
582
+
583
+ # use the pitzer approach if parameters are available
584
+ pitzer_calc = False
585
+
586
+ param = solution.get_property(salt.formula, "model_parameters.molar_volume_pitzer")
587
+ if param is not None:
588
+ # determine the average molality of the salt
589
+ # this is necessary for solutions inside e.g. an ion exchange
590
+ # membrane, where the cation and anion concentrations may be
591
+ # unequal
592
+ molality = (solution.get_amount(salt.cation, "mol/kg") + solution.get_amount(salt.anion, "mol/kg")) / 2
593
+
594
+ # determine alpha1 and alpha2 based on the type of salt
595
+ # see the May reference for the rules used to determine
596
+ # alpha1 and alpha2 based on charge
597
+ if salt.nu_cation >= 2 and salt.nu_anion >= 2:
598
+ if salt.nu_cation >= 3 or salt.nu_anion >= 3:
599
+ alpha1 = 2
600
+ alpha2 = 50
601
+ else:
602
+ alpha1 = 1.4
603
+ alpha2 = 12
604
+ else:
605
+ alpha1 = 2
606
+ alpha2 = 0
607
+
608
+ apparent_vol = ac.get_apparent_volume_pitzer(
609
+ solution.ionic_strength,
610
+ molality,
611
+ alpha1,
612
+ alpha2,
613
+ ureg.Quantity(param["Beta0"]["value"]).magnitude,
614
+ ureg.Quantity(param["Beta1"]["value"]).magnitude,
615
+ ureg.Quantity(param["Beta2"]["value"]).magnitude,
616
+ ureg.Quantity(param["Cphi"]["value"]).magnitude,
617
+ ureg.Quantity(param["V_o"]["value"]).magnitude,
618
+ salt.z_cation,
619
+ salt.z_anion,
620
+ salt.nu_cation,
621
+ salt.nu_anion,
622
+ str(solution.temperature),
623
+ )
624
+
625
+ solute_vol += (
626
+ apparent_vol
627
+ * (
628
+ solution.get_amount(salt.cation, "mol") / salt.nu_cation
629
+ + solution.get_amount(salt.anion, "mol") / salt.nu_anion
630
+ )
631
+ / 2
632
+ )
633
+
634
+ pitzer_calc = True
635
+
636
+ logger.debug("Updated solution volume using Pitzer model for solute %s" % salt.formula)
637
+
638
+ # add the partial molar volume of any other solutes, except for water
639
+ # or the parent salt, which is already accounted for by the Pitzer parameters
640
+ for solute, mol in solution.components.items():
641
+ # ignore water
642
+ if solute in ["H2O", "HOH", "H2O(aq)"]:
643
+ continue
644
+
645
+ # ignore the salt cation and anion, if already accounted for by Pitzer
646
+ if pitzer_calc is True and solute in [salt.anion, salt.cation]:
647
+ continue
648
+
649
+ part_vol = solution.get_property(solute, "size.molar_volume")
650
+ if part_vol is not None:
651
+ solute_vol += part_vol * ureg.Quantity(mol, "mol")
652
+ logger.debug("Updated solution volume using direct partial molar volume for solute %s" % solute)
653
+
654
+ else:
655
+ logger.warning(
656
+ f"Volume of solute {solute} will be ignored because partial molar volume data are not available."
657
+ )
658
+
659
+ return solute_vol.to("L")
660
+
661
+ def equilibrate(self, solution):
662
+ """Adjust the speciation of a Solution object to achieve chemical equilibrium."""
663
+ if self.ppsol is not None:
664
+ self.ppsol.forget()
665
+ self._setup_ppsol(solution)
666
+
667
+ # store the original solvent mass
668
+ orig_solvent_moles = solution.components[solution.solvent]
669
+
670
+ # use the output from PHREEQC to update the Solution composition
671
+ # the .species_moles attribute should return MOLES (not moles per ___)
672
+ for s, mol in self.ppsol.species_moles.items():
673
+ solution.components[s] = mol
674
+
675
+ # make sure all species are accounted for
676
+ assert set(self._stored_comp.keys()) - set(solution.components.keys()) == set()
677
+
678
+ # log a message if any components were not touched by PHREEQC
679
+ # if that was the case, re-adjust the charge balance to account for those species (since PHREEQC did not)
680
+ missing_species = set(self._stored_comp.keys()) - {standardize_formula(s) for s in self.ppsol.species}
681
+ if len(missing_species) > 0:
682
+ logger.warning(
683
+ f"After equilibration, the amounts of species {missing_species} were not modified "
684
+ "by PHREEQC. These species are likely absent from its database."
685
+ )
686
+
687
+ # re-adjust charge balance for any missing species
688
+ # note that if balance_charge is set, it will have been passed to PHREEQC, so we only need to adjust
689
+ # for any missing species here.
690
+ charge_adjust = 0
691
+ for s in missing_species:
692
+ charge_adjust += -1 * solution.get_amount(s, "eq").magnitude
693
+ if charge_adjust != 0:
694
+ logger.warning(
695
+ "After equilibration, the charge balance of the solution was not electroneutral."
696
+ f" {charge_adjust} eq of charge were added via {solution.balance_charge}"
697
+ )
698
+
699
+ if solution.balance_charge is None:
700
+ pass
701
+ elif solution.balance_charge == "pH":
702
+ solution.components["H+"] += charge_adjust.magnitude
703
+ elif solution.balance_charge == "pE":
704
+ raise NotImplementedError
705
+ else:
706
+ z = solution.get_property(solution.balance_charge, "charge")
707
+ solution.add_amount(solution.balance_charge, f"{charge_adjust/z} mol")
708
+
709
+ # rescale the solvent mass to ensure the total mass of solution does not change
710
+ # this is important because PHREEQC and the pyEQL database may use slightly different molecular
711
+ # weights for water. Since water amount is passed to PHREEQC in kg but returned in moles, each
712
+ # call to equilibrate can thus result in a slight change in the Solution mass.
713
+ solution.components[solution.solvent] = orig_solvent_moles
714
+
715
+ def __deepcopy__(self, memo) -> "NativeEOS":
716
+ # custom deepcopy required because the PhreeqPython instance used by the Native and Phreeqc engines
717
+ # is not pickle-able.
718
+ import copy
719
+
720
+ cls = self.__class__
721
+ result = cls.__new__(cls)
722
+ memo[id(self)] = result
723
+ for k, v in self.__dict__.items():
724
+ if k == "pp":
725
+ result.pp = PhreeqPython(database=self.phreeqc_db, database_directory=self.db_path)
726
+ continue
727
+ setattr(result, k, copy.deepcopy(v, memo))
728
+ return result
729
+
730
+
731
+ class PhreeqcEOS(NativeEOS):
732
+ """Engine based on the PhreeqC model, as implemented via the phreeqpython package."""
733
+
734
+ def __init__(
735
+ self,
736
+ phreeqc_db: Literal[
737
+ "vitens.dat", "wateq4f_PWN.dat", "pitzer.dat", "llnl.dat", "geothermal.dat"
738
+ ] = "phreeqc.dat",
739
+ ) -> None:
740
+ """
741
+ Args:
742
+ phreeqc_db: Name of the PHREEQC database file to use for solution thermodynamics
743
+ and speciation calculations. Generally speaking, `llnl.dat` is recommended
744
+ for moderate salinity water and prediction of mineral solubilities,
745
+ `wateq4f_PWN.dat` is recommended for low to moderate salinity waters. It is
746
+ similar to vitens.dat but has many more species. `pitzer.dat` is recommended
747
+ when accurate activity coefficients in solutions above 1 M TDS are desired, but
748
+ it has fewer species than the other databases. `llnl.dat` and `geothermal.dat`
749
+ may offer improved prediction of LSI but currently these databases are not
750
+ usable because they do not allow for conductivity calculations.
751
+ """
752
+ super().__init__(phreeqc_db=phreeqc_db)
753
+
754
+ def get_activity_coefficient(self, solution, solute):
755
+ """
756
+ Return the *molal scale* activity coefficient of solute, given a Solution
757
+ object.
758
+ """
759
+ if self.ppsol is None or solution.components != self._stored_comp:
760
+ self._destroy_ppsol()
761
+ self._setup_ppsol(solution)
762
+
763
+ # translate the species into keys that phreeqc will understand
764
+ k = standardize_formula(solute)
765
+ spl = k.split("[")
766
+ el = spl[0]
767
+ chg = spl[1].split("]")[0]
768
+ if chg[-1] == "1":
769
+ chg = chg[0] # just pass + or -, not +1 / -1
770
+ k = el + chg
771
+
772
+ # calculate the molal scale activity coefficient
773
+ # act = self.ppsol.activity(k, "mol") / self.ppsol.molality(k, "mol")
774
+ act = self.ppsol.pp.ip.get_activity(self.ppsol.number, k) / self.ppsol.pp.ip.get_molality(self.ppsol.number, k)
775
+
776
+ return ureg.Quantity(act, "dimensionless")
777
+
778
+ def get_osmotic_coefficient(self, solution):
779
+ """
780
+ Return the *molal scale* osmotic coefficient of solute, given a Solution
781
+ object.
782
+
783
+ PHREEQC appears to assume a unit osmotic coefficient unless the pitzer database
784
+ is used. Unfortunately, there is no easy way to access the osmotic coefficient
785
+ via phreeqcpython
786
+ """
787
+ # TODO - find a way to access or calculate osmotic coefficient
788
+ return ureg.Quantity(1, "dimensionless")
789
+
790
+ def get_solute_volume(self, solution):
791
+ """Return the volume of the solutes."""
792
+ # TODO - phreeqc seems to have no concept of volume, but it does calculate density
793
+ return ureg.Quantity(0, "L")