Skip to main content
Glama
puran-water

Corrosion Engineering MCP Server

by puran-water
phreeqc_adapter.py9.61 kB
""" 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}")

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/puran-water/corrosion-engineering-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server