Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3cb64b3
get_sat_idx
May 13, 2026
0a334fd
correct halite naming
May 13, 2026
8b37b29
Merge branch 'main' of https://github.com/SuixiongTay/pyEQL into scal…
May 13, 2026
4a2891a
Merge branch 'main' of https://github.com/SuixiongTay/pyEQL into scal…
May 13, 2026
c782684
add monkeypatch for plt
May 13, 2026
f17b807
Update test_solution.py
YitongPan1 May 13, 2026
7a63484
Update test_solution.py
YitongPan1 May 14, 2026
65a1384
Update test_solution.py
YitongPan1 May 14, 2026
5871b37
Update test_solution.py
YitongPan1 May 14, 2026
30964be
added corrections
May 15, 2026
045085a
Merge branch 'main' of https://github.com/SuixiongTay/pyEQL into scal…
May 15, 2026
114a569
added corrections
May 15, 2026
ff895d1
Merge branch 'scaling_sat_index' of https://github.com/SuixiongTay/py…
May 15, 2026
9baf96c
added engine parameterize test
May 15, 2026
303e0d8
Update test_solution.py
YitongPan1 May 16, 2026
8abffd4
Update test_solution.py
YitongPan1 May 16, 2026
693e730
Update test_solution.py
YitongPan1 May 16, 2026
562e307
Update test_solution.py
YitongPan1 May 16, 2026
ecc71cd
Update test_solution.py
YitongPan1 May 16, 2026
3d4364f
Update test_solution.py
YitongPan1 May 18, 2026
9d51041
Merge branch 'main' into scaling_sat_index
rkingsbury May 28, 2026
8bbf078
solution.py correction
May 29, 2026
39210d2
small correction
May 29, 2026
992bb32
small correction
May 29, 2026
b0ade6d
small corrections
May 29, 2026
2f5e5fa
removed checkpoints
May 29, 2026
97bcb40
Merge branch 'main' into scaling_sat_index
rkingsbury May 29, 2026
6cd4529
trigger ci
May 29, 2026
d68c80f
Merge branch 'main' into scaling_sat_index
rkingsbury May 29, 2026
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
5 changes: 5 additions & 0 deletions src/pyEQL/phreeqc/solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ def equalize(self, phases: list[str], to_si: list[float], in_phase: list[float])
def si(self, eq_species) -> float:
return self._get_calculated_prop("SI", eq_species=eq_species)

@property
def phases(self) -> dict[str, float]:
eq_species = self._get_calculated_prop("eq_species") or {}
return {phase: props["SI"] for phase, props in eq_species.items()}

"""
The following properties are somewhat redundant, but included in here
so we can act as a drop-in replacement for PhreeqPython as far as its
Expand Down
65 changes: 65 additions & 0 deletions src/pyEQL/solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -2917,3 +2917,68 @@ def add_solvent(self, formula: str, amount: str): # pragma: no cover
mw = self.get_property(formula, "molecular_weight")
target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
self.components[formula] = target_mol.to("moles").magnitude

def get_saturation_index(self, get_plot=None) -> dict:
r"""
Calculate the saturation index of a solute in the solution.
Notes:
The saturation index (:math:`\mathrm{SI}`) is defined as log10(IAP/Ksp), where IAP is the ion activity product and Ksp is the solubility product constant.
This method calculates the saturation index based on the active engine and database from `__init__`. The interpretation of the saturation index values is as follows:

- :math:`\mathrm{SI} < 0`: The solution is **undersaturated**. The solid tends to dissolve if present.

- :math:`\mathrm{SI} = 0`: The solution is **at saturation equilibrium**. Therefore, at the saturation limit, the SI is zero.

- :math:`\mathrm{SI} > 0`: The solution is **supersaturated**. Precipitation is thermodynamically favored, although kinetic factors may delay or prevent it.

