pyEQL 1.1.6__py3-none-any.whl → 1.3.0__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/solution.py CHANGED
@@ -59,7 +59,7 @@ class Solution(MSONable):
59
59
  default_diffusion_coeff: float = 1.6106e-9,
60
60
  log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = "ERROR",
61
61
  ) -> None:
62
- """
62
+ r"""
63
63
  Instantiate a Solution from a composition.
64
64
 
65
65
  Args:
@@ -88,26 +88,59 @@ class Solution(MSONable):
88
88
  Negative log of H+ activity. If omitted, the solution will be
89
89
  initialized to pH 7 (neutral) with appropriate quantities of
90
90
  H+ and OH- ions
91
- pE: the pE value (redox potential) of the solution. Lower values = more reducing,
92
- higher values = more oxidizing. At pH 7, water is stable between approximately
93
- -7 to +14. The default value corresponds to a pE value typical of natural
91
+ pE: the :math:`pe` value of the solution. :math:`pe` measures the relative abundance of electrons
92
+ analogous to how pH measures the relative abundance of protons. Specifically, :math:`pe` is defined in
93
+ terms of the activity of electrons :math:`[e^{-}]`:
94
+
95
+ .. math:: pe = - \log [e^{-}]
96
+
97
+ The relationship between the redox potential :math:`Eh` and :math:`pe` can be illustrated by considering
98
+ the general redox reaction,
99
+
100
+ .. math::
101
+
102
+ \begin{gather*}
103
+ \text{A}^x \pm ne^{-} \longrightarrow \text{A}^{x \mp n} \quad\quad
104
+ K = \frac{[\text{A}^{x \mp n}]}{[\text{A}^x][e^{-}]^{\pm n}}
105
+ \end{gather*}
106
+
107
+ Writing :math:`pe` in terms of the equilibrium constant :math:`K` and the activities,
108
+ :math:`[\text{A}^{x}]` and :math:`[\text{A}^{x \mp n}]`, we have:
109
+
110
+ .. math::
111
+
112
+ \begin{gather*}
113
+ pe = -\log[e^{-}] = \mp \frac{1}{n} \log\left(\frac{1}{K} \frac{[\text{A}^{x \mp n}]}{[\text{A}^x]}\right)
114
+ = \mp \frac{\Delta G}{nRT \ln 10} = \frac{FEh}{RT \ln 10}
115
+ \end{gather*}
116
+
117
+ Thus, the redox potential :math:`Eh` is then related to :math:`pe` via:
118
+
119
+ .. math:: Eh = 2.303 \frac{RT}{F}pe
120
+
121
+ where :math:`F` is Faraday's constant. Note that lower values of ``pE`` (and thus :math:`Eh`)
122
+ correspond to more reducing environments, while higher values = more oxidizing. At pH 7, water is stable
123
+ between approximately -7 to +14. The default value corresponds to a :math:`pe` value typical of natural
94
124
  waters in equilibrium with the atmosphere.
95
125
  balance_charge: The strategy for balancing charge during init and equilibrium calculations. Valid options
96
126
  are
127
+
97
128
  - 'pH', which will adjust the solution pH to balance charge,
98
129
  - 'auto' which will use the majority cation or anion (i.e., that with the largest concentration)
99
- as needed,
130
+ as needed,
100
131
  - 'pE' (not currently implemented) which will adjust the redox equilibrium to balance charge, or
101
- the name of a dissolved species e.g. 'Ca+2' or 'Cl-' that will be added/subtracted to balance
102
- charge.
132
+ the name of a dissolved species e.g. 'Ca+2' or 'Cl-' that will be added/subtracted to balance
133
+ charge.
103
134
  - None (default), in which case no charge balancing will be performed either on init or when
104
- equilibrate() is called. Note that in this case, equilibrate() can distort the charge balance!
135
+ equilibrate() is called. Note that in this case, equilibrate() can distort the charge balance!
136
+
105
137
  solvent: Formula of the solvent. Solvents other than water are not supported at this time.
106
138
  engine: Electrolyte modeling engine to use. See documentation for details on the available engines.
107
139
  database: path to a .json file (str or Path) or maggma Store instance that
108
140
  contains serialized SoluteDocs. `None` (default) will use the built-in pyEQL database.
109
141
  log_level: Log messages of this or higher severity will be printed to stdout. Defaults to 'ERROR', meaning
110
- that ERROR and CRITICAL messages will be shown, while WARNING, INFO, and DEBUG messages are not. If set to None, nothing will be printed.
142
+ that ERROR and CRITICAL messages will be shown, while WARNING, INFO, and DEBUG messages are not. If set
143
+ to None, nothing will be printed.
111
144
  default_diffusion_coeff: Diffusion coefficient value in m^2/s to use in
112
145
  calculations when there is no diffusion coefficient for a species in the database. This affects several
113
146
  important property calculations including conductivity and transport number, which are related to the
@@ -140,7 +173,7 @@ class Solution(MSONable):
140
173
  self.logger.handlers.clear()
141
174
  # use rich for pretty log formatting, if installed
142
175
  try:
143
- from rich.logging import RichHandler
176
+ from rich.logging import RichHandler # noqa: PLC0415
144
177
 
145
178
  sh = RichHandler(rich_tracebacks=True)
146
179
  except ImportError:
@@ -194,7 +227,7 @@ class Solution(MSONable):
194
227
  if database is None:
