Skip to content

Commit 9291d27

Browse files
authored
Merge pull request #154 from KingsburyLab/bugfix
Solution: fix pH charge balancing bug and possible water ion imbalance
2 parents 1612550 + 0e0ee6a commit 9291d27

File tree

4 files changed

+102
-32
lines changed

4 files changed

+102
-32
lines changed

CHANGELOG.md

+14
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.3] - 2024-07-28
9+
10+
### Fixed
11+
12+
- `Solution`: Fix a bug in which setting `balance_charge` to `pH` could result in
13+
negative concentration errors when charge balancing or after `equilibrate` was
14+
called. `Solution` now correctly enforces the ion product of water (Kw=1e-14)
15+
whenever adjusting the amounts of H+ or OH- for charge balancing.
16+
17+
### Added
18+
19+
- `Solution._adjust_charge_balance`: Added a privat helper method to consolidate charge
20+
balancing code used in `__init__` and `equilibrate`.
21+
822
## [1.1.2] - 2024-07-28
923

1024
### Fixed

src/pyEQL/engines.py

+2-14
Original file line numberDiff line numberDiff line change
@@ -687,20 +687,8 @@ def equilibrate(self, solution: "Solution") -> None:
687687
)
688688

689689
# re-adjust charge balance for any missing species
690-
# note that if balance_charge is set, it will have been passed to PHREEQC, so we only need to adjust
691-
# for any missing species here.
692-
charge_adjust = 0
693-
for s in missing_species:
694-
charge_adjust += -1 * solution.get_amount(s, "eq").magnitude
695-
if charge_adjust != 0:
696-
logger.warning(
697-
"After equilibration, the charge balance of the solution was not electroneutral."
698-
f" {charge_adjust} eq of charge were added via {solution._cb_species}"
699-
)
700-
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")
690+
# note that if balance_charge is set, it will have been passed to PHREEQC, so the only reason to re-adjust charge balance here is to account for any missing species.
691+
solution._adjust_charge_balance()
704692

705693
# rescale the solvent mass to ensure the total mass of solution does not change
706694
# this is important because PHREEQC and the pyEQL database may use slightly different molecular

src/pyEQL/solution.py

+54-12
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
EQUIV_WT_CACO3 = ureg.Quantity(100.09 / 2, "g/mol")
3636
# string to denote unknown oxidation states
3737
UNKNOWN_OXI_STATE = "unk"
38+
K_W = 1e-14 # ion product of water at 25 degC
3839

3940

4041
class Solution(MSONable):
@@ -242,7 +243,7 @@ def __init__(
242243

243244
# set the pH with H+ and OH-
244245
self.add_solute("H+", str(10 ** (-1 * pH)) + "mol/L")
245-
self.add_solute("OH-", str(10 ** (-1 * (14 - pH))) + "mol/L")
246+
self.add_solute("OH-", str(K_W / (10 ** (-1 * pH))) + "mol/L")
246247

247248
# populate the other solutes
248249
self._solutes = solutes
@@ -288,17 +289,7 @@ def __init__(
288289
)
289290

290291
# adjust charge balance, if necessary
291-
if not np.isclose(cb, 0, atol=1e-8) and self.balance_charge is not None:
292-
balanced = False
293-
self.logger.info(
294-
f"Solution is not electroneutral (C.B. = {cb} eq/L). Adding {self._cb_species} to compensate."
295-
)
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):
299-
balanced = True
300-
if not balanced:
301-
warnings.warn(f"Unable to balance charge using species {self._cb_species}")
292+
self._adjust_charge_balance()
302293

303294
@property
304295
def mass(self) -> Quantity:
@@ -2293,6 +2284,57 @@ def get_lattice_distance(self, solute: str) -> Quantity:
22932284

22942285
return distance.to("nm")
22952286

2287+
def _adjust_charge_balance(self, atol=1e-8) -> None:
2288+
"""Helper method to adjust the charge balance of the Solution."""
2289+
cb = self.charge_balance
2290+
if not np.isclose(cb, 0, atol=atol):
2291+
self.logger.info(f"Solution is not electroneutral (C.B. = {cb} eq/L).")
2292+
if self.balance_charge is None:
2293+
# Nothing to do.
2294+
self.logger.info("balance_charge is None, so no charge balancing will be performed.")
2295+
return
2296+
2297+
self.logger.info(
2298+
f"Solution is not electroneutral (C.B. = {cb} eq/L). Adjusting {self._cb_species} to compensate."
2299+
)
2300+
2301+
if self.balance_charge == "pH":
2302+
# the charge imbalance associated with the H+ / OH- system can be expressed
2303+
# as ([H+] - [OH-]) or ([H+] - K_W/[H+]). If we adjust H+, we also have to
2304+
# adjust OH- to maintain water equilibrium.
2305+
C_hplus = self.get_amount("H+", "mol/L").magnitude
2306+
start_imbalance = C_hplus - K_W / C_hplus
2307+
new_imbalance = start_imbalance - cb
2308+
# calculate the new concentration of H+ that will balance the charge
2309+
# solve H^2 - new_imbalance H - K_W = 0, so a=1, b=-new_imbalance, c=-K_W
2310+
# check b^2 - 4ac; are there any real roots?
2311+
if new_imbalance**2 - 4 * 1 * K_W < 0:
2312+
self.logger.error("Cannot balance charge by adjusting pH. The imbalance is too large.")
2313+
return
2314+
new_hplus = max(
2315+
[
2316+
(new_imbalance + np.sqrt(new_imbalance**2 + 4 * 1 * K_W)) / 2,
2317+
(new_imbalance - np.sqrt(new_imbalance**2 + 4 * 1 * K_W)) / 2,
2318+
]
2319+
)
2320+
self.set_amount("H+", f"{new_hplus} mol/L")
2321+
self.set_amount("OH-", f"{K_W/new_hplus} mol/L")
2322+
assert np.isclose(self.charge_balance, 0, atol=atol), f"{self.charge_balance}"
2323+
return
2324+
2325+
z = self.get_property(self._cb_species, "charge")
2326+
try:
2327+
self.add_amount(self._cb_species, f"{-1*cb/z} mol")
2328+
return
2329+
except ValueError:
2330+
# if the concentration is negative, it must mean there is not enough present.
2331+
# remove everything that's present and log an error.
2332+
self.components[self._cb_species] = 0
2333+
self.logger.error(
2334+
f"There is not enough {self._cb_species} present to balance the charge. Try a different species."
2335+
)
2336+
return
2337+
22962338
def _update_volume(self):
22972339
"""Recalculate the solution volume based on composition."""
22982340
self._volume = self._get_solvent_volume() + self._get_solute_volume()

