diff --git a/src/pyEQL/solution.py b/src/pyEQL/solution.py index ac393658..4d20cf14 100644 --- a/src/pyEQL/solution.py +++ b/src/pyEQL/solution.py @@ -11,6 +11,7 @@ import logging import os import warnings +import re from functools import lru_cache from importlib.resources import files from pathlib import Path @@ -2523,6 +2524,66 @@ def from_file(self, filename: str | Path) -> Solution: return Solution(**solution_dict) return loadfn(filename) + + + @classmethod + def from_phreeqc(cls, path_to_pqi: str) -> "Solution": + """ + Parse a PHREEQC .pqi input file and pull out only the SOLUTION block, + extracting solute amounts (assumed mol/L), plus pH, pE, and temperature if present. + """ + import re + + sol_lines: list[str] = [] + in_solution = False + + with open(path_to_pqi, "r") as f: + for raw in f: + line = raw.strip() + if not in_solution and line.upper().startswith("SOLUTION"): + in_solution = True + continue + # stop on blank or a new ALL-CAPS keyword + if in_solution and (line == "" or re.match(r"^[A-Z]+\b", line)): + break + if in_solution: + sol_lines.append(line) + + if not sol_lines: + raise ValueError(f"No SOLUTION block found in '{path_to_pqi}'") + + composition: dict[str, str] = {} + pH = pE = None + temperature = None # degrees C + + for l in sol_lines: + parts = re.split(r"\s+", l) + key = parts[0].upper() + vals = parts[1:] + if key == "PH": + pH = float(vals[0]) + elif key == "PE": + pE = float(vals[0]) + elif key in ("TEMP", "TEMPERATURE"): + temperature = float(vals[0]) + else: + # solute line: e.g. "Ca+2 0.01" + try: + amt = float(vals[0]) + except ValueError: + continue + # **here** we tag the unit so pyEQL knows mol/L + composition[parts[0]] = f"{amt} mol/L" + + return cls( + solutes=composition, + pH=pH, + pE=pE, + temperature=f"{temperature} degC" if temperature is not None else None, + engine="ideal", + ) + + # arithmetic operations def __add__(self, other: Solution) -> Solution: """ diff --git a/tests/test_parser_from_phreeqc.py b/tests/test_parser_from_phreeqc.py new file mode 100644 index 00000000..a320781b --- /dev/null +++ b/tests/test_parser_from_phreeqc.py @@ -0,0 +1,32 @@ +import warnings +warnings.filterwarnings("ignore", category=RuntimeWarning) + +import textwrap +import pytest + +from pyEQL import Solution + +def test_from_phreeqc_minimal_parser(tmp_path): + """Smoke-test our new from_phreeqc() on a tiny SOLUTION block.""" + pqi = tmp_path / "sample.pqi" + pqi.write_text(textwrap.dedent(""" + SOLUTION foo + pH 7.5 + temp 30 + Ca+2 0.01 + SO4-2 0.01 + + END + """)) + sol = Solution.from_phreeqc(str(pqi)) + assert sol.pH == pytest.approx(7.5, rel=1e-6) + assert sol.temperature.magnitude == pytest.approx(303.15, rel=1e-6) + assert sol.get_amount("Ca+2", "mol/L").magnitude == pytest.approx(0.01, rel=1e-6) + assert sol.get_amount("SO4-2", "mol/L").magnitude == pytest.approx(0.01, rel=1e-6) + +def test_from_phreeqc_missing_block_raises(tmp_path): + """If there is no SOLUTION block, we should get a ValueError.""" + pqi = tmp_path / "empty.pqi" + pqi.write_text("THIS FILE HAS NO SOLUTION BLOCK\n") + with pytest.raises(ValueError): + Solution.from_phreeqc(str(pqi))