pyEQL 1.1.0__py3-none-any.whl → 1.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyEQL/__init__.py +1 -4
- pyEQL/database/pyeql_db.json +2 -2
- pyEQL/engines.py +43 -47
- pyEQL/solution.py +48 -102
- pyEQL/utils.py +47 -1
- {pyEQL-1.1.0.dist-info → pyEQL-1.1.2.dist-info}/METADATA +14 -12
- {pyEQL-1.1.0.dist-info → pyEQL-1.1.2.dist-info}/RECORD +12 -13
- {pyEQL-1.1.0.dist-info → pyEQL-1.1.2.dist-info}/WHEEL +1 -2
- pyEQL/pint_custom_units.txt +0 -52
- {pyEQL-1.1.0.dist-info → pyEQL-1.1.2.dist-info}/AUTHORS.md +0 -0
- {pyEQL-1.1.0.dist-info → pyEQL-1.1.2.dist-info}/COPYING +0 -0
- {pyEQL-1.1.0.dist-info → pyEQL-1.1.2.dist-info}/LICENSE.txt +0 -0
- {pyEQL-1.1.0.dist-info → pyEQL-1.1.2.dist-info}/top_level.txt +0 -0
pyEQL/__init__.py
CHANGED
|
@@ -37,11 +37,8 @@ ureg = UnitRegistry(cache_folder=":auto:")
|
|
|
37
37
|
# convert "offset units" so that, e.g. Quantity('25 degC') works without error
|
|
38
38
|
# see https://pint.readthedocs.io/en/0.22/user/nonmult.html?highlight=offset#temperature-conversion
|
|
39
39
|
ureg.autoconvert_offset_to_baseunit = True
|
|
40
|
-
# append custom unit definitions and contexts
|
|
41
|
-
fname = files("pyEQL") / "pint_custom_units.txt"
|
|
42
|
-
ureg.load_definitions(fname)
|
|
43
40
|
# activate the "chemistry" context globally
|
|
44
|
-
ureg.enable_contexts("
|
|
41
|
+
ureg.enable_contexts("chemistry")
|
|
45
42
|
# set the default string formatting for pint quantities
|
|
46
43
|
ureg.default_format = "P~"
|
|
47
44
|
|
pyEQL/database/pyeql_db.json
CHANGED
|
@@ -6698,7 +6698,7 @@
|
|
|
6698
6698
|
"n_elements": 1,
|
|
6699
6699
|
"size": {
|
|
6700
6700
|
"radius_ionic": {
|
|
6701
|
-
"value": "0.755",
|
|
6701
|
+
"value": "0.755 Å",
|
|
6702
6702
|
"reference": "pymatgen",
|
|
6703
6703
|
"data_type": "experimental"
|
|
6704
6704
|
},
|
|
@@ -24663,7 +24663,7 @@
|
|
|
24663
24663
|
"n_elements": 1,
|
|
24664
24664
|
"size": {
|
|
24665
24665
|
"radius_ionic": {
|
|
24666
|
-
"value": "0.83",
|
|
24666
|
+
"value": "0.83 Å",
|
|
24667
24667
|
"reference": "pymatgen",
|
|
24668
24668
|
"data_type": "experimental"
|
|
24669
24669
|
},
|
pyEQL/engines.py
CHANGED
|
@@ -11,7 +11,7 @@ import os
|
|
|
11
11
|
import warnings
|
|
12
12
|
from abc import ABC, abstractmethod
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import Literal
|
|
14
|
+
from typing import TYPE_CHECKING, Literal
|
|
15
15
|
|
|
16
16
|
from phreeqpython import PhreeqPython
|
|
17
17
|
|
|
@@ -26,6 +26,9 @@ SPECIAL_ELEMENTS = ["S", "C", "N", "Cu", "Fe", "Mn"]
|
|
|
26
26
|
|
|
27
27
|
logger = logging.getLogger(__name__)
|
|
28
28
|
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from pyEQL import Solution
|
|
31
|
+
|
|
29
32
|
|
|
30
33
|
class EOS(ABC):
|
|
31
34
|
"""
|
|
@@ -38,7 +41,7 @@ class EOS(ABC):
|
|
|
38
41
|
"""
|
|
39
42
|
|
|
40
43
|
@abstractmethod
|
|
41
|
-
def get_activity_coefficient(self, solution, solute):
|
|
44
|
+
def get_activity_coefficient(self, solution: "Solution", solute: str) -> ureg.Quantity:
|
|
42
45
|
"""
|
|
43
46
|
Return the *molal scale* activity coefficient of solute, given a Solution
|
|
44
47
|
object.
|
|
@@ -55,7 +58,7 @@ class EOS(ABC):
|
|
|
55
58
|
"""
|
|
56
59
|
|
|
57
60
|
@abstractmethod
|
|
58
|
-
def get_osmotic_coefficient(self, solution):
|
|
61
|
+
def get_osmotic_coefficient(self, solution: "Solution") -> ureg.Quantity:
|
|
59
62
|
"""
|
|
60
63
|
Return the *molal scale* osmotic coefficient of a Solution.
|
|
61
64
|
|
|
@@ -70,7 +73,7 @@ class EOS(ABC):
|
|
|
70
73
|
"""
|
|
71
74
|
|
|
72
75
|
@abstractmethod
|
|
73
|
-
def get_solute_volume(self):
|
|
76
|
+
def get_solute_volume(self, solution: "Solution") -> ureg.Quantity:
|
|
74
77
|
"""
|
|
75
78
|
Return the volume of only the solutes.
|
|
76
79
|
|
|
@@ -85,7 +88,7 @@ class EOS(ABC):
|
|
|
85
88
|
"""
|
|
86
89
|
|
|
87
90
|
@abstractmethod
|
|
88
|
-
def equilibrate(self, solution):
|
|
91
|
+
def equilibrate(self, solution: "Solution") -> None:
|
|
89
92
|
"""
|
|
90
93
|
Adjust the speciation and pH of a Solution object to achieve chemical equilibrium.
|
|
91
94
|
|
|
@@ -105,25 +108,25 @@ class EOS(ABC):
|
|
|
105
108
|
class IdealEOS(EOS):
|
|
106
109
|
"""Ideal solution equation of state engine."""
|
|
107
110
|
|
|
108
|
-
def get_activity_coefficient(self, solution, solute):
|
|
111
|
+
def get_activity_coefficient(self, solution: "Solution", solute: str) -> ureg.Quantity:
|
|
109
112
|
"""
|
|
110
113
|
Return the *molal scale* activity coefficient of solute, given a Solution
|
|
111
114
|
object.
|
|
112
115
|
"""
|
|
113
116
|
return ureg.Quantity(1, "dimensionless")
|
|
114
117
|
|
|
115
|
-
def get_osmotic_coefficient(self, solution):
|
|
118
|
+
def get_osmotic_coefficient(self, solution: "Solution") -> ureg.Quantity:
|
|
116
119
|
"""
|
|
117
120
|
Return the *molal scale* osmotic coefficient of solute, given a Solution
|
|
118
121
|
object.
|
|
119
122
|
"""
|
|
120
123
|
return ureg.Quantity(1, "dimensionless")
|
|
121
124
|
|
|
122
|
-
def get_solute_volume(self, solution):
|
|
125
|
+
def get_solute_volume(self, solution: "Solution") -> ureg.Quantity:
|
|
123
126
|
"""Return the volume of the solutes."""
|
|
124
127
|
return ureg.Quantity(0, "L")
|
|
125
128
|
|
|
126
|
-
def equilibrate(self, solution):
|
|
129
|
+
def equilibrate(self, solution: "Solution") -> None:
|
|
127
130
|
"""Adjust the speciation of a Solution object to achieve chemical equilibrium."""
|
|
128
131
|
warnings.warn("equilibrate() has no effect in IdealEOS!")
|
|
129
132
|
return
|
|
@@ -138,7 +141,9 @@ class NativeEOS(EOS):
|
|
|
138
141
|
|
|
139
142
|
def __init__(
|
|
140
143
|
self,
|
|
141
|
-
phreeqc_db: Literal[
|
|
144
|
+
phreeqc_db: Literal[
|
|
145
|
+
"phreeqc.dat", "vitens.dat", "wateq4f_PWN.dat", "pitzer.dat", "llnl.dat", "geothermal.dat"
|
|
146
|
+
] = "llnl.dat",
|
|
142
147
|
) -> None:
|
|
143
148
|
"""
|
|
144
149
|
Args:
|
|
@@ -172,7 +177,7 @@ class NativeEOS(EOS):
|
|
|
172
177
|
# store the solution composition to see whether we need to re-instantiate the solution
|
|
173
178
|
self._stored_comp = None
|
|
174
179
|
|
|
175
|
-
def _setup_ppsol(self, solution):
|
|
180
|
+
def _setup_ppsol(self, solution: "Solution") -> None:
|
|
176
181
|
"""Helper method to set up a PhreeqPython solution for subsequent analysis."""
|
|
177
182
|
self._stored_comp = solution.components.copy()
|
|
178
183
|
solv_mass = solution.solvent_mass.to("kg").magnitude
|
|
@@ -224,10 +229,7 @@ class NativeEOS(EOS):
|
|
|
224
229
|
d[key] = str(mol / solv_mass)
|
|
225
230
|
|
|
226
231
|
# 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
|
-
):
|
|
232
|
+
if solution.balance_charge is not None and solution._cb_species in solution.get_components_by_element()[el]:
|
|
231
233
|
d[key] += " charge"
|
|
232
234
|
|
|
233
235
|
# create the PHREEQC solution object
|
|
@@ -244,13 +246,13 @@ class NativeEOS(EOS):
|
|
|
244
246
|
|
|
245
247
|
self.ppsol = ppsol
|
|
246
248
|
|
|
247
|
-
def _destroy_ppsol(self):
|
|
248
|
-
"""Remove the PhreeqPython solution from memory"""
|
|
249
|
+
def _destroy_ppsol(self) -> None:
|
|
250
|
+
"""Remove the PhreeqPython solution from memory."""
|
|
249
251
|
if self.ppsol is not None:
|
|
250
252
|
self.ppsol.forget()
|
|
251
253
|
self.ppsol = None
|
|
252
254
|
|
|
253
|
-
def get_activity_coefficient(self, solution, solute):
|
|
255
|
+
def get_activity_coefficient(self, solution: "Solution", solute: str):
|
|
254
256
|
r"""
|
|
255
257
|
Whenever the appropriate parameters are available, the Pitzer model [may]_ is used.
|
|
256
258
|
If no Pitzer parameters are available, then the appropriate equations are selected
|
|
@@ -329,7 +331,7 @@ class NativeEOS(EOS):
|
|
|
329
331
|
|
|
330
332
|
# show an error if no salt can be found that contains the solute
|
|
331
333
|
if salt is None:
|
|
332
|
-
logger.error("No salts found that contain solute
|
|
334
|
+
logger.error(f"No salts found that contain solute {solute}. Returning unit activity coefficient.")
|
|
333
335
|
return ureg.Quantity(1, "dimensionless")
|
|
334
336
|
|
|
335
337
|
# use the Pitzer model for higher ionic strength, if the parameters are available
|
|
@@ -344,14 +346,14 @@ class NativeEOS(EOS):
|
|
|
344
346
|
# alpha1 and alpha2 based on charge
|
|
345
347
|
if salt.nu_cation >= 2 and salt.nu_anion <= -2:
|
|
346
348
|
if salt.nu_cation >= 3 or salt.nu_anion <= -3:
|
|
347
|
-
alpha1 = 2
|
|
348
|
-
alpha2 = 50
|
|
349
|
+
alpha1 = 2.0
|
|
350
|
+
alpha2 = 50.0
|
|
349
351
|
else:
|
|
350
352
|
alpha1 = 1.4
|
|
351
353
|
alpha2 = 12
|
|
352
354
|
else:
|
|
353
|
-
alpha1 = 2
|
|
354
|
-
alpha2 = 0
|
|
355
|
+
alpha1 = 2.0
|
|
356
|
+
alpha2 = 0.0
|
|
355
357
|
|
|
356
358
|
# determine the average molality of the salt
|
|
357
359
|
# this is necessary for solutions inside e.g. an ion exchange
|
|
@@ -427,7 +429,7 @@ class NativeEOS(EOS):
|
|
|
427
429
|
|
|
428
430
|
return molal
|
|
429
431
|
|
|
430
|
-
def get_osmotic_coefficient(self, solution):
|
|
432
|
+
def get_osmotic_coefficient(self, solution: "Solution") -> ureg.Quantity:
|
|
431
433
|
r"""
|
|
432
434
|
Return the *molal scale* osmotic coefficient of solute, given a Solution
|
|
433
435
|
object.
|
|
@@ -574,7 +576,7 @@ class NativeEOS(EOS):
|
|
|
574
576
|
# this means the solution is empty
|
|
575
577
|
return 1
|
|
576
578
|
|
|
577
|
-
def get_solute_volume(self, solution):
|
|
579
|
+
def get_solute_volume(self, solution: "Solution") -> ureg.Quantity:
|
|
578
580
|
"""Return the volume of the solutes."""
|
|
579
581
|
# identify the predominant salt in the solution
|
|
580
582
|
salt = solution.get_salt()
|
|
@@ -596,14 +598,14 @@ class NativeEOS(EOS):
|
|
|
596
598
|
# alpha1 and alpha2 based on charge
|
|
597
599
|
if salt.nu_cation >= 2 and salt.nu_anion >= 2:
|
|
598
600
|
if salt.nu_cation >= 3 or salt.nu_anion >= 3:
|
|
599
|
-
alpha1 = 2
|
|
600
|
-
alpha2 = 50
|
|
601
|
+
alpha1 = 2.0
|
|
602
|
+
alpha2 = 50.0
|
|
601
603
|
else:
|
|
602
604
|
alpha1 = 1.4
|
|
603
605
|
alpha2 = 12
|
|
604
606
|
else:
|
|
605
|
-
alpha1 = 2
|
|
606
|
-
alpha2 = 0
|
|
607
|
+
alpha1 = 2.0
|
|
608
|
+
alpha2 = 0.0
|
|
607
609
|
|
|
608
610
|
apparent_vol = ac.get_apparent_volume_pitzer(
|
|
609
611
|
solution.ionic_strength,
|
|
@@ -633,7 +635,7 @@ class NativeEOS(EOS):
|
|
|
633
635
|
|
|
634
636
|
pitzer_calc = True
|
|
635
637
|
|
|
636
|
-
logger.debug("Updated solution volume using Pitzer model for solute
|
|
638
|
+
logger.debug(f"Updated solution volume using Pitzer model for solute {salt.formula}")
|
|
637
639
|
|
|
638
640
|
# add the partial molar volume of any other solutes, except for water
|
|
639
641
|
# or the parent salt, which is already accounted for by the Pitzer parameters
|
|
@@ -649,7 +651,7 @@ class NativeEOS(EOS):
|
|
|
649
651
|
part_vol = solution.get_property(solute, "size.molar_volume")
|
|
650
652
|
if part_vol is not None:
|
|
651
653
|
solute_vol += part_vol * ureg.Quantity(mol, "mol")
|
|
652
|
-
logger.debug("Updated solution volume using direct partial molar volume for solute
|
|
654
|
+
logger.debug(f"Updated solution volume using direct partial molar volume for solute {solute}")
|
|
653
655
|
|
|
654
656
|
else:
|
|
655
657
|
logger.warning(
|
|
@@ -658,7 +660,7 @@ class NativeEOS(EOS):
|
|
|
658
660
|
|
|
659
661
|
return solute_vol.to("L")
|
|
660
662
|
|
|
661
|
-
def equilibrate(self, solution):
|
|
663
|
+
def equilibrate(self, solution: "Solution") -> None:
|
|
662
664
|
"""Adjust the speciation of a Solution object to achieve chemical equilibrium."""
|
|
663
665
|
if self.ppsol is not None:
|
|
664
666
|
self.ppsol.forget()
|
|
@@ -693,18 +695,12 @@ class NativeEOS(EOS):
|
|
|
693
695
|
if charge_adjust != 0:
|
|
694
696
|
logger.warning(
|
|
695
697
|
"After equilibration, the charge balance of the solution was not electroneutral."
|
|
696
|
-
f" {charge_adjust} eq of charge were added via {solution.
|
|
698
|
+
f" {charge_adjust} eq of charge were added via {solution._cb_species}"
|
|
697
699
|
)
|
|
698
700
|
|
|
699
|
-
if solution.balance_charge is None:
|
|
700
|
-
|
|
701
|
-
|
|
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")
|
|
701
|
+
if solution.balance_charge is not None:
|
|
702
|
+
z = solution.get_property(solution._cb_species, "charge")
|
|
703
|
+
solution.add_amount(solution._cb_species, f"{charge_adjust/z} mol")
|
|
708
704
|
|
|
709
705
|
# rescale the solvent mass to ensure the total mass of solution does not change
|
|
710
706
|
# this is important because PHREEQC and the pyEQL database may use slightly different molecular
|
|
@@ -734,7 +730,7 @@ class PhreeqcEOS(NativeEOS):
|
|
|
734
730
|
def __init__(
|
|
735
731
|
self,
|
|
736
732
|
phreeqc_db: Literal[
|
|
737
|
-
"vitens.dat", "wateq4f_PWN.dat", "pitzer.dat", "llnl.dat", "geothermal.dat"
|
|
733
|
+
"phreeqc.dat", "vitens.dat", "wateq4f_PWN.dat", "pitzer.dat", "llnl.dat", "geothermal.dat"
|
|
738
734
|
] = "phreeqc.dat",
|
|
739
735
|
) -> None:
|
|
740
736
|
"""
|
|
@@ -751,12 +747,12 @@ class PhreeqcEOS(NativeEOS):
|
|
|
751
747
|
"""
|
|
752
748
|
super().__init__(phreeqc_db=phreeqc_db)
|
|
753
749
|
|
|
754
|
-
def get_activity_coefficient(self, solution, solute):
|
|
750
|
+
def get_activity_coefficient(self, solution: "Solution", solute: str) -> ureg.Quantity:
|
|
755
751
|
"""
|
|
756
752
|
Return the *molal scale* activity coefficient of solute, given a Solution
|
|
757
753
|
object.
|
|
758
754
|
"""
|
|
759
|
-
if self.ppsol is None or solution.components != self._stored_comp:
|
|
755
|
+
if (self.ppsol is None) or (solution.components != self._stored_comp):
|
|
760
756
|
self._destroy_ppsol()
|
|
761
757
|
self._setup_ppsol(solution)
|
|
762
758
|
|
|
@@ -775,7 +771,7 @@ class PhreeqcEOS(NativeEOS):
|
|
|
775
771
|
|
|
776
772
|
return ureg.Quantity(act, "dimensionless")
|
|
777
773
|
|
|
778
|
-
def get_osmotic_coefficient(self, solution):
|
|
774
|
+
def get_osmotic_coefficient(self, solution: "Solution") -> ureg.Quantity:
|
|
779
775
|
"""
|
|
780
776
|
Return the *molal scale* osmotic coefficient of solute, given a Solution
|
|
781
777
|
object.
|
|
@@ -787,7 +783,7 @@ class PhreeqcEOS(NativeEOS):
|
|
|
787
783
|
# TODO - find a way to access or calculate osmotic coefficient
|
|
788
784
|
return ureg.Quantity(1, "dimensionless")
|
|
789
785
|
|
|
790
|
-
def get_solute_volume(self, solution):
|
|
786
|
+
def get_solute_volume(self, solution: "Solution") -> ureg.Quantity:
|
|
791
787
|
"""Return the volume of the solutes."""
|
|
792
788
|
# TODO - phreeqc seems to have no concept of volume, but it does calculate density
|
|
793
789
|
return ureg.Quantity(0, "L")
|
pyEQL/solution.py
CHANGED
|
@@ -92,11 +92,15 @@ class Solution(MSONable):
|
|
|
92
92
|
-7 to +14. The default value corresponds to a pE value typical of natural
|
|
93
93
|
waters in equilibrium with the atmosphere.
|
|
94
94
|
balance_charge: The strategy for balancing charge during init and equilibrium calculations. Valid options
|
|
95
|
-
are
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
are
|
|
96
|
+
- 'pH', which will adjust the solution pH to balance charge,
|
|
97
|
+
- 'auto' which will use the majority cation or anion (i.e., that with the largest concentration)
|
|
98
|
+
as needed,
|
|
99
|
+
- 'pE' (not currently implemented) which will adjust the redox equilibrium to balance charge, or
|
|
100
|
+
the name of a dissolved species e.g. 'Ca+2' or 'Cl-' that will be added/subtracted to balance
|
|
101
|
+
charge.
|
|
102
|
+
- None (default), in which case no charge balancing will be performed either on init or when
|
|
103
|
+
equilibrate() is called. Note that in this case, equilibrate() can distort the charge balance!
|
|
100
104
|
solvent: Formula of the solvent. Solvents other than water are not supported at this time.
|
|
101
105
|
engine: Electrolyte modeling engine to use. See documentation for details on the available engines.
|
|
102
106
|
database: path to a .json file (str or Path) or maggma Store instance that
|
|
@@ -171,7 +175,7 @@ class Solution(MSONable):
|
|
|
171
175
|
self._pE = pE
|
|
172
176
|
self._pH = pH
|
|
173
177
|
self.pE = self._pE
|
|
174
|
-
if isinstance(balance_charge, str) and balance_charge not in ["pH", "pE"]:
|
|
178
|
+
if isinstance(balance_charge, str) and balance_charge not in ["pH", "pE", "auto"]:
|
|
175
179
|
self.balance_charge = standardize_formula(balance_charge)
|
|
176
180
|
else:
|
|
177
181
|
self.balance_charge = balance_charge #: Standardized formula of the species used for charge balancing.
|
|
@@ -257,33 +261,44 @@ class Solution(MSONable):
|
|
|
257
261
|
for item in self._solutes:
|
|
258
262
|
self.add_solute(*item)
|
|
259
263
|
|
|
260
|
-
#
|
|
264
|
+
# determine the species that will be used for charge balancing, when needed.
|
|
265
|
+
# this is necessary to do even if the composition is already electroneutral,
|
|
266
|
+
# because the appropriate species also needs to be passed to equilibrate
|
|
267
|
+
# to keep from distorting the charge balance.
|
|
261
268
|
cb = self.charge_balance
|
|
269
|
+
if self.balance_charge is None:
|
|
270
|
+
self._cb_species = None
|
|
271
|
+
elif self.balance_charge == "pH":
|
|
272
|
+
self._cb_species = "H[+1]"
|
|
273
|
+
elif self.balance_charge == "pE":
|
|
274
|
+
raise NotImplementedError("Balancing charge via redox (pE) is not yet implemented!")
|
|
275
|
+
elif self.balance_charge == "auto":
|
|
276
|
+
# add the most abundant ion of the opposite charge
|
|
277
|
+
if cb <= 0:
|
|
278
|
+
self._cb_species = max(self.cations, key=self.cations.get)
|
|
279
|
+
elif cb > 0:
|
|
280
|
+
self._cb_species = max(self.anions, key=self.anions.get)
|
|
281
|
+
else:
|
|
282
|
+
ions = set().union(*[self.cations, self.anions]) # all ions
|
|
283
|
+
self._cb_species = self.balance_charge
|
|
284
|
+
if self._cb_species not in ions:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
f"Charge balancing species {self._cb_species} was not found in the solution!. "
|
|
287
|
+
f"Species {ions} were found."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# adjust charge balance, if necessary
|
|
262
291
|
if not np.isclose(cb, 0, atol=1e-8) and self.balance_charge is not None:
|
|
263
292
|
balanced = False
|
|
264
293
|
self.logger.info(
|
|
265
|
-
f"Solution is not electroneutral (C.B. = {cb} eq/L). Adding {
|
|
294
|
+
f"Solution is not electroneutral (C.B. = {cb} eq/L). Adding {self._cb_species} to compensate."
|
|
266
295
|
)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
) # if C.B. is negative, we need to add cations. H+ is 1 eq/mol
|
|
296
|
+
z = self.get_property(self._cb_species, "charge")
|
|
297
|
+
self.components[self._cb_species] += -1 * cb / z * self.volume.to("L").magnitude
|
|
298
|
+
if np.isclose(self.charge_balance, 0, atol=1e-8):
|
|
271
299
|
balanced = True
|
|
272
|
-
elif self.balance_charge == "pE":
|
|
273
|
-
raise NotImplementedError("Balancing charge via redox (pE) is not yet implemented!")
|
|
274
|
-
else:
|
|
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
|
|
284
|
-
|
|
285
300
|
if not balanced:
|
|
286
|
-
warnings.warn(f"Unable to balance charge using species {self.
|
|
301
|
+
warnings.warn(f"Unable to balance charge using species {self._cb_species}")
|
|
287
302
|
|
|
288
303
|
@property
|
|
289
304
|
def mass(self) -> Quantity:
|
|
@@ -1282,81 +1297,12 @@ class Solution(MSONable):
|
|
|
1282
1297
|
Returns:
|
|
1283
1298
|
Nothing. The concentration of solute is modified.
|
|
1284
1299
|
"""
|
|
1285
|
-
#
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
):
|
|
1292
|
-
# store the original volume for later
|
|
1293
|
-
orig_volume = self.volume
|
|
1294
|
-
|
|
1295
|
-
# change the amount of the solute present to match the desired amount
|
|
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
|
|
1306
|
-
)
|
|
1307
|
-
|
|
1308
|
-
# set the amount to zero and log a warning if the desired amount
|
|
1309
|
-
# change would result in a negative concentration
|
|
1310
|
-
if self.get_amount(solute, "mol").magnitude < 0:
|
|
1311
|
-
self.logger.error(
|
|
1312
|
-
"Attempted to set a negative concentration for solute %s. Concentration set to 0" % solute
|
|
1313
|
-
)
|
|
1314
|
-
self.set_amount(solute, "0 mol")
|
|
1315
|
-
|
|
1316
|
-
# calculate the volume occupied by all the solutes
|
|
1317
|
-
solute_vol = self._get_solute_volume()
|
|
1318
|
-
|
|
1319
|
-
# determine the volume of solvent that will preserve the original volume
|
|
1320
|
-
target_vol = orig_volume - solute_vol
|
|
1321
|
-
|
|
1322
|
-
# adjust the amount of solvent
|
|
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")
|
|
1327
|
-
target_mol = target_mass / mw
|
|
1328
|
-
self.components[self.solvent] = target_mol.magnitude
|
|
1329
|
-
|
|
1330
|
-
else:
|
|
1331
|
-
# change the amount of the solute present
|
|
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
|
|
1342
|
-
)
|
|
1343
|
-
|
|
1344
|
-
# set the amount to zero and log a warning if the desired amount
|
|
1345
|
-
# change would result in a negative concentration
|
|
1346
|
-
if self.get_amount(solute, "mol").magnitude < 0:
|
|
1347
|
-
self.logger.error(
|
|
1348
|
-
"Attempted to set a negative concentration for solute %s. Concentration set to 0" % solute
|
|
1349
|
-
)
|
|
1350
|
-
self.set_amount(solute, "0 mol")
|
|
1351
|
-
|
|
1352
|
-
# update the volume to account for the space occupied by all the solutes
|
|
1353
|
-
# make sure that there is still solvent present in the first place
|
|
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
|
|
1300
|
+
# Get the current amount of the solute
|
|
1301
|
+
current_amt = self.get_amount(solute, amount.split(" ")[1])
|
|
1302
|
+
if current_amt.magnitude == 0:
|
|
1303
|
+
self.logger.warning(f"Add new solute {solute} to the solution")
|
|
1304
|
+
new_amt = ureg.Quantity(amount) + current_amt
|
|
1305
|
+
self.set_amount(solute, new_amt)
|
|
1360
1306
|
|
|
1361
1307
|
def set_amount(self, solute: str, amount: str):
|
|
1362
1308
|
"""
|
|
@@ -2465,7 +2411,7 @@ class Solution(MSONable):
|
|
|
2465
2411
|
"""
|
|
2466
2412
|
str_filename = str(filename)
|
|
2467
2413
|
if not ("yaml" in str_filename.lower() or "json" in str_filename.lower()):
|
|
2468
|
-
self.logger.error("Invalid file extension entered -
|
|
2414
|
+
self.logger.error("Invalid file extension entered - {str_filename}")
|
|
2469
2415
|
raise ValueError("File extension must be .json or .yaml")
|
|
2470
2416
|
if "yaml" in str_filename.lower():
|
|
2471
2417
|
solution_dict = self.as_dict()
|
pyEQL/utils.py
CHANGED
|
@@ -60,6 +60,18 @@ def standardize_formula(formula: str):
|
|
|
60
60
|
be enclosed in square brackets to remove any ambiguity in the meaning of the formula. For example, 'Na+',
|
|
61
61
|
'Na+1', and 'Na[+]' will all standardize to "Na[+1]"
|
|
62
62
|
"""
|
|
63
|
+
# fix permuted sign and charge number (e.g. Co2+)
|
|
64
|
+
for str, rep in zip(["²⁺", "³⁺", "⁴⁺", "²⁻", "³⁻", "⁴⁻"], ["+2", "+3", "+4", "-2", "-3", "-4"]):
|
|
65
|
+
formula = formula.replace(str, rep)
|
|
66
|
+
|
|
67
|
+
# replace superscripts with non superscripts
|
|
68
|
+
for char, rep in zip("⁻⁺⁰¹²³⁴⁵⁶⁷⁸⁹", "-+0123456789"):
|
|
69
|
+
formula = formula.replace(char, rep)
|
|
70
|
+
|
|
71
|
+
# replace subscripts with non subscripts
|
|
72
|
+
for char, rep in zip("₀₁₂₃₄₅₆₇₈₉", "0123456789"):
|
|
73
|
+
formula = formula.replace(char, rep)
|
|
74
|
+
|
|
63
75
|
sform = Ion.from_formula(formula).reduced_formula
|
|
64
76
|
|
|
65
77
|
# TODO - manual formula adjustments. May be implemented upstream in pymatgen in the future
|
|
@@ -81,15 +93,49 @@ def standardize_formula(formula: str):
|
|
|
81
93
|
# thiocyanate
|
|
82
94
|
elif sform == "CSN[-1]":
|
|
83
95
|
sform = "SCN[-1]"
|
|
84
|
-
# triiodide
|
|
96
|
+
# triiodide, nitride, an phosphide
|
|
85
97
|
elif sform == "I[-0.33333333]":
|
|
86
98
|
sform = "I3[-1]"
|
|
99
|
+
elif sform == "N[-0.33333333]":
|
|
100
|
+
sform = "N3[-1]"
|
|
101
|
+
elif sform == "P[-0.33333333]":
|
|
102
|
+
sform = "P3[-1]"
|
|
87
103
|
# formate
|
|
88
104
|
elif sform == "HCOO[-1]":
|
|
89
105
|
sform = "HCO2[-1]"
|
|
90
106
|
# oxalate
|
|
91
107
|
elif sform == "CO2[-1]":
|
|
92
108
|
sform = "C2O4[-2]"
|
|
109
|
+
# triflate
|
|
110
|
+
elif sform == "CS(OF)3[-1]":
|
|
111
|
+
sform = "CF3SO3[-1]"
|
|
112
|
+
# haloacetic acids of F, Cl, Br, I
|
|
113
|
+
elif sform == "C2Cl3O2[-1]":
|
|
114
|
+
sform = "CCl3COO[-1]"
|
|
115
|
+
elif sform == "C2O2F3[-1]":
|
|
116
|
+
sform = "CF3COO[-1]"
|
|
117
|
+
elif sform == "C2I3O2[-1]":
|
|
118
|
+
sform = "CI3COO[-1]"
|
|
119
|
+
elif sform == "C2Br3O2[-1]":
|
|
120
|
+
sform = "CBr3COO[-1]"
|
|
121
|
+
|
|
122
|
+
# Cl+F
|
|
123
|
+
elif sform == "C2Cl2O2F[-1]":
|
|
124
|
+
sform = "CFCl2COO[-1]"
|
|
125
|
+
elif sform == "C2Cl(OF)2[-1]":
|
|
126
|
+
sform = "CF2ClCOO[-1]"
|
|
127
|
+
|
|
128
|
+
# Cl+Br
|
|
129
|
+
elif sform == "C2Br(ClO)2[-1]":
|
|
130
|
+
sform = "CBrCl2COO[-1]"
|
|
131
|
+
elif sform == "C2Br2ClO2[-1]":
|
|
132
|
+
sform = "CBr2ClCOO[-1]"
|
|
133
|
+
|
|
134
|
+
# Cl+I
|
|
135
|
+
elif sform == "C2I(ClO)2[-1]":
|
|
136
|
+
sform = "CICl2COO[-1]"
|
|
137
|
+
elif sform == "C2I2ClO2[-1]":
|
|
138
|
+
sform = "CI2ClCOO[-1]"
|
|
93
139
|
|
|
94
140
|
# TODO - consider adding recognition of special formulas like MeOH for methanol or Cit for citrate
|
|
95
141
|
return sform
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pyEQL
|
|
3
|
-
Version: 1.1.
|
|
4
|
-
Summary:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
Version: 1.1.2
|
|
4
|
+
Summary: A python interface for solution chemistry
|
|
5
|
+
Author-email: Ryan Kingsbury <kingsbury@princeton.edu>
|
|
6
|
+
Project-URL: Docs, https://pyeql.readthedocs.io/
|
|
7
|
+
Project-URL: Repo, https://github.com/KingsburyLab/pyEQL
|
|
8
|
+
Project-URL: Package, https://pypi.org/project/pyEQL
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
14
|
Classifier: Development Status :: 4 - Beta
|
|
12
|
-
Classifier: Programming Language :: Python
|
|
13
15
|
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
14
17
|
Classifier: Topic :: Scientific/Engineering
|
|
15
|
-
Classifier: Topic :: Software Development :: Libraries
|
|
16
18
|
Requires-Python: >=3.9
|
|
17
|
-
Description-Content-Type: text/markdown
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
18
20
|
License-File: LICENSE.txt
|
|
19
21
|
License-File: COPYING
|
|
20
22
|
License-File: AUTHORS.md
|
|
@@ -23,7 +25,7 @@ Requires-Dist: numpy <2
|
|
|
23
25
|
Requires-Dist: scipy
|
|
24
26
|
Requires-Dist: pymatgen ==2024.5.1
|
|
25
27
|
Requires-Dist: iapws
|
|
26
|
-
Requires-Dist: monty
|
|
28
|
+
Requires-Dist: monty >=2024.7.12
|
|
27
29
|
Requires-Dist: maggma >=0.67.0
|
|
28
30
|
Requires-Dist: phreeqpython
|
|
29
31
|
Provides-Extra: docs
|
|
@@ -1,27 +1,26 @@
|
|
|
1
|
-
pyEQL/__init__.py,sha256=
|
|
1
|
+
pyEQL/__init__.py,sha256=OCp_PiQEPyVoi1VX0ursBzHJWN6nDS1Id6bTBOgqCYs,1999
|
|
2
2
|
pyEQL/activity_correction.py,sha256=eOixjgTd5hTrTRD5s6aPCCG12lAIH7-lRN0Z1qHu678,37151
|
|
3
|
-
pyEQL/engines.py,sha256=
|
|
3
|
+
pyEQL/engines.py,sha256=kE4ZtV2Z-zhOwtkH0cbv95oG1CETJmqMOoT0UfDuJeo,35221
|
|
4
4
|
pyEQL/equilibrium.py,sha256=YCtoAJSgn1WC9NJnc3H4FTJdKQvogsvCuj7HqlKMtww,8307
|
|
5
5
|
pyEQL/functions.py,sha256=nc-Hc61MmW-ELBR1PByJvQnELxM7PZexMHbU_O5-Bnw,10584
|
|
6
|
-
pyEQL/pint_custom_units.txt,sha256=XHmcMlwVvqF9nEW7_e9Xgyq-xWEr-cDYqieas11T3eY,2882
|
|
7
6
|
pyEQL/salt_ion_match.py,sha256=0nCZXmeo67VqcyYWQpPx-81hjSvnsg8HFB3fIyfjW_k,4070
|
|
8
7
|
pyEQL/solute.py,sha256=no00Rc3tRfHmyht4wm2UXA1KZhKC45tWMO5QEkZY6yg,5140
|
|
9
|
-
pyEQL/solution.py,sha256=
|
|
10
|
-
pyEQL/utils.py,sha256=
|
|
8
|
+
pyEQL/solution.py,sha256=vtC2xRUt1y2wTv_L1o7Qs3wr8b_X7wvkUsp56499baQ,113898
|
|
9
|
+
pyEQL/utils.py,sha256=DWLtNm71qw5j4-jqBp5v3LssEjWgJnVvI6a_H60c5ic,6670
|
|
11
10
|
pyEQL/database/geothermal.dat,sha256=kksnfcBtWdOTpNn4CLXU1Mz16cwas2WuVKpuMU8CaVI,234230
|
|
12
11
|
pyEQL/database/llnl.dat,sha256=jN-a0kfUFbQlYMn2shTVRg1JX_ZhLa-tJ0lLw2YSpLU,751462
|
|
13
12
|
pyEQL/database/phreeqc_license.txt,sha256=8W1r8VxC2kVptIMSU9sDFNASYqN7MdwKEtIWWfjTQuM,2906
|
|
14
|
-
pyEQL/database/pyeql_db.json,sha256
|
|
13
|
+
pyEQL/database/pyeql_db.json,sha256=-7Z8tpAddXhPlvpxms7cFQKL_DSSbemzOzxm6L5vaVk,1080801
|
|
15
14
|
pyEQL/presets/Ringers lactate.yaml,sha256=vtSnuvgALHR27XEjpDzC0xyw5-E6b2FSsF1EUEBiWpw,413
|
|
16
15
|
pyEQL/presets/normal saline.yaml,sha256=i2znhnIeXfNx1iMFFSif7crMRCFRP6xN1m7Wp7USduM,318
|
|
17
16
|
pyEQL/presets/rainwater.yaml,sha256=S0WHZNDfCJyjSSFxNFdkypjn2s3P0jJGCiYIxvi1ibA,337
|
|
18
17
|
pyEQL/presets/seawater.yaml,sha256=oryc1CkhRz20RpWE6uiGiT93HoZnqlB0s-0PmBWr3-U,843
|
|
19
18
|
pyEQL/presets/urine.yaml,sha256=0Njtc-H1fFRo7UhquHdiSTT4z-8VZJ1utDCk02qk28M,679
|
|
20
19
|
pyEQL/presets/wastewater.yaml,sha256=jTTFBpmKxczaEtkCZb0xUULIPZt7wfC8eAJ6rthGnmw,502
|
|
21
|
-
pyEQL-1.1.
|
|
22
|
-
pyEQL-1.1.
|
|
23
|
-
pyEQL-1.1.
|
|
24
|
-
pyEQL-1.1.
|
|
25
|
-
pyEQL-1.1.
|
|
26
|
-
pyEQL-1.1.
|
|
27
|
-
pyEQL-1.1.
|
|
20
|
+
pyEQL-1.1.2.dist-info/AUTHORS.md,sha256=K9ZLhKFwZ2zLlFXwN62VuUYCpr5T6n4mOUCUHlytTUs,415
|
|
21
|
+
pyEQL-1.1.2.dist-info/COPYING,sha256=Ww2oUywfFTn242v9ksCgQdIVSpcMXJiKKePn0GFm25E,7649
|
|
22
|
+
pyEQL-1.1.2.dist-info/LICENSE.txt,sha256=2Zf1F7RzbpeposgIxUydpurqNCMoMgDi2gAB65_GjwQ,969
|
|
23
|
+
pyEQL-1.1.2.dist-info/METADATA,sha256=1mxAOqZ5yuBRO4iDo2RZN7gpAcJMXYOfY_KKFPFMzQQ,6096
|
|
24
|
+
pyEQL-1.1.2.dist-info/WHEEL,sha256=Wyh-_nZ0DJYolHNn1_hMa4lM7uDedD_RGVwbmTjyItk,91
|
|
25
|
+
pyEQL-1.1.2.dist-info/top_level.txt,sha256=QMOaZjCAm_lS4Njsjh4L0B5aWnJFGQMYKhuH88CG1co,6
|
|
26
|
+
pyEQL-1.1.2.dist-info/RECORD,,
|
pyEQL/pint_custom_units.txt
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# Units definition file for pint library
|
|
2
|
-
|
|
3
|
-
# This file defines additional units and contexts to enable the pint
|
|
4
|
-
# library to process solution chemistry units such as mol/L and mol/kg.
|
|
5
|
-
|
|
6
|
-
@context(mw=0,volume=0,solvent_mass=0) chemistry = chem
|
|
7
|
-
# mw is the molecular weight of the species
|
|
8
|
-
# volume is the volume of the solution
|
|
9
|
-
# solvent_mass is the mass of solvent in the solution
|
|
10
|
-
|
|
11
|
-
# moles -> mass require the molecular weight
|
|
12
|
-
[substance] -> [mass]: value * mw
|
|
13
|
-
[mass] -> [substance]: value / mw
|
|
14
|
-
|
|
15
|
-
# moles/volume -> mass/volume and moles/mass -> mass / mass
|
|
16
|
-
# require the molecular weight
|
|
17
|
-
[substance] / [volume] -> [mass] / [volume]: value * mw
|
|
18
|
-
[mass] / [volume] -> [substance] / [volume]: value / mw
|
|
19
|
-
[substance] / [mass] -> [mass] / [mass]: value * mw
|
|
20
|
-
[mass] / [mass] -> [substance] / [mass]: value / mw
|
|
21
|
-
|
|
22
|
-
# moles/volume -> moles requires the solution volume
|
|
23
|
-
[substance] / [volume] -> [substance]: value * volume
|
|
24
|
-
[substance] -> [substance] / [volume]: value / volume
|
|
25
|
-
|
|
26
|
-
# moles/mass -> moles requires the solvent (usually water) mass
|
|
27
|
-
[substance] / [mass] -> [substance]: value * solvent_mass
|
|
28
|
-
[substance] -> [substance] / [mass]: value / solvent_mass
|
|
29
|
-
|
|
30
|
-
# moles/mass -> moles/volume require the solvent mass and the volume
|
|
31
|
-
[substance] / [mass] -> [substance]/[volume]: value * solvent_mass / volume
|
|
32
|
-
[substance] / [volume] -> [substance] / [mass]: value / solvent_mass * volume
|
|
33
|
-
|
|
34
|
-
@end
|
|
35
|
-
|
|
36
|
-
@context electricity = elec
|
|
37
|
-
[length] ** 2 * [mass] / [current] ** 2 / [time] ** 3 <-> [length] ** -2 * [mass] **-1 / [current] ** -2 / [time] ** -3: 1 / value
|
|
38
|
-
@end
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
#From the pint documentation:
|
|
42
|
-
|
|
43
|
-
#@context(n=1) spectroscopy = sp
|
|
44
|
-
# # n index of refraction of the medium.
|
|
45
|
-
# [length] <-> [frequency]: speed_of_light / n / value
|
|
46
|
-
# [frequency] -> [energy]: planck_constant * value
|
|
47
|
-
# [energy] -> [frequency]: value / planck_constant
|
|
48
|
-
#@end
|
|
49
|
-
|
|
50
|
-
# The @context directive indicates the beginning of the transformations which are finished by the @end statement. You can optionally specify parameters for the context in parenthesis. All parameters are named and default values are mandatory. Multiple parameters are separated by commas (like in a python function definition). Finally, you provide the name of the context (e.g. spectroscopy) and, optionally, a short version of the name (e.g. sp) separated by an equal sign.
|
|
51
|
-
# Conversions rules are specified by providing source and destination dimensions separated using a colon (:) from the equation. A special variable named value will be replaced by the source quantity. Other names will be looked first in the context arguments and then in registry.
|
|
52
|
-
# A single forward arrow (->) indicates that the equations is used to transform from the first dimension to the second one. A double arrow (<->) is used to indicate that the transformation operates both ways.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|