Args:
get_plot (bool, optional):
If True, displays an interactive bar plot of saturation indices sorted from most oversaturated to least. Defaults to None (no plot).
Returns:
dict:
A dictionary with mineral phase names as keys and their saturation index values as values, sorted in descending order (most oversaturated to least oversaturated).
"""

engine = self.engine

if not hasattr(engine, "ppsol"):
raise NotImplementedError(f"Engine {type(engine).__name__} does not support saturation index calculations.")

# caching method from Phrqsol
if (engine.ppsol is None) or (self.components != engine._stored_comp):
engine._destroy_ppsol()
engine._setup_ppsol(self)

ppsol = engine.ppsol

phases = list(ppsol.phases.keys())
eq_species_dict = {phase: ppsol.si(phase) for phase in phases}

sorted_eq_species_dict = dict(sorted(eq_species_dict.items(), key=lambda item: item[1], reverse=True))

if get_plot:
import pandas as pd # noqa: PLC0415
import plotly.express as px # noqa: PLC0415

df = pd.DataFrame(
{"species": list(sorted_eq_species_dict.keys()), "si": list(sorted_eq_species_dict.values())}
)

fig = px.bar(
df,
x="species",
y="si",
labels={"species": "Mineral Phase", "si": "Saturation Index"},
color="si",
color_continuous_scale="Mint",
)

fig.update_layout(
xaxis_tickangle=-45,
template="plotly_white",
height=500,
)

fig.show()

return sorted_eq_species_dict
60 changes: 60 additions & 0 deletions tests/test_solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from itertools import zip_longest

import numpy as np
import plotly.graph_objs as go
import pytest
import yaml
from monty.serialization import dumpfn, loadfn
Expand Down Expand Up @@ -1239,3 +1240,62 @@ def test_should_use_major_salt_molar_volume_to_calculate_solute_volume_when_para
"OH-", "size.molar_volume"
)
assert solute_volume_without_protons_and_hydroxide.m == expected_solute_volume.m


@pytest.mark.parametrize("engine", ["native", "phreeqc", "phreeqc2026"])
class TestSaturationIndex:
@staticmethod
def test_halite_si_over(engine, monkeypatch):
monkeypatch.setattr(go.Figure, "show", lambda self: None)
solution = Solution({"Na+": "10 mol/L", "K+": "10 mol/L", "Cl-": "10 mol/L"}, engine=engine)
si = solution.get_saturation_index()
assert si["Halite"] > 0.01
Comment thread
rkingsbury marked this conversation as resolved.
si_plot = solution.get_saturation_index(get_plot=True)
assert isinstance(si_plot, dict)
values = list(si.values())
assert values == sorted(values, reverse=True)

def test_halite_si_under(self, engine, monkeypatch):
solution = Solution({"Na+": "0.001 mol/L", "Cl-": "0.001 mol/L"}, engine=engine)
si = solution.get_saturation_index()
assert si["Halite"] < -0.01

def test_halite_si_near(self, engine, monkeypatch):
solution = Solution({"Na+": "6 mol/L", "Cl-": "6 mol/L"}, engine=engine)
si = solution.get_saturation_index()
assert -0.5 < si["Halite"] < 0.0

def test_calcite_si_matches_phreeqc(self, engine, monkeypatch):
from pyEQL.engines import Phreeqc2026EOS, PhreeqcEOS # noqa: PLC0415

monkeypatch.setattr(go.Figure, "show", lambda self: None)
phreeqc_eos = PhreeqcEOS(phreeqc_db="phreeqc.dat")
phreeqc2026_eos = Phreeqc2026EOS(phreeqc_db="phreeqc.dat")
composition = {"Ca2+": "2 mmol/L", "CO3-2": "2 mmol/L", "H+": "10**(-10.3) mol/L"}

phreeqc_si = Solution(composition, engine=phreeqc_eos).get_saturation_index()
phreeqc2026_si = Solution(composition, engine=phreeqc2026_eos).get_saturation_index()
assert pytest.approx(phreeqc2026_si["Calcite"], rel=1e-3, abs=1e-3) == phreeqc_si["Calcite"]
Comment thread
SuixiongTay marked this conversation as resolved.
assert phreeqc_si["Calcite"] == pytest.approx(2.14, rel=1e-2, abs=1e-2)
assert phreeqc2026_si["Calcite"] == pytest.approx(2.14, rel=1e-2, abs=1e-2)

def test_saturation_index_ideal_not_supported(self, engine, monkeypatch):
solution = Solution({"Na+": "1 mol/L", "Cl-": "1 mol/L"}, engine="ideal")
with pytest.raises(NotImplementedError):
solution.get_saturation_index()

# @pytest.mark.skip(reason="temporarily disabled")
def test_multi_equilibrate_si(self, engine, monkeypatch):
monkeypatch.setattr(go.Figure, "show", lambda self: None)
solution = Solution(
{"Na+": "10 mol/L", "K+": "10 mol/L", "Ca2+": "0.05 mol/L", "Cl-": "10 mol/L", "CO3-2": "0.05 mol/L"},
engine="native",
)
si = solution.get_saturation_index()
assert isinstance(si, dict)
assert "Halite" in si
assert "Calcite" in si
assert len(si) >= 2
solution.equilibrate()
assert 0 < si["Halite"] < 1
assert 0 < si["Calcite"] < 1
Loading