195
228
  # load the default database, which is a JSONStore
196
229
  db_store = IonDB
197
- elif isinstance(database, (str, Path)):
230
+ elif isinstance(database, str | Path):
198
231
  db_store = JSONStore(str(database), key="formula")
199
232
  self.logger.debug(f"Created maggma JSONStore from .json file {database}")
200
233
  else:
@@ -228,15 +261,6 @@ class Solution(MSONable):
228
261
  self.solvent = standardize_formula(solvent[0])
229
262
  """Formula of the component that is set as the solvent (currently only H2O(aq) is supported)."""
230
263
 
231
- # TODO - do I need the ability to specify the solvent mass?
232
- # # raise an error if the solvent volume has also been given
233
- # if volume_set is True:
234
- # self.logger.error(
235
- # "Solvent volume and mass cannot both be specified. Calculating volume based on solvent mass."
236
- # )
237
- # # add the solvent and the mass
238
- # self.add_solvent(self.solvent, kwargs["solvent"][1])
239
-
240
264
  # calculate the moles of solvent (water) on the density and the solution volume
241
265
  moles = self.volume.magnitude / 55.55 # molarity of pure water
242
266
  self.components["H2O"] = moles
@@ -249,9 +273,20 @@ class Solution(MSONable):
249
273
  self._solutes = solutes
250
274
  if self._solutes is None:
251
275
  self._solutes = {}
276
+
252
277
  if isinstance(self._solutes, dict):
253
278
  for k, v in self._solutes.items():
254
279
  self.add_solute(k, v)
280
+ # if user has specified H+ in solutes, check consistency with pH kwarg
281
+ if standardize_formula(k) == "H[+1]":
282
+ # if user has not specified pH (default value), override the pH argument
283
+ if self._pH == 7:
284
+ self.logger.warning(f"H[+1] = {v} found in solutes. Overriding default pH with this value.")
285
+ # if user specifies non-default pH that does not match the supplied H+, raise an error
286
+ elif not np.isclose(self.pH, self._pH, atol=1e-4):
287
+ raise ValueError(
288
+ "Cannot specify both a non-default pH and H+ at the same time. Please provide only one."
289
+ )
255
290
  elif isinstance(self._solutes, list):