tests/test_solution.py

+32-6
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,8 @@ def test_init_engines():
202202

203203

204204
def test_component_subsets(s2):
205-
assert s2.cations == {"Na[+1]": 8, "H[+1]": 2e-7}
206-
assert s2.anions == {"Cl[-1]": 8, "OH[-1]": 2e-7}
205+
assert list(s2.cations.keys()) == ["Na[+1]", "H[+1]"]
206+
assert list(s2.anions.keys()) == ["Cl[-1]", "OH[-1]"]
207207
assert list(s2.neutrals.keys()) == ["H2O(aq)"]
208208

209209

@@ -285,6 +285,32 @@ def test_charge_balance(s3, s5, s5_pH, s6, s6_Ca):
285285
assert s._cb_species == "Cl[-1]"
286286
assert np.isclose(s.charge_balance, 0, atol=1e-8)
287287

288+
# check 'pH' when the solution needs to be made more POSITIVE
289+
s = Solution({"Na+": "2 mM", "Cl-": "1 mM"}, balance_charge="pH", pH=4)
290+
assert s.balance_charge == "pH"
291+
assert s._cb_species == "H[+1]"
292+
assert np.isclose(s.charge_balance, 0, atol=1e-8)
293+
assert s.pH > 4
294+
s.equilibrate()
295+
assert s.balance_charge == "pH"
296+
assert s._cb_species == "H[+1]"
297+
assert np.isclose(s.charge_balance, 0, atol=1e-8)
298+
299+
# check 'pH' when the imbalance is extreme
300+
s = Solution({"Na+": "2 mM", "Cl-": "1 M"}, balance_charge="pH", pH=4)
301+
assert s.balance_charge == "pH"
302+
assert s._cb_species == "H[+1]"
303+
assert np.isclose(s.charge_balance, 0, atol=1e-8)
304+
assert np.isclose(s.pH, 0, atol=0.1)
305+
s.equilibrate()
306+
assert s.balance_charge == "pH"
307+
assert s._cb_species == "H[+1]"
308+
assert np.isclose(s.charge_balance, 0, atol=1e-8)
309+
310+
# check warning when there isn't enough to balance
311+
s = Solution({"Na+": "1 M", "K+": "2 mM", "Cl-": "2 mM"}, balance_charge="K+")
312+
assert s.get_amount("K+", "mol/L") == 0
313+
288314
# check "auto" with an electroneutral solution
289315
s = Solution({"Na+": "2 mM", "Cl-": "2 mM"}, balance_charge="auto")
290316
assert s.balance_charge == "auto"
@@ -405,16 +431,16 @@ def test_components_by_element(s1, s2):
405431
assert s1.get_components_by_element() == {
406432
"H(1.0)": [
407433
"H2O(aq)",
408-
"H[+1]",
409434
"OH[-1]",
435+
"H[+1]",
410436
],
411437
"O(-2.0)": ["H2O(aq)", "OH[-1]"],
412438
}
413439
assert s2.get_components_by_element() == {
414440
"H(1.0)": [
415441
"H2O(aq)",
416-
"H[+1]",
417442
"OH[-1]",
443+
"H[+1]",
418444
],
419445
"O(-2.0)": ["H2O(aq)", "OH[-1]"],
420446
"Na(1.0)": ["Na[+1]"],
@@ -498,8 +524,8 @@ def test_equilibrate(s1, s2, s5_pH):
498524
orig_solv_mass = s5_pH.solvent_mass.magnitude
499525
set(s5_pH.components.keys())
500526
s5_pH.equilibrate()
501-
assert np.isclose(s5_pH.get_total_amount("Ca", "mol").magnitude, 0.001)
502-
assert np.isclose(s5_pH.get_total_amount("C(4)", "mol").magnitude, 0.001)
527+
assert np.isclose(s5_pH.get_total_amount("Ca", "mol").magnitude, 0.001, atol=1e-7)
528+
assert np.isclose(s5_pH.get_total_amount("C(4)", "mol").magnitude, 0.001, atol=1e-7)
503529
# due to the large pH shift, water mass and density need not be perfectly conserved
504530
assert np.isclose(s5_pH.solvent_mass.magnitude, orig_solv_mass, atol=1e-3)
505531
assert np.isclose(s5_pH.density.magnitude, orig_density, atol=1e-3)

0 commit comments

Comments
 (0)