Skip to content

Commit 1525552

Browse files
authored
Merge pull request #151 from KingsburyLab/bugfix
Solution: add automatic charge balancing and other misc improvements
2 parents a2166cc + 2afe6ef commit 1525552

File tree

9 files changed

+85
-158
lines changed

9 files changed

+85
-158
lines changed

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,26 @@ ci:
1212

1313
repos:
1414
- repo: https://github.com/astral-sh/ruff-pre-commit
15-
rev: v0.4.8
15+
rev: v0.5.5
1616
hooks:
1717
- id: ruff
1818
args: [--fix, --ignore, "D,E501", "--show-fixes"]
1919

2020
- repo: https://github.com/psf/black-pre-commit-mirror
21-
rev: 24.2.0
21+
rev: 24.4.2
2222
hooks:
2323
- id: black
2424

2525
- repo: https://github.com/codespell-project/codespell
26-
rev: v2.2.6
26+
rev: v2.3.0
2727
hooks:
2828
- id: codespell
2929
stages: [commit, commit-msg]
3030
exclude_types: [html, svg]
3131
additional_dependencies: [tomli] # needed to read pyproject.toml below py3.11
3232

3333
- repo: https://github.com/pre-commit/pre-commit-hooks
34-
rev: v4.5.0
34+
rev: v4.6.0
3535
hooks:
3636
- id: check-case-conflict
3737
- id: check-symlinks

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,29 @@ 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.0.4] - 2024-07-26
8+
## [1.1.0] - 2024-07-27
99

1010
### Fixed
1111

1212
- `equilibrate`: Fixed a bug that could cause an `AttributeError` when pH was used for charge
1313
balancing.
1414
- Database: `size.radius_ionic` was missing units for `Ni[+2]` and `Cr[+3]`. Correct units have been added.
1515

16+
### Added
17+
18+
- `Solution`: New automatic charge balancing method will automatically identify the majority (highest concentration)
19+
cation or anion as appropriate (depending on the charge balance) for charge balancing. To use this mode, set
20+
`balance_charge='auto'` when instantiating a `Solution`.
21+
1622
### Changed
1723

24+
- `Solution.add_amount`: This method will now add solutes that are absent from the Solution. Previously, calling, e.g.,
25+
`add_amount('Na+', '1 mol')` on a `Solution` that did not contain any sodium would result in an error. A warning
26+
is logged if the method has to add a new solute.
27+
- Units: use the upstream chemistry context from `pint` instead of the custom one from 2013.
28+
- `pre-commit autoupdate`
29+
- Misc. linting and code quality improvements.
30+
- Unit tests: update `tmpdir` to `tmp_path` text fixture.
1831
- CI: Small updates to pre-commit and GitHub actions per scientific python [repo review](https://scientific-python.github.io/repo-review/?repo=kingsburylab%2FpyEQL&branch=main).
1932

2033
## [1.0.3] - 2024-07-20

MANIFEST.in

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,5 @@ include README COPYING CHANGELOG LICENSE README.md README.rst README.txt CHANGES
22
recursive-include src/pyEQL/database/ *
33
recursive-include src/pyEQL/presets/ *
44
recursive-include docs/ *
5-
include src/pyEQL/pint_custom_units.txt
65
prune docs/build
76
global-exclude *.pyc *~ .DS_Store *__pycache__* *.pyo

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name = "pyEQL"
33
readme = "README.md"
44
dynamic = ["version"]
5-
description="A python interace for solution chemistry"
5+
description="A python interface for solution chemistry"
66
authors =[
77
{name = "Ryan Kingsbury", email = "kingsbury@princeton.edu"}
88
]
@@ -25,7 +25,7 @@ dependencies = [
2525
"scipy",
2626
"pymatgen==2024.5.1",
2727
"iapws",
28-
"monty",
28+
"monty>=2024.7.12",
2929
"maggma>=0.67.0",
3030
"phreeqpython",
3131
]

src/pyEQL/__init__.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,8 @@
3737
# convert "offset units" so that, e.g. Quantity('25 degC') works without error
3838
# see https://pint.readthedocs.io/en/0.22/user/nonmult.html?highlight=offset#temperature-conversion
3939
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)
4340
# activate the "chemistry" context globally
44-
ureg.enable_contexts("chem")
41+
ureg.enable_contexts("chemistry")
4542
# set the default string formatting for pint quantities
4643
ureg.default_format = "P~"
4744

