"""
PHREEQC adapter layer for chemistry backend abstraction.
Design Philosophy (from Codex review):
- Separate PHREEQC I/O from reaction logic
- Lightweight adapter around phreeqpython
- Lets you prototype Reaktoro or IPhreeqcPy backends later while keeping
identical JSON payloads
This adapter provides:
- Consistent interface across chemistry backends
- Automatic error handling and validation
- Integration with state container for caching
- Conversion utilities for common input formats
"""
from typing import Any, Dict, Optional
from core.interfaces import ChemistryBackend
from core.schemas import SpeciationResult, ProvenanceMetadata, ConfidenceLevel
import logging
# Import will be available after requirements.txt installation
try:
from phreeqpython import PhreeqPython
PHREEQPYTHON_AVAILABLE = True
except ImportError:
PHREEQPYTHON_AVAILABLE = False
logging.warning("phreeqpython not available - install with: pip install phreeqpython>=1.5.5")
class PhreeqcAdapter(ChemistryBackend):
"""
Adapter for phreeqpython backend.
Usage:
adapter = PhreeqcAdapter()
result = adapter.speciate(
temperature_C=60,
pressure_bar=10,
water={"pH": 7.2, "alkalinity_mg_L_CaCO3": 200},
gases={"pCO2_bar": 0.5, "pH2S_bar": 0.01},
ions={"Cl_mg_L": 35000, "SO4_mg_L": 2700}
)
print(result["pH"]) # 6.8
"""
def __init__(self, database: str = "phreeqc.dat"):
"""
Initialize PHREEQC adapter.
Args:
database: PHREEQC database file (default: "phreeqc.dat")
"""
if not PHREEQPYTHON_AVAILABLE:
raise ImportError(
"phreeqpython is not installed. "
"Install with: pip install phreeqpython>=1.5.5"
)
self._pp = PhreeqPython(database=database)
self._database = database
self._logger = logging.getLogger(__name__)
def speciate(
self,
temperature_C: float,
pressure_bar: float,
water: Dict[str, float],
gases: Optional[Dict[str, float]] = None,
ions: Optional[Dict[str, float]] = None,
) -> Dict[str, Any]:
"""
Calculate aqueous speciation using phreeqpython.
Args:
temperature_C: Temperature in Celsius
pressure_bar: Pressure in bar (affects gas solubility)
water: Water properties {"pH": 7.0, "alkalinity_mg_L_CaCO3": 200}
gases: Partial pressures {"pCO2_bar": 0.5, "pH2S_bar": 0.01, "pO2_bar": 0.0}
ions: Ion concentrations in mg/L {"Cl_mg_L": 35000, "SO4_mg_L": 2700, ...}
Returns:
Dictionary with speciation results (see SpeciationResult schema)
"""
try:
# Build solution definition for phreeqpython
solution_dict = self._build_solution_dict(
temperature_C=temperature_C,
pressure_bar=pressure_bar,
water=water,
gases=gases,
ions=ions,
)
# Run PHREEQC speciation
self._logger.info(f"Running PHREEQC speciation at T={temperature_C}°C, P={pressure_bar} bar")
solution = self._pp.add_solution_simple(solution_dict, temperature=temperature_C)
# Extract results
result = self._extract_results(solution, temperature_C)
return result
except Exception as e:
self._logger.error(f"PHREEQC speciation failed: {e}")
raise RuntimeError(f"PHREEQC speciation error: {e}")
def get_backend_name(self) -> str:
"""Return backend identifier"""
return "phreeqpython"
# ========================================================================
# Internal Methods
# ========================================================================
def _build_solution_dict(
self,
temperature_C: float,
pressure_bar: float,
water: Dict[str, float],
gases: Optional[Dict[str, float]],
ions: Optional[Dict[str, float]],
) -> Dict[str, Any]:
"""
Build phreeqpython solution dictionary from inputs.
Handles:
- pH and alkalinity
- Gas equilibria (CO2, H2S, O2)
- Ion concentrations
- Unit conversions
"""
solution = {
"temp": temperature_C,
"units": "mg/L",
}
# Add water properties
if "pH" in water:
solution["pH"] = water["pH"]
if "alkalinity_mg_L_CaCO3" in water:
# Convert alkalinity to HCO3 for PHREEQC
# Alkalinity as CaCO3 ≈ HCO3 (mg/L) / 1.22
alk_mg_L = water["alkalinity_mg_L_CaCO3"]
solution["Alkalinity"] = f"{alk_mg_L} as CaCO3"
# Add ions
if ions:
# Map common ion names to PHREEQC elements
ion_mapping = {
"Cl_mg_L": "Cl",
"SO4_mg_L": "S(6)",
"Ca_mg_L": "Ca",
"Mg_mg_L": "Mg",
"Na_mg_L": "Na",
"K_mg_L": "K",
"Fe_mg_L": "Fe",
"Mn_mg_L": "Mn",
}
for ion_key, phreeqc_element in ion_mapping.items():
if ion_key in ions:
solution[phreeqc_element] = ions[ion_key]
# Add gas phases
if gases:
# CO2 equilibrium
if "pCO2_bar" in gases:
# PHREEQC uses log10(pCO2) in atmospheres
pCO2_atm = gases["pCO2_bar"] / 1.01325
solution["C(4)"] = f"{pCO2_atm} CO2(g) {pCO2_atm}"
# H2S equilibrium
if "pH2S_bar" in gases:
pH2S_atm = gases["pH2S_bar"] / 1.01325
solution["S(-2)"] = f"{pH2S_atm} H2S(g) {pH2S_atm}"
# O2 equilibrium
if "pO2_bar" in gases and gases["pO2_bar"] > 0:
pO2_atm = gases["pO2_bar"] / 1.01325
solution["O(0)"] = f"{pO2_atm} O2(g) {pO2_atm}"
return solution
def _extract_results(self, solution, temperature_C: float) -> Dict[str, Any]:
"""
Extract results from phreeqpython solution object.
Returns dictionary matching SpeciationResult schema.
"""
# Basic properties
result = {
"pH": solution.pH,
"temperature_C": temperature_C,
"ionic_strength": solution.I,
}
# Species activities
activities = {}
key_species = ["H+", "OH-", "HCO3-", "CO3-2", "H2CO3", "HS-", "S-2", "H2S", "Cl-", "SO4-2"]
for species in key_species:
try:
activities[species] = solution.species.get(species, 0.0)
except:
pass
result["activities"] = activities
# Species concentrations (mg/L)
concentrations = {}
for species in key_species:
try:
# phreeqpython returns mol/L, convert to mg/L
mol_L = solution.species.get(species, 0.0)
# Simplified: actual molecular weight lookup needed
concentrations[species] = mol_L * 1000 # Placeholder conversion
except:
pass
result["concentrations_mg_L"] = concentrations
# Saturation indices
si = {}
key_minerals = ["FeCO3", "FeS", "CaCO3", "Calcite", "Aragonite", "Mackinawite"]
for mineral in key_minerals:
try:
si[mineral] = solution.si(mineral)
except:
pass
result["saturation_indices"] = si
# Redox
try:
result["pe"] = solution.pe
except:
result["pe"] = None
try:
# Eh (V) = 0.059 * pe at 25°C (simplified)
if result["pe"] is not None:
result["Eh_V"] = 0.059 * result["pe"]
else:
result["Eh_V"] = None
except:
result["Eh_V"] = None
# Add provenance
result["provenance"] = {
"model": "phreeqpython",
"version": None, # Can query phreeqpython.__version__
"validation_dataset": None,
"confidence": "high", # PHREEQC is well-validated
"sources": ["PHREEQC database: " + self._database],
"assumptions": ["Equilibrium thermodynamics", "Aqueous phase only"],
"warnings": [],
}
return result
def close(self):
"""Clean up PHREEQC resources"""
# phreeqpython doesn't require explicit cleanup
pass
# ============================================================================
# Utility Functions
# ============================================================================
def convert_units(value: float, from_unit: str, to_unit: str) -> float:
"""
Convert between common corrosion units.
Supported conversions:
- mm/y ↔ mpy (mils per year)
- mg/L ↔ mol/L
- bar ↔ atm ↔ psi
"""
conversions = {
("mm_per_y", "mpy"): 39.37, # 1 mm/y = 39.37 mpy
("mpy", "mm_per_y"): 1/39.37,
("bar", "atm"): 1/1.01325,
("atm", "bar"): 1.01325,
("bar", "psi"): 14.5038,
("psi", "bar"): 1/14.5038,
}
key = (from_unit, to_unit)
if key in conversions:
return value * conversions[key]
elif from_unit == to_unit:
return value
else:
raise ValueError(f"Unsupported unit conversion: {from_unit} → {to_unit}")