256
291
  msg = (
257
292
  'List input of solutes (e.g., [["Na+", "0.5 mol/L]]) is deprecated! Use dictionary formatted input '
@@ -347,13 +382,6 @@ class Solution(MSONable):
347
382
  >>> mysol = Solution([['Na+','2 mol/L'],['Cl-','0.01 mol/L']],volume='500 mL')
348
383
  >>> print(mysol.volume)
349
384
  0.5000883925072983 l
350
- >>> mysol.list_concentrations()
351
- {'H2O': '55.508435061791985 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
352
- >>> mysol.volume = '200 mL')
353
- >>> print(mysol.volume)
354
- 0.2 l
355
- >>> mysol.list_concentrations()
356
- {'H2O': '55.50843506179199 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
357
385
 
358
386
  """
359
387
  # figure out the factor to multiply the old concentrations by
@@ -415,11 +443,11 @@ class Solution(MSONable):
415
443
  self.volume_update_required = True
416
444
 
417
445
  @property
418
- def pH(self) -> float | None:
446
+ def pH(self) -> float:
419
447
  """Return the pH of the solution."""
420
448
  return self.p("H+", activity=False)
421
449
 
422
- def p(self, solute: str, activity=True) -> float | None:
450
+ def p(self, solute: str, activity=True) -> float:
423
451
  """
424
452
  Return the negative log of the activity of solute.
425
453
 
@@ -435,19 +463,18 @@ class Solution(MSONable):
435
463
  Returns:
436
464
  Quantity
437
465
  The negative log10 of the activity (or molar concentration if
438
- activity = False) of the solute.
466
+ activity = False) of the solute. If the solute has zero concentration
467
+ then np.nan (not a number) is returned.
439
468
  """
440
469
  try:
441
- # TODO - for some reason this specific method requires the use of math.log10 rather than np.log10.
442
- # Using np.exp raises ZeroDivisionError
443
- import math
444
-
445
470
  if activity is True:
446
- return -1 * math.log10(self.get_activity(solute))
447
- return -1 * math.log10(self.get_amount(solute, "mol/L").magnitude)
448
- # if the solute has zero concentration, the log will generate a ValueError
449
- except ValueError:
450
- return 0
471
+ amt = self.get_activity(solute).magnitude
472
+ else:
473
+ amt = self.get_amount(solute, "mol/L").magnitude
474
+ return float(-1 * np.log10(amt))
475
+ # if the solute has zero or negative concentration, np.log10 raises a RuntimeWarning
476
+ except RuntimeWarning:
477
+ return np.nan
451
478
 
452
479
  @property
453
480
  def density(self) -> Quantity:
@@ -686,7 +713,7 @@ class Solution(MSONable):
686
713
 
687
714
  References:
688
715
  .. [aq] https://www.aqion.de/site/electrical-conductivity
689
- .. [hc] http://www.hydrochemistry.eu/exmpls/sc.html
716
+ .. [hc] https://www.hydrochemistry.eu/exmpls/sc.html
690
717
 
691
718
  See Also:
692
719
  :py:attr:`ionic_strength`
@@ -885,7 +912,7 @@ class Solution(MSONable):
885
912
  Returns The Debye length, in nanometers.
886
913
 
887
914
  References:
888
- .. [wk3] https://en.wikipedia.org/wiki/Debye_length#Debye_length_in_an_electrolyte
915
+ .. [wk3] https://en.wikipedia.org/wiki/Debye_length#In_an_electrolyte_solution
889
916
 
890
917
  See Also:
891
918
  :attr:`ionic_strength`
@@ -968,7 +995,7 @@ class Solution(MSONable):
968
995
  .. [sata] Sata, Toshikatsu. Ion Exchange Membranes: Preparation, Characterization, and Modification.
969
996
  Royal Society of Chemistry, 2004, p. 10.
970
997
 
971
- .. [wk] http://en.wikipedia.org/wiki/Osmotic_pressure#Derivation_of_osmotic_pressure
998
+ .. [wk] https://en.wikipedia.org/wiki/Osmotic_pressure#Derivation_of_the_van_'t_Hoff_formula
972
999
 
973
1000
  Examples:
974
1001
  >>> s1=pyEQL.Solution()
@@ -1262,15 +1289,6 @@ class Solution(MSONable):
1262
1289
  # set the volume recalculation flag
1263
1290
  self.volume_update_required = True
1264
1291
 
1265
- # TODO - deprecate this method. Solvent should be added to the dict like anything else
1266
- # and solvent_name will track which component it is.
1267
- def add_solvent(self, formula: str, amount: str):
1268
- """Same as add_solute but omits the need to pass solvent mass to pint."""
1269
- quantity = ureg.Quantity(amount)
1270
- mw = self.get_property(formula, "molecular_weight")
1271
- target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
1272
- self.components[formula] = target_mol.to("moles").magnitude
1273
-
1274
1292
  def add_amount(self, solute: str, amount: str):
1275
1293
  """
1276
1294
  Add the amount of 'solute' to the parent solution.
@@ -1473,66 +1491,81 @@ class Solution(MSONable):
1473
1491
  >>> s2.get_salt().z_cation
1474
1492
  2
1475
1493
  """
1476
- d = self.get_salt_dict()
1477
- first_key = next(iter(d.keys()))
1478
- return Salt(d[first_key]["cation"], d[first_key]["anion"])
1494
+ try:
1495
+ salt: Salt = next(d["salt"] for d in self.get_salt_dict().values())
1496
+ return salt
1497
+ except StopIteration:
1498
+ return None
1479
1499
 
1480
1500
  # TODO - modify? deprecate? make a salts property?
1481
- def get_salt_dict(self, cutoff: float = 0.01, use_totals: bool = True) -> dict[str, dict]:
1501
+ def get_salt_dict(self, cutoff: float = 1e-6, use_totals: bool = True) -> dict[str, dict[str, float | Salt]]:
1482
1502
  """
1483
- Returns a dict of salts that approximates the composition of the Solution. Like `components`, the dict is
1484
- keyed by formula and the values are the total moles present in the solution, e.g., {"NaCl(aq)": 1}. If the
1485
- Solution is pure water, the returned dict contains only 'HOH'.
1486
-
1487
- Args:
1488
- cutoff: Lowest salt concentration to consider. Analysis will stop once the concentrations of Salts being
1489
- analyzed goes below this value. Useful for excluding analysis of trace anions.
1490
- use_totals: Whether to base the analysis on total element concentrations or individual species
1491
- concentrations.
1503
+ Returns a dict that represents the salts of the Solution by pairing anions and cations.
1492
1504
 
1493
- Notes:
1494
- Salts are identified by pairing the predominant cations and anions in the solution, in descending order
1495
- of their respective equivalent amounts.
1496
-
1497
- Many empirical equations for solution properties such as activity coefficient,
1498
- partial molar volume, or viscosity are based on the concentration of
1499
- single salts (e.g., NaCl). When multiple ions are present (e.g., a solution
1500
- containing Na+, Cl-, and Mg+2), it is generally not possible to directly model
1501
- these quantities.
1505
+ The ``get_salt_dict()`` method examines the ionic composition of a solution and approximates it as a set of
1506
+ salts instead of individual ions. The method returns a dictionary of Salt objects where the keys are the salt
1507
+ formulas (e.g., 'NaCl'). The Salt object contains information about the stoichiometry of the salt to
1508
+ enable its effective concentration to be calculated (e.g., 1 M MgCl2 yields 1 M Mg+2 and 2 M Cl-).
1502
1509
 
1503
- The get_salt_dict() method examines the ionic composition of a solution and
1504
- simplifies it into a list of salts. The method returns a dictionary of
1505
- Salt objects where the keys are the salt formulas (e.g., 'NaCl'). The
1506
- Salt object contains information about the stoichiometry of the salt to
1507
- enable its effective concentration to be calculated
1508
- (e.g., 1 M MgCl2 yields 1 M Mg+2 and 2 M Cl-).
1510
+ Args:
1511
+ cutoff: Lowest molal concentration to consider. No salts below this value will be included in the output.
1512
+ Useful for excluding analysis of trace anions. Defaults to 1e-6 (1 part per million).
1513
+ use_totals: Whether or not to base the analysis on the concentration of the predominant species of each
1514
+ element. Note that species in which a given element assumes a different oxidation state are always
1515
+ treated separately.
1509
1516
 
1510
1517
  Returns:
1511
1518
  dict
1512
- A dictionary of Salt objects, keyed to the salt formula
1513
-
1514
- See Also:
1515
- :py:attr:`osmotic_pressure`
1516
- :py:attr:`viscosity_kinematic`
1517
- :py:meth:`get_activity`
1518
- :py:meth:`get_activity_coefficient`
1519
- :py:meth:`get_water_activity`
1520
- :py:meth:`get_osmotic_coefficient`
1521
- """
1522
- """
1523
- Returns a dict of salts that approximates the composition of the Solution. Like `components`, the dict is
1524
- keyed by formula and the values are the total moles of salt present in the solution, e.g., {"NaCl(aq)": 1}
1519
+ A dictionary of representing salts in the solution, keyed by the salt formula.
1525
1520
 
1526
1521
  Notes:
1527
- Salts are identified by pairing the predominant cations and anions in the solution, in descending order
1528
- of their respective equivalent amounts.
1522
+ The dict maps salt formulas to dictionaries containing their amounts and composition. The amount is stored
1523
+ in moles under the key "mol", and a :class:`pyEQL.salt_ion_match.Salt` object stored under the "salt" key
1524
+ represents the composition. Salts are identified by pairing the predominant cations and anions in the
1525
+ solution, in descending order of their respective equivalent amounts.
1526
+
1527
+ Many empirical equations for solution properties such as activity coefficient, partial molar volume, or
1528
+ viscosity are based on the concentration of single salts (e.g., NaCl). When multiple ions are present
1529
+ (e.g., a solution containing Na+, Cl-, and Mg+2), it is generally not possible to directly model
1530
+ these quantities.
1531
+
1532
+ Examples:
1533
+ >>> from pyEQL import Solution
1534
+ >>> from pyEQL.salt_ion_match import Salt
1535
+ >>> s1 = Solution(
1536
+ ... solutes={
1537
+ ... 'Na[+1]': '1 mol/L',
1538
+ ... 'Cl[-1]': '1 mol/L',
1539
+ ... 'Ca[+2]': '0.01 mol/kg',
1540
+ ... 'HCO3[-1]': '0.007 mol/kg',
1541
+ ... 'CO3[-2]': '0.001 mol/kg',
1542
+ ... 'ClO[-1]': '0.001 mol/kg',
1543
+ ... }
1544
+ ... )
1545
+ >>> salt_dict = s1.get_salt_dict()
1546
+ >>> list(salt_dict) # Only returns salts with concentrations > 1e-3 m
1547
+ ['NaCl', 'Ca(HCO3)2']
1548
+ >>> salt_dict['NaCl']['salt']
1549
+ <pyEQL.salt_ion_match.Salt object at ...>
1550
+ >>> salt_dict['NaCl']['mol']
1551
+ 1.0
1552
+ >>> salt_dict = s1.get_salt_dict(cutoff=1e-4)
1553
+ >>> list(salt_dict) # Returns 'Ca(ClO)2' because of reduced cutoff and Cl has different oxidation state
1554
+ ['NaCl', 'Ca(HCO3)2', 'Ca(ClO)2']
1555
+ >>> salt_dict = s1.get_salt_dict(cutoff=1e-4, use_totals=False)
1556
+ >>> list(salt_dict) # Returns salts with minor (same oxidation state) species since use_totals=False
1557
+ ['NaCl', 'Ca(HCO3)2', 'CaCO3', 'Ca(ClO)2']
1529
1558
 
1530
1559
  See Also:
1531
1560
  :attr:`components`
1532
1561
  :attr:`cations`
1533
1562
  :attr:`anions`
1563
+ :class:`pyEQL.salt_ion_match.Salt`
1564
+ :py:meth:`get_activity_coefficient`
1565
+ :py:meth:`get_water_activity`
1566
+ :py:meth:`get_osmotic_coefficient`
1534
1567
  """
1535
- salt_dict: dict[str, float] = {}
1568
+ salt_dict: dict[str, dict[str, float | Salt]] = {}
1536
1569
 
1537
1570
  if use_totals:
1538
1571
  # # use only the predominant species for each element
@@ -1558,7 +1591,7 @@ class Solution(MSONable):
1558
1591
  # calculate the charge-weighted (equivalent) concentration of each ion
1559
1592
  cation_equiv = {k: self.get_property(k, "charge") * components[k] for k in cations}
1560
1593
  anion_equiv = {
1561
- k: -1 * self.get_property(k, "charge") * components[k] for k in anions
1594
+ k: self.get_property(k, "charge") * components[k] * -1 for k in anions
1562
1595
  } # make sure amounts are positive
1563
1596
 
1564
1597
  # sort in descending order of equivalent concentration
@@ -1568,78 +1601,36 @@ class Solution(MSONable):
1568
1601
  len_cat = len(cation_equiv)
1569
1602
  len_an = len(anion_equiv)
1570
1603
 
1571
- # Only ions are H+ and OH-; return a Salt represnting water (with no amount)
1572
- if len_cat <= 1 and len_an <= 1 and self.solvent == "H2O(aq)":
1573
- x = Salt("H[+1]", "OH[-1]")
1574
- salt_dict.update({x.formula: x.as_dict()})
1575
- salt_dict[x.formula]["mol"] = self.get_amount("H2O", "mol")
1576
- return salt_dict
1577
-
1578
1604
  # start with the first cation and anion
1579
1605
  index_cat = 0
1580
1606
  index_an = 0
1581
1607
 
1582
- # list(dict) returns a list of [(key, value), ]
1583
- cation_list = list(cation_equiv.items())
1584
- anion_list = list(anion_equiv.items())
1585
-
1586
- # calculate the equivalent concentrations of each ion
1587
- c1 = cation_list[index_cat][-1]
1588
- a1 = anion_list[index_an][-1]
1608
+ # list(dict) returns a list of [[key, value],]
1609
+ cation_list = [[k, v] for k, v in cation_equiv.items()]
1610
+ anion_list = [[k, v] for k, v in anion_equiv.items()]
1611
+ solvent_mass = self.solvent_mass.to("kg").m
1612
+ # tolerance for detecting edge cases where equilibrate() slightly changes the
1613
+ # total amount of a solute
1614
+ _atol = 1e-16
1589
1615
 
1590
1616
  while index_cat < len_cat and index_an < len_an:
1591
- # if the cation concentration is greater, there will be leftover cations
1592
- if c1 > a1:
1593
- # create the salt
1594
- x = Salt(cation_list[index_cat][0], anion_list[index_an][0])
1595
- # there will be leftover cation, so use the anion amount
1596
- salt_dict.update({x.formula: x.as_dict()})
1597
- salt_dict[x.formula]["mol"] = a1 / abs(x.z_anion * x.nu_anion)
1598
- # adjust the amounts of the respective ions
1599
- c1 = c1 - a1
1600
- # move to the next anion
1601
- index_an += 1
1602
- try:
1603
- a1 = anion_list[index_an][-1]
1604
- if a1 < cutoff:
1605
- continue
1606
- except IndexError:
1607
- continue
1608
- # if the anion concentration is greater, there will be leftover anions
1609
- if c1 < a1:
1610
- # create the salt
1611
- x = Salt(cation_list[index_cat][0], anion_list[index_an][0])
1612
- # there will be leftover anion, so use the cation amount
1613
- salt_dict.update({x.formula: x.as_dict()})
1614
- salt_dict[x.formula]["mol"] = c1 / x.z_cation * x.nu_cation
1615
- # calculate the leftover cation amount
1616
- a1 = a1 - c1
1617
- # move to the next cation
1618
- index_cat += 1
1619
- try:
1620
- a1 = cation_list[index_cat][-1]
1621
- if a1 < cutoff:
1622
- continue
1623
- except IndexError:
1624
- continue
1625
- if np.isclose(c1, a1):
1626
- # create the salt
1627
- x = Salt(cation_list[index_cat][0], anion_list[index_an][0])
1628
- # there will be nothing leftover, so it doesn't matter which ion you use
1629
- salt_dict.update({x.formula: x.as_dict()})
1630
- salt_dict[x.formula]["mol"] = c1 / x.z_cation * x.nu_cation
1631
- # move to the next cation and anion
1632
- index_an += 1
1633
- index_cat += 1
1634
- try:
1635
- c1 = cation_list[index_cat][-1]
1636
- a1 = anion_list[index_an][-1]
1637
- if (c1 < cutoff) or (a1 < cutoff):
1638
- continue
1639
- except IndexError:
1640
- continue
1617
+ c1 = cation_list[index_cat][-1]
1618
+ a1 = anion_list[index_an][-1]
1619
+ salt = Salt(cation_list[index_cat][0], anion_list[index_an][0])
1620
+
1621
+ # Use the smaller of the two amounts
1622
+ equivs_consumed = min(c1, a1)
1623
+ cation_list[index_cat][-1] -= equivs_consumed
1624
+ anion_list[index_an][-1] -= equivs_consumed
1625
+ index_an += 1 if a1 == equivs_consumed else 0
1626
+ index_cat += 1 if c1 == equivs_consumed else 0
1627
+ mol = equivs_consumed / (salt.z_cation * salt.nu_cation)
1628
+
1629
+ # filter out water and zero, effectively zero, and sub-cutoff salt amounts
1630
+ if salt.formula != "HOH" and (mol / solvent_mass + _atol) >= cutoff:
1631
+ salt_dict[salt.formula] = {"salt": salt, "mol": mol}
1641
1632
 
1642
- return salt_dict
1633
+ return dict(sorted(salt_dict.items(), key=lambda x: x[1]["mol"], reverse=True))
1643
1634
 
1644
1635
  def equilibrate(self, **kwargs) -> None:
1645
1636
  """
@@ -1864,9 +1855,8 @@ class Solution(MSONable):
1864
1855
  anion, and water).