src/pyEQL/pint_custom_units.txt

Lines changed: 0 additions & 52 deletions
This file was deleted.

src/pyEQL/solution.py

Lines changed: 25 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,15 @@ def __init__(
9292
-7 to +14. The default value corresponds to a pE value typical of natural
9393
waters in equilibrium with the atmosphere.
9494
balance_charge: The strategy for balancing charge during init and equilibrium calculations. Valid options
95-
are 'pH', which will adjust the solution pH to balance charge, 'pE' which will adjust the
96-
redox equilibrium to balance charge, or the name of a dissolved species e.g. 'Ca+2' or 'Cl-'
97-
that will be added/subtracted to balance charge. If set to None, no charge balancing will be
98-
performed either on init or when equilibrate() is called. Note that in this case, equilibrate()
99-
can distort the charge balance!
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!
100104
solvent: Formula of the solvent. Solvents other than water are not supported at this time.
101105
engine: Electrolyte modeling engine to use. See documentation for details on the available engines.
102106
database: path to a .json file (str or Path) or maggma Store instance that
@@ -171,7 +175,7 @@ def __init__(
171175
self._pE = pE
172176
self._pH = pH
173177
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"]:
175179
self.balance_charge = standardize_formula(balance_charge)
176180
else:
177181
self.balance_charge = balance_charge #: Standardized formula of the species used for charge balancing.
@@ -273,13 +277,19 @@ def __init__(
273277
raise NotImplementedError("Balancing charge via redox (pE) is not yet implemented!")
274278
else:
275279
ions = set().union(*[self.cations, self.anions]) # all ions
280+
if self.balance_charge == "auto":
281+
# add the most abundant ion of the opposite charge
282+
if cb <= 0:
283+
self.balance_charge = max(self.cations, key=self.cations.get)
284+
elif cb > 0:
285+
self.balance_charge = max(self.anions, key=self.anions.get)
276286
if self.balance_charge not in ions:
277287
raise ValueError(
278288
f"Charge balancing species {self.balance_charge} was not found in the solution!. "
279289
f"Species {ions} were found."
280290
)
281-
z = self.get_property(balance_charge, "charge")
282-
self.components[balance_charge] += -1 * cb / z * self.volume.to("L").magnitude
291+
z = self.get_property(self.balance_charge, "charge")
292+
self.components[self.balance_charge] += -1 * cb / z * self.volume.to("L").magnitude
283293
balanced = True
284294

285295
if not balanced:
@@ -1282,81 +1292,12 @@ def add_amount(self, solute: str, amount: str):
12821292
Returns:
12831293
Nothing. The concentration of solute is modified.
12841294
"""
1285-
# if units are given on a per-volume basis,
1286-
# iteratively solve for the amount of solute that will preserve the
1287-
# original volume and result in the desired concentration
1288-
if ureg.Quantity(amount).dimensionality in (
1289-
"[substance]/[length]**3",
1290-
"[mass]/[length]**3",
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
1295+
# Get the current amount of the solute
1296+
current_amt = self.get_amount(solute, amount.split(" ")[1])
1297+
if current_amt.magnitude == 0:
1298+
self.logger.warning(f"Add new solute {solute} to the solution")
1299+
new_amt = ureg.Quantity(amount) + current_amt
1300+
self.set_amount(solute, new_amt)
13601301

13611302
def set_amount(self, solute: str, amount: str):
13621303
"""
@@ -2465,7 +2406,7 @@ def to_file(self, filename: str | Path) -> None:
24652406
"""
24662407
str_filename = str(filename)
24672408
if not ("yaml" in str_filename.lower() or "json" in str_filename.lower()):
2468-
self.logger.error("Invalid file extension entered - %s" % str_filename)
2409+
self.logger.error("Invalid file extension entered - {str_filename}")
24692410
raise ValueError("File extension must be .json or .yaml")
24702411
if "yaml" in str_filename.lower():
24712412
solution_dict = self.as_dict()

tests/test_solution.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import copy
1010
import os
1111
import platform
12-
from pathlib import Path
1312

1413
import numpy as np
1514
import pytest
@@ -255,6 +254,31 @@ def test_charge_balance(s3, s5, s5_pH, s6, s6_Ca):
255254
assert np.isclose(s6.charge_balance, -0.12)
256255
assert np.isclose(s6_Ca.charge_balance, 0, atol=1e-8)
257256

257+
# test auto charge balance
258+
s = Solution(
259+
[
260+
["Ca+2", "1 mM"], # 2 meq/L
261+
["Mg+2", "5 mM"], # 10 meq/L
262+
["Na+1", "10 mM"], # 10 meq/L
263+
["Ag+1", "10 mM"], # no contribution to alk or hardness
264+
["CO3-2", "6 mM"], # no contribution to alk or hardness
265+
["SO4-2", "60 mM"], # -120 meq/L
266+
["Br-", "20 mM"],
267+
], # -20 meq/L
268+
volume="1 L",
269+
balance_charge="auto",
270+
)
271+
assert s.balance_charge == "Na[+1]"
272+
assert np.isclose(s.charge_balance, 0, atol=1e-8)
273+
s.equilibrate()
274+
assert s.balance_charge == "Na[+1]"
275+
276+
s = Solution({"Na+": "2 mM", "Cl-": "1 mM"}, balance_charge="auto")
277+
assert s.balance_charge == "Cl[-1]"
278+
assert np.isclose(s.charge_balance, 0, atol=1e-8)
279+
s.equilibrate()
280+
assert s.balance_charge == "Cl[-1]"
281+
258282

259283
def test_alkalinity_hardness(s3, s5, s6):
260284
assert np.isclose(s3.hardness, 0)
@@ -621,11 +645,11 @@ def test_as_from_dict(s1, s2):
621645
# assert s2_new.database != s2.database
622646

623647

624-
def test_serialization(s1, s2, tmpdir):
648+
def test_serialization(s1, s2, tmp_path):
625649
from monty.serialization import dumpfn, loadfn
626650

627-
dumpfn(s1, str(tmpdir / "s1.json"))
628-
s1_new = loadfn(str(tmpdir / "s1.json"))
651+
dumpfn(s1, str(tmp_path / "s1.json"))
652+
s1_new = loadfn(str(tmp_path / "s1.json"))
629653
assert s1_new.volume.magnitude == 2
630654
assert s1_new._solutes["H[+1]"] == "2e-07 mol"
631655
assert s1_new.get_total_moles_solute() == s1.get_total_moles_solute()
@@ -644,8 +668,8 @@ def test_serialization(s1, s2, tmpdir):
644668
# TODO currently this test will fail due to a bug in maggma's __eq__
645669
# assert s1_new.database != s1.database
646670

647-
dumpfn(s2, str(tmpdir / "s2.json"))
648-
s2_new = loadfn(str(tmpdir / "s2.json"))
671+
dumpfn(s2, str(tmp_path / "s2.json"))
672+
s2_new = loadfn(str(tmp_path / "s2.json"))
649673
assert s2_new.volume == s2.volume
650674
# components concentrations should be the same
651675
assert s2_new.components == s2.components
@@ -667,7 +691,7 @@ def test_serialization(s1, s2, tmpdir):
667691
# assert s2_new.database != s2.database
668692

669693

670-
def test_from_preset(tmpdir):
694+
def test_from_preset(tmp_path):
671695
from monty.serialization import dumpfn
672696

673697
preset_name = "seawater"
@@ -685,7 +709,6 @@ def test_from_preset(tmpdir):
685709
with pytest.raises(FileNotFoundError):
686710
Solution.from_preset("nonexistent_preset")
687711
# test json as preset
688-
tmp_path = Path(tmpdir)
689712
json_preset = tmp_path / "test.json"
690713
dumpfn(solution, json_preset)
691714
solution_json = Solution.from_preset(tmp_path / "test")
@@ -695,8 +718,7 @@ def test_from_preset(tmpdir):
695718
assert np.isclose(solution_json.pH, data["pH"], atol=0.01)
696719

697720

698-
def test_test_to_from_file(tmpdir, s1):
699-
tmp_path = Path(tmpdir)
721+
def test_to_from_file(tmp_path, s1):
700722
for f in ["test.json", "test.yaml"]:
701723
filename = tmp_path / f
702724
s1.to_file(filename)

0 commit comments

Comments
 (0)