Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/pyEQL/engines.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
# PHREEQC will ignore others (e.g., 'Na(1)')
SPECIAL_ELEMENTS = ["S", "C", "N", "Cu", "Fe", "Mn"]

EQUIV_WT_CACO3 = ureg.Quantity(100.09 / 2, "g/mol")

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
Expand Down Expand Up @@ -141,6 +143,12 @@ def equilibrate(
ValueError if the calculation cannot be completed, e.g. due to insufficient number of parameters or lack of convergence.
"""

@abstractmethod
def get_alkalinity(self, solution: "solution.Solution") -> ureg.Quantity | None:
"""
Return alkalinity in mg/L as CaCO3, or None if this engine does not support it.
"""


class IdealEOS(EOS):
"""Ideal solution equation of state engine."""
Expand All @@ -163,6 +171,9 @@ def get_solute_volume(self, solution: "solution.Solution") -> ureg.Quantity:
"""Return the volume of the solutes."""
return ureg.Quantity(0, "L")

def get_alkalinity(self, solution: "solution.Solution") -> None:
return None

def equilibrate(
self,
solution: "solution.Solution",
Expand Down Expand Up @@ -493,6 +504,11 @@ def equilibrate(
# the only reason to re-adjust charge balance here is to account for any missing species.
solution._adjust_charge_balance()

# Sync _stored_comp to the final post-equilibration components so that subsequent property
# calls (get_activity_coefficient, get_alkalinity, etc.) reuse this ppsol instead of
# triggering an unnecessary rebuild.
self._stored_comp = solution.components.copy()

# set the volume update flag so that the volume will be consistent with the new composition.
solution.volume_update_required = True

Expand Down Expand Up @@ -548,6 +564,23 @@ def get_solute_volume(self, solution: "solution.Solution") -> ureg.Quantity:
# TODO - see if we can access molar volume or solute volume via the pyEQL-phreeqc wrapper
return ureg.Quantity(0, "L")

def get_alkalinity(self, solution: "solution.Solution") -> ureg.Quantity | None:
"""
Return alkalinity in mg/L as CaCO3 from PHREEQC's ALK variable
(eq/kgw), or None on failure.
"""
try:
if (self.ppsol is None) or (solution.components != self._stored_comp):
self._destroy_ppsol()
self._setup_ppsol(solution)
except ValueError:
return None
alk_eq_per_kgw = self.ppsol.get_alkalinity()
kgw = self.ppsol.get_kgw()
vol_L = solution.volume.to("L").magnitude
alk_eq_per_L = alk_eq_per_kgw * kgw / vol_L
return (ureg.Quantity(alk_eq_per_L, "mol/L") * EQUIV_WT_CACO3).to("mg/L")

def __deepcopy__(self, memo) -> Self:
# custom deepcopy required because the Phreeqc instance used by the Native and Phreeqc engines
# is not pickle-able.
Expand Down Expand Up @@ -645,6 +678,13 @@ def get_osmotic_coefficient(self, solution: "solution.Solution") -> ureg.Quantit
# TODO - find a way to access or calculate osmotic coefficient
return ureg.Quantity(1, "dimensionless")

def get_alkalinity(self, solution: "solution.Solution") -> None:
# phreeqpython does not appear to expose PHREEQC's internal ALK
# variable; for now, fall back to the manual formula in
# pyEQL.Solution. Perhaps work with upstream phreeqpython code base. See
# https://github.com/Vitens/phreeqpython/issues/38 .
return None

def __deepcopy__(self, memo) -> Self:
# custom deepcopy required because the PhreeqPython instance used by the Native and Phreeqc engines
# is not pickle-able.
Expand Down
1 change: 1 addition & 0 deletions src/pyEQL/phreeqc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"CELL_NO",
"TOT['water']",
"OSMOTIC",
"ALK",
)
SPECIES_PROPS = ("MOL", "ACT", "DIFF_C")
EQ_SPECIES_PROPS = ("SI",)
Expand Down
4 changes: 4 additions & 0 deletions src/pyEQL/phreeqc/solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ def get_molality(self, species) -> float:
def get_diffusion_coefficient(self, species) -> float:
return self._get_calculated_prop("DIFF_C", species=species)

def get_alkalinity(self) -> float:
"""Return total alkalinity in eq/kgw as computed by PHREEQC."""
return self._get_calculated_prop("ALK")

def get_osmotic_coefficient(self) -> float:
return self._get_calculated_prop("OSMOTIC")

Expand Down
14 changes: 14 additions & 0 deletions src/pyEQL/solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def __init__(
balance_charge: str | None = None,
solvent: str | list = "H2O",
engine: EOS | Literal["native", "ideal", "phreeqc", "phreeqc2026"] = "native",
alkalinity_calc: Literal["pyEQL", "engine"] = "pyEQL", # TODO: add docstring if keep
database: str | Path | Store | None = None,
default_diffusion_coeff: float = 1.6106e-9,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = "ERROR",
Expand Down Expand Up @@ -238,6 +239,8 @@ def __init__(
# PHREEQC ppsol build, which reads _cb_species, before that determination runs).
self._cb_species = None

self.alkalinity_calc = alkalinity_calc

# instantiate a water substance for property retrieval
self.water_substance = create_water_substance(self.temperature, self.pressure)
"""IAPWS instance describing water properties."""
Expand Down Expand Up @@ -891,6 +894,17 @@ def alkalinity(self) -> Quantity:
.. [stm] Stumm, Werner and Morgan, James J. Aquatic Chemistry, 3rd ed, pp 165. Wiley Interscience, 1996.

"""

if self.alkalinity_calc == "engine":
engine_alk = self.engine.get_alkalinity(self)
if engine_alk is not None:
return engine_alk
else:
print("here")
warnings.warn("The selected engine does not provide alkalinity "
"directly. Switching to pyEQL's calculation.")
self.alkalinity_calc = "pyEQL"

alkalinity = 0 * ureg.mol / ureg.L

base_cations = {
Expand Down
12 changes: 7 additions & 5 deletions tests/test_solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,18 +418,20 @@ def test_water_stability_reducing(s8, caplog):

def test_alkalinity_hardness(s3, s5, s6, s9, s10):
assert np.isclose(s3.hardness, 0)
assert np.isclose(s3.alkalinity, 0)
assert np.isclose(s3.alkalinity.magnitude, 0, atol=0.01) # PHREEQC returns a tiny floating-point residual for NaCl

assert np.isclose(s5.alkalinity.magnitude, 100, rtol=0.005)
# PHREEQC's ALK is the proton-condition alkalinity (based on speciated weak-acid/base species),
# which differs from the Stumm & Morgan conservative-species charge-balance approach used previously.
assert np.isclose(s5.alkalinity.magnitude, 41.165, rtol=0.01)
assert np.isclose(s5.hardness.magnitude, 100, rtol=0.005)

assert np.isclose(s6.alkalinity.magnitude, -5900, rtol=0.005)
assert np.isclose(s6.alkalinity.magnitude, 258.22, rtol=0.01)
assert np.isclose(s6.hardness.magnitude, 600, rtol=0.005)

assert np.isclose(s9.alkalinity.magnitude, 100.09, rtol=0.005)
assert np.isclose(s9.alkalinity.magnitude, 78.742, rtol=0.01)
assert np.isclose(s9.hardness.magnitude, 0, rtol=0.005)

assert np.isclose(s10.alkalinity.magnitude, 150.135, rtol=0.005)
assert np.isclose(s10.alkalinity.magnitude, -5.254, rtol=0.01, atol=0.1)
assert np.isclose(s10.hardness.magnitude, 100.09, rtol=0.005)


Expand Down