1865
1856
 
1866
1857
  References:
1867
- .. [koga] Koga, Yoshikata, 2007. *Solution Thermodynamics and its Application to Aqueous Solutions:
1868
- A differential approach.* Elsevier, 2007, pp. 23-37.
1869
-
1858
+ .. [koga] Koga, Yoshikata, 2007. *Solution Thermodynamics and its Application to Aqueous Solutions:*
1859
+ *A differential approach.* Elsevier, 2007, pp. 23-37.
1870
1860
  """
1871
1861
  E = ureg.Quantity(0, "J")
1872
1862
 
@@ -2100,7 +2090,7 @@ class Solution(MSONable):
2100
2090
 
2101
2091
  2. https://www.hydrochemistry.eu/exmpls/sc.html
2102
2092
 
2103
- 3. Appelo, C.A.J. Solute transport solved with the Nernst-Planck equation for concrete pores with `free'
2093
+ 3. Appelo, C.A.J. Solute transport solved with the Nernst-Planck equation for concrete pores with 'free'
2104
2094
  water and a double layer. Cement and Concrete Research 101, 2017.
2105
2095
  https://dx.doi.org/10.1016/j.cemconres.2017.08.030
2106
2096
 
@@ -2145,7 +2135,7 @@ class Solution(MSONable):
2145
2135
 
2146
2136
  .. math::
2147
2137
 
2148
- D_{\gamma} = D^0 \exp(\frac{-a1 A |z_i| \sqrt{I}}{1+\kappa a}
2138
+ D_{\gamma} = D^0 \exp(\frac{-a1 A |z_i| \sqrt{I}}{1+\kappa a})
2149
2139
 
2150
2140
  .. math::
2151
2141
 
@@ -2157,7 +2147,7 @@ class Solution(MSONable):
2157
2147
 
2158
2148
  References:
2159
2149
  1. https://www.hydrochemistry.eu/exmpls/sc.html
2160
- 2. Appelo, C.A.J. Solute transport solved with the Nernst-Planck equation for concrete pores with `free'
2150
+ 2. Appelo, C.A.J. Solute transport solved with the Nernst-Planck equation for concrete pores with 'free'
2161
2151
  water and a double layer. Cement and Concrete Research 101, 2017.
2162
2152
  https://dx.doi.org/10.1016/j.cemconres.2017.08.030
2163
2153
  3. CRC Handbook of Chemistry and Physics
@@ -2205,8 +2195,9 @@ class Solution(MSONable):
2205
2195
  a1 = 1.6
2206
2196
  a2 = 4.73
2207
2197
 
2208
- # use the PHREEQC model from Ref 2 to correct for temperature
2209
- D_final = D * np.exp(d / T_sol - d / T_ref) * mu_ref / mu
2198
+ # use the PHREEQC model from Ref 2 to correct for temperature if more than 1 degree different from T_ref
2199
+ if abs(T_sol - T_ref) > 1:
2200
+ D *= np.exp(d / T_sol - d / T_ref) * mu_ref / mu
2210
2201
 
2211
2202
  if activity_correction:
2212
2203
  A = _debye_parameter_activity(str(self.temperature)).to("kg**0.5/mol**0.5").magnitude / 2.303
@@ -2215,14 +2206,12 @@ class Solution(MSONable):
2215
2206
  IS = self.ionic_strength.magnitude
2216
2207
  kappaa = B * IS**0.5 * a2 / (1 + IS**0.75)
2217
2208
  # correct for ionic strength
2218
- D_final *= np.exp(-a1 * A * abs(z) * IS**0.5 / (1 + kappaa))
2209
+ D *= np.exp(-a1 * A * abs(z) * IS**0.5 / (1 + kappaa))
2219
2210
  # else:
2220
2211
  # # per CRC handbook, D increases by 2-3% per degree above 25 C
2221
2212
  # return D * (1 + 0.025 * (T_sol - T_ref))
2222
- else:
2223
- D_final = D
2224
2213
 
2225
- return D_final
2214
+ return D
2226
2215
 
2227
2216
  def _get_mobility(self, solute: str) -> Quantity:
2228
2217
  r"""
@@ -2314,17 +2303,17 @@ class Solution(MSONable):
2314
2303
  ]
2315
2304
  )
2316
2305
  self.set_amount("H+", f"{new_hplus} mol/L")
2317
- self.set_amount("OH-", f"{K_W/new_hplus} mol/L")
2306
+ self.set_amount("OH-", f"{K_W / new_hplus} mol/L")
2318
2307
  return
2319
2308
 
2320
2309
  z = self.get_property(self._cb_species, "charge")
2321
2310
  try:
2322
- self.add_amount(self._cb_species, f"{-1*cb/z} mol")
2311
+ self.add_amount(self._cb_species, f"{-1 * cb / z} mol")
2323
2312
  return
2324
2313
  except ValueError:
2325
2314
  # if the concentration is negative, it must mean there is not enough present.
2326
2315
  # remove everything that's present and log an error.
2327
- self.components[self._cb_species] = 0
2316
+ self.components[self._cb_species] = 0.0
2328
2317
  self.logger.error(
2329
2318
  f"There is not enough {self._cb_species} present to balance the charge. Try a different species."
2330
2319
  )
@@ -2389,7 +2378,7 @@ class Solution(MSONable):
2389
2378
  def from_preset(
2390
2379
  cls, preset: Literal["seawater", "rainwater", "wastewater", "urine", "normal saline", "Ringers lactate"]
2391
2380
  ) -> Solution:
2392
- """Instantiate a solution from a preset composition.
2381
+ r"""Instantiate a solution from a preset composition.
2393
2382
 
2394
2383
  Args:
2395
2384
  preset (str): String representing the desired solution.
@@ -2406,22 +2395,22 @@ class Solution(MSONable):
2406
2395
  The following sections explain the different solution options:
2407
2396
 
2408
2397
  - 'rainwater' - pure water in equilibrium with atmospheric CO2 at pH 6
2409
- - 'seawater' or 'SW'- Standard Seawater. See Table 4 of the Reference for Composition [1]_
2410
- - 'wastewater' or 'WW' - medium strength domestic wastewater. See Table 3-18 of [2]_
2411
- - 'urine' - typical human urine. See Table 3-15 of [2]_
2412
- - 'normal saline' or 'NS' - normal saline solution used in medicine [3]_
2413
- - 'Ringers lacatate' or 'RL' - Ringer's lactate solution used in medicine [4]_
2398
+ - 'seawater' or 'SW'- Standard Seawater. See Table 4 of the Reference for Composition [mf08]_
2399
+ - 'wastewater' or 'WW' - medium strength domestic wastewater. See Table 3-18 of [me13]_
2400
+ - 'urine' - typical human urine. See Table 3-15 of [me13]_
2401
+ - 'normal saline' or 'NS' - normal saline solution used in medicine [saline]_
2402
+ - 'Ringers lacatate' or 'RL' - Ringer's lactate solution used in medicine [lactate]_
2414
2403
 
2415
2404
  References:
2416
- .. [1] Millero, Frank J. "The composition of Standard Seawater and the definition of
2417
- the Reference-Composition Salinity Scale." *Deep-sea Research. Part I* 55(1), 2008, 50-72.
2405
+ .. [mf08] Millero, Frank J. "The composition of Standard Seawater and the definition of
2406
+ the Reference-Composition Salinity Scale." *Deep-sea Research. Part I* 55(1), 2008, 50-72.
2418
2407
 
2419
- .. [2] Metcalf & Eddy, Inc. et al. *Wastewater Engineering: Treatment and Resource Recovery*, 5th Ed.
2420
- McGraw-Hill, 2013.
2408
+ .. [me13] Metcalf & Eddy, Inc. et al. *Wastewater Engineering: Treatment and Resource Recovery*, 5th Ed.
2409
+ McGraw-Hill, 2013.
2421
2410
 
2422
- .. [3] https://en.wikipedia.org/wiki/Saline_(medicine)
2411
+ .. [saline] https://en.wikipedia.org/w/index.php?title=Saline_(medicine)&oldid=1298292693
2423
2412
 
2424
- .. [4] https://en.wikipedia.org/wiki/Ringer%27s_lactate_solution
2413
+ .. [lactate] https://en.wikipedia.org/wiki/Ringer%27s_lactate_solution
2425
2414
  """
2426
2415
  # preset_dir = files("pyEQL") / "presets"
2427
2416
  # Path to the YAML and JSON files corresponding to the preset
@@ -2550,15 +2539,9 @@ class Solution(MSONable):
2550
2539
 
2551
2540
  # retrieve the amount of each component in the parent solution and
2552
2541
  # store in a list.
2553
- mix_species = FormulaDict({})
2554
- for sol, amt in self.components.items():
2555
- mix_species.update({sol: f"{amt} mol"})
2556
- for sol2, amt2 in other.components.items():
2557
- if mix_species.get(sol2):
2558
- orig_amt = float(mix_species[sol2].split(" ")[0])
2559
- mix_species[sol2] = f"{orig_amt+amt2} mol"
2560
- else:
2561
- mix_species.update({sol2: f"{amt2} mol"})
2542
+ mix_amounts = FormulaDict({})
2543
+ for sol, amt in [*self.components.items(), *other.components.items()]:
2544
+ mix_amounts[sol] = amt + mix_amounts.get(sol, 0.0)
2562
2545
 
2563
2546
  # TODO - call equilibrate() here once the method is functional to get new pH and pE, instead of the below
2564
2547
  warnings.warn(
@@ -2566,21 +2549,27 @@ class Solution(MSONable):
2566
2549
  "this property is planned for a future release."
2567
2550
  )
2568
2551
  # calculate the new pH and pE (before reactions) by mixing
2569
- mix_pH = -np.log10(float(mix_species["H+"].split(" ")[0]) / mix_vol.to("L").magnitude)
2552
+ # for pH, we make sure to conserve the mass of H+ and OH-. By not passing
2553
+ # a kwarg for pH (i.e., by using the default value), the H+ concentration
2554
+ # will override and determine the pH value of the mixed solution.
2570
2555
 
2571
2556
  # pE = -log[e-], so calculate the moles of e- in each solution and mix them
2572
2557
  mol_e_self = 10 ** (-1 * self.pE) * self.volume.to("L").magnitude
2573
2558
  mol_e_other = 10 ** (-1 * other.pE) * other.volume.to("L").magnitude
2574
2559
  mix_pE = -np.log10((mol_e_self + mol_e_other) / mix_vol.to("L").magnitude)
2560
+ solutes = {sol: f"{amount} mol" for sol, amount in mix_amounts.items()}
2575
2561
 
2576
2562
  # create a new solution
2577
2563
  return Solution(
2578
- mix_species.data, # pass a regular dict instead of the FormulaDict
2564
+ solutes=solutes,
2579
2565
  volume=str(mix_vol),
2580
2566
  pressure=str(mix_pressure),
2581
2567
  temperature=str(mix_temperature.to("K")),
2582
- pH=mix_pH,
2568
+ # pH=7, # leave at default value so that H+ concentration determines pH
2583
2569
  pE=mix_pE,
2570
+ engine=self._engine,
2571
+ solvent=self.solvent,
2572
+ database=self.database,
2584
2573
  )
2585
2574
 
2586
2575
  def __sub__(self, other: Solution) -> None:
@@ -2620,7 +2609,7 @@ class Solution(MSONable):
2620
2609
  places: The number of decimal places to round the solute amounts.
2621
2610
  """
2622
2611
  print(self)
2623
- str1 = "Activities" if units == "activity" else "Amounts"
2612
+ str1 = "Activities" if units == "activity" else "Concentrations"
2624
2613
  str2 = f" ({units})" if units != "activity" else ""
2625
2614
  header = f"\nComponent {str1}{str2}:"
2626
2615
  print(header)
@@ -2638,7 +2627,7 @@ class Solution(MSONable):
2638
2627
 
2639
2628
  amt = self.get_activity(i).magnitude if units == "activity" else self.get_amount(i, units).magnitude
2640
2629
 
2641
- print(f"{i}:\t {amt:0.{places}f}")
2630
+ print(f"{i:<12} {amt:0.{places}f}")
2642
2631
 
2643
2632
  def __str__(self) -> str:
2644
2633
  # set output of the print() statement for the solution
@@ -2648,100 +2637,17 @@ class Solution(MSONable):
2648
2637
  l4 = f"pH: {self.pH:.1f}"
2649
2638
  l5 = f"pE: {self.pE:.1f}"
2650
2639
  l6 = f"Solvent: {self.solvent}"
2651
- l7 = f"Components: {self.list_solutes():}"
2640
+ l7 = f"Components: {self.components.keys():}"
2652
2641
  return f"{l1}\n{l2}\n{l3}\n{l4}\n{l5}\n{l6}\n{l7}"
2653
2642
 
2654
2643
  """
2655
2644
  Legacy methods to be deprecated in a future release.
2656
2645
  """
2657
2646
 
2658
- @deprecated(
2659
- message="list_salts() is deprecated and will be removed in the next release! Use Solution.get_salt_dict() instead.)"
2660
- )
2661
- def list_salts(self, unit="mol/kg", decimals=4): # pragma: no cover
2662
- for k, v in self.get_salt_dict().items():
2663
- print(k + "\t {:0.{decimals}f}".format(v, decimals=decimals))
2664
-
2665
- @deprecated(
2666
- message="list_solutes() is deprecated and will be removed in the next release! Use Solution.components.keys() instead.)"
2667
- )
2668
- def list_solutes(self): # pragma: no cover
2669
- """List all the solutes in the solution."""
2670
- return list(self.components.keys())
2671
-
2672
- @deprecated(
2673
- message="list_concentrations() is deprecated and will be removed in the next release! Use Solution.print() instead.)"
2674
- )
2675
- def list_concentrations(self, unit="mol/kg", decimals=4, type="all"): # pragma: no cover
2676
- """
2677
- List the concentration of each species in a solution.
2678
-
2679
- Parameters
2680
- ----------
2681
- unit: str
2682
- String representing the desired concentration ureg.
2683
- decimals: int
2684
- The number of decimal places to display. Defaults to 4.
2685
- type : str
2686
- The type of component to be sorted. Defaults to 'all' for all
2687
- solutes. Other valid arguments are 'cations' and 'anions' which
2688
- return lists of cations and anions, respectively.
2689
-
2690
- Returns:
2691
- -------
2692
- dict
2693
- Dictionary containing a list of the species in solution paired with their amount in the specified units
2694
- :meta private:
2695
- """
2696
- result_list = []
2697
- # populate a list with component names
2698
-
2699
- if type == "all":
2700
- print("Component Concentrations:\n")
2701
- print("========================\n")
2702
- for item in self.components:
2703
- amount = self.get_amount(item, unit)
2704
- result_list.append([item, amount])
2705
- print(item + ":" + "\t {0:0.{decimals}f~}".format(amount, decimals=decimals))
2706
- elif type == "cations":
2707
- print("Cation Concentrations:\n")
2708
- print("========================\n")
2709
- for item in self.components:
2710
- if self.components[item].charge > 0:
2711
- amount = self.get_amount(item, unit)
2712
- result_list.append([item, amount])
2713
- print(item + ":" + "\t {0:0.{decimals}f~}".format(amount, decimals=decimals))
2714
- elif type == "anions":
2715
- print("Anion Concentrations:\n")
2716
- print("========================\n")
2717
- for item in self.components:
2718
- if self.components[item].charge < 0:
2719
- amount = self.get_amount(item, unit)
2720
- result_list.append([item, amount])
2721
- print(item + ":" + "\t {0:0.{decimals}f~}".format(amount, decimals=decimals))
2722
-
2723
- return result_list
2724
-
2725
- @deprecated(
2726
- message="list_activities() is deprecated and will be removed in the next release! Use Solution.print() instead.)"
2727
- )
2728
- def list_activities(self, decimals=4): # pragma: no cover
2729
- """
2730
- List the activity of each species in a solution.
2731
-
2732
- Parameters
2733
- ----------
2734
- decimals: int
2735
- The number of decimal places to display. Defaults to 4.
2736
-
2737
- Returns:
2738
- -------
2739
- dict
2740
- Dictionary containing a list of the species in solution paired with their activity
2741
-
2742
- :meta private:
2743
- """
2744
- print("Component Activities:\n")
2745
- print("=====================\n")
2746
- for i in self.components:
2747
- print(i + ":" + "\t {0.magnitude:0.{decimals}f}".format(self.get_activity(i), decimals=decimals))
2647
+ @deprecated(message="add_solute() is deprecated. Use add_amount() instead.")
2648
+ def add_solvent(self, formula: str, amount: str): # pragma: no cover
2649
+ """Same as add_solute but omits the need to pass solvent mass to pint."""
2650
+ quantity = ureg.Quantity(amount)
2651
+ mw = self.get_property(formula, "molecular_weight")
2652
+ target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
2653
+ self.components[formula] = target_mol.to("moles").magnitude