Skip to main content
Glama
puran-water

Corrosion Engineering MCP Server

by puran-water
chemistry_backend.py18.6 kB
""" PHREEQC chemistry backend for corrosion engineering calculations. This module provides a thread-safe wrapper around phreeqpython for: - Aqueous speciation and pH calculation - Scaling tendency prediction (LSI, RSI, SDSI) - Corrosive gas dissolution (CO₂, H₂S, O₂) - Electrochemical species tracking (Fe²⁺, Fe³⁺, Cl⁻, SO₄²⁻) Design: - Singleton pattern with thread-local PHREEQC instances - Charge balance validation and correction - Unit conversion helpers (mg/L ↔ mol/L ↔ meq/L) - Cross-validation with degasser-design-mcp water chemistry Thread Safety: Per Codex guidance, each thread gets its own PHREEQC instance to avoid race conditions in the C++ backend. Use threading.local() to isolate. Usage: >>> backend = PHREEQCBackend() >>> result = backend.run_speciation( ... ions={"Na+": 1000.0, "Cl-": 1500.0, "Ca2+": 100.0, "HCO3-": 200.0}, ... temperature_C=25.0, ... pH=7.5 ... ) >>> print(result["pH_calculated"]) 7.48 """ from __future__ import annotations import logging import threading from dataclasses import dataclass from typing import Dict, Optional, Tuple, Any from pathlib import Path import phreeqpython logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Unit conversion constants # --------------------------------------------------------------------------- # Faraday constant (C/mol) for charge calculations FARADAY = 96485.3321 # C/mol # Gas constant (J/mol·K) for thermodynamic calculations R_GAS = 8.314462618 # J/mol·K # Standard temperature (K) T_STD = 298.15 # 25°C # --------------------------------------------------------------------------- # Ion definitions (aligned with degasser-design-mcp) # --------------------------------------------------------------------------- VALID_IONS: Dict[str, Dict[str, float]] = { # Cations "Na+": {"charge": 1, "mw": 22.99, "name": "Sodium"}, "Ca2+": {"charge": 2, "mw": 40.08, "name": "Calcium"}, "Mg2+": {"charge": 2, "mw": 24.31, "name": "Magnesium"}, "K+": {"charge": 1, "mw": 39.10, "name": "Potassium"}, "Fe2+": {"charge": 2, "mw": 55.85, "name": "Iron(II)"}, "Fe3+": {"charge": 3, "mw": 55.85, "name": "Iron(III)"}, "Mn2+": {"charge": 2, "mw": 54.94, "name": "Manganese"}, "Ba2+": {"charge": 2, "mw": 137.33, "name": "Barium"}, "Sr2+": {"charge": 2, "mw": 87.62, "name": "Strontium"}, "NH4+": {"charge": 1, "mw": 18.04, "name": "Ammonium"}, "H+": {"charge": 1, "mw": 1.01, "name": "Hydrogen"}, # Anions "Cl-": {"charge": -1, "mw": 35.45, "name": "Chloride"}, "SO4-2": {"charge": -2, "mw": 96.06, "name": "Sulfate"}, "HCO3-": {"charge": -1, "mw": 61.02, "name": "Bicarbonate"}, "CO3-2": {"charge": -2, "mw": 60.01, "name": "Carbonate"}, "NO3-": {"charge": -1, "mw": 62.00, "name": "Nitrate"}, "F-": {"charge": -1, "mw": 19.00, "name": "Fluoride"}, "PO4-3": {"charge": -3, "mw": 94.97, "name": "Phosphate"}, "SiO3-2": {"charge": -2, "mw": 76.08, "name": "Silicate"}, "Br-": {"charge": -1, "mw": 79.90, "name": "Bromide"}, "B(OH)4-": {"charge": -1, "mw": 78.84, "name": "Borate"}, "OH-": {"charge": -1, "mw": 17.01, "name": "Hydroxide"}, } # Map ion names to PHREEQC element keywords # Format: (PHREEQC_keyword, conversion_factor) ION_TO_PHREEQC: Dict[str, Tuple[str, float]] = { # Cations (1:1 mapping) "Na+": ("Na", 1.0), "Ca2+": ("Ca", 1.0), "Mg2+": ("Mg", 1.0), "K+": ("K", 1.0), "Fe2+": ("Fe(2)", 1.0), "Fe3+": ("Fe(3)", 1.0), "Mn2+": ("Mn", 1.0), "Ba2+": ("Ba", 1.0), "Sr2+": ("Sr", 1.0), "NH4+": ("N(-3)", 18.04 / 14.01), # Express on nitrogen basis # Anions (with elemental conversion) "Cl-": ("Cl", 1.0), "SO4-2": ("S(6)", 96.06 / 32.07), # Sulfate → Sulfur "HCO3-": ("Alkalinity", 61.02 / 50.0), # Convert HCO3- to CaCO3 equivalents (mg/L) "CO3-2": ("C(4)", 60.01 / 12.01), # Carbonate → Carbon "NO3-": ("N(5)", 62.00 / 14.01), # Nitrate → Nitrogen "F-": ("F", 1.0), "PO4-3": ("P", 94.97 / 30.97), # Phosphate → Phosphorus "Br-": ("Br", 1.0), "B(OH)4-": ("B", 78.84 / 10.81), # Borate → Boron "SiO3-2": ("Si", 76.08 / 28.09), # Silicate → Silicon } # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @dataclass class SpeciationResult: """ Result of PHREEQC aqueous speciation calculation. Attributes: pH: Calculated pH after charge balance pe: Redox potential (dimensionless) temperature_C: Temperature (°C) ionic_strength_M: Ionic strength (mol/L) alkalinity_mg_L_CaCO3: Total alkalinity as mg/L CaCO₃ species: Dict of major species concentrations (mol/L) saturation_indices: Dict of SI values for relevant minerals charge_balance_percent: Charge imbalance (%) raw_solution: Optional raw phreeqpython Solution (None to prevent memory leaks) """ pH: float pe: float temperature_C: float ionic_strength_M: float alkalinity_mg_L_CaCO3: float species: Dict[str, float] saturation_indices: Dict[str, float] charge_balance_percent: float raw_solution: Optional[Any] = None # Disabled by default to prevent memory leaks @dataclass class ScalingResult: """ Scaling tendency prediction result. Attributes: lsi: Langelier Saturation Index (pH - pH_s) rsi: Ryznar Stability Index (2 × pH_s - pH) puckorius_index: Puckorius Scaling Index larson_ratio: Cl⁻ + SO₄²⁻ to HCO₃⁻ ratio (corrosivity indicator) interpretation: Text interpretation of scaling/corrosion tendency """ lsi: float rsi: float puckorius_index: float larson_ratio: float interpretation: str # --------------------------------------------------------------------------- # Unit conversion helpers (per Codex guidance) # --------------------------------------------------------------------------- def mg_L_to_mol_L(concentration_mg_L: float, molecular_weight: float) -> float: """ Convert concentration from mg/L to mol/L. Args: concentration_mg_L: Concentration in mg/L molecular_weight: Molecular weight in g/mol Returns: Concentration in mol/L """ return concentration_mg_L / molecular_weight / 1000.0 def mol_L_to_mg_L(concentration_mol_L: float, molecular_weight: float) -> float: """ Convert concentration from mol/L to mg/L. Args: concentration_mol_L: Concentration in mol/L molecular_weight: Molecular weight in g/mol Returns: Concentration in mg/L """ return concentration_mol_L * molecular_weight * 1000.0 def mg_L_to_meq_L(concentration_mg_L: float, molecular_weight: float, charge: int) -> float: """ Convert concentration from mg/L to meq/L (milliequivalents per liter). Args: concentration_mg_L: Concentration in mg/L molecular_weight: Molecular weight in g/mol charge: Absolute charge of the ion Returns: Concentration in meq/L """ mol_L = mg_L_to_mol_L(concentration_mg_L, molecular_weight) return mol_L * abs(charge) * 1000.0 # meq/L def calculate_charge_balance(ion_dict: Dict[str, float]) -> float: """ Calculate charge balance error (%) for water chemistry. Positive values indicate excess cations, negative values indicate excess anions. Args: ion_dict: Dictionary of ion concentrations (mg/L) Returns: Charge balance error as percentage """ cation_meq = 0.0 anion_meq = 0.0 for ion, conc_mg_L in ion_dict.items(): props = VALID_IONS.get(ion) if not props: logger.warning(f"Unknown ion '{ion}' in charge balance calculation") continue meq_L = mg_L_to_meq_L(conc_mg_L, props["mw"], props["charge"]) if props["charge"] > 0: cation_meq += meq_L else: anion_meq += meq_L total_meq = cation_meq + anion_meq if total_meq == 0: return 0.0 return (cation_meq - anion_meq) / total_meq * 100.0 # --------------------------------------------------------------------------- # PHREEQC backend (thread-safe singleton) # --------------------------------------------------------------------------- class PHREEQCBackend: """ Thread-safe PHREEQC chemistry backend. Uses threading.local() to give each thread its own PHREEQC instance, preventing race conditions in the C++ backend (per Codex guidance). """ _thread_local = threading.local() _database_path: Optional[Path] = None def __init__(self, database: str = "phreeqc.dat"): """ Initialize PHREEQC backend. Args: database: PHREEQC database file ("phreeqc.dat", "pitzer.dat", etc.) """ self.database = database def _get_phreeqc(self) -> phreeqpython.PhreeqPython: """ Get thread-local PHREEQC instance. Creates a new instance if this is the first call from this thread. Returns: phreeqpython.PhreeqPython instance for this thread """ if not hasattr(self._thread_local, "pp"): logger.debug(f"Creating new PHREEQC instance for thread {threading.current_thread().name}") self._thread_local.pp = phreeqpython.PhreeqPython(database=self.database) return self._thread_local.pp def convert_to_phreeqc_solution(self, ion_dict: Dict[str, float]) -> Dict[str, float]: """ Convert ion dictionary to PHREEQC solution format. Args: ion_dict: Ion concentrations in mg/L (e.g., {"Na+": 1000.0, "Cl-": 1500.0}) Returns: Dictionary with PHREEQC element keywords """ solution: Dict[str, float] = {} for ion, conc_mg_L in ion_dict.items(): if ion not in ION_TO_PHREEQC: logger.warning(f"Unknown ion '{ion}'; passing through directly") solution[ion] = conc_mg_L continue phreeqc_keyword, conversion_factor = ION_TO_PHREEQC[ion] solution[phreeqc_keyword] = conc_mg_L / conversion_factor return solution def run_speciation( self, ions: Dict[str, float], temperature_C: float = 25.0, pH: Optional[float] = None, pe: float = 4.0, ) -> SpeciationResult: """ Run PHREEQC aqueous speciation calculation. Args: ions: Ion concentrations in mg/L (e.g., {"Na+": 1000.0, "Cl-": 1500.0}) temperature_C: Temperature in °C pH: Initial pH (if None, PHREEQC calculates from charge balance) pe: Redox potential (dimensionless, default 4.0 for oxic) Returns: SpeciationResult with pH, species, saturation indices """ pp = self._get_phreeqc() # Convert to PHREEQC format phreeqc_solution = self.convert_to_phreeqc_solution(ions) # Add temperature, pH, pe phreeqc_solution["temp"] = temperature_C phreeqc_solution["pe"] = pe if pH is not None: phreeqc_solution["pH"] = pH # Add units declaration phreeqc_solution["units"] = "mg/L" # Run PHREEQC try: sol = pp.add_solution(phreeqc_solution) except Exception as e: logger.error(f"PHREEQC error: {e}") raise RuntimeError(f"PHREEQC speciation failed: {e}") from e # Extract results calculated_pH = sol.pH calculated_pe = sol.pe ionic_strength = sol.I # mol/L # Calculate alkalinity from species totals # Alkalinity = [HCO3-] + 2×[CO3-2] + [OH-] - [H+] (meq/L → mg/L as CaCO₃) # Per Codex: Using total("C") overshoots in acidic/organic waters try: # Get individual species totals in mol/L hco3_mol = sol.total("HCO3", units="mol") if "HCO3" in str(sol.species) else 0.0 co3_mol = sol.total("CO3", units="mol") if "CO3" in str(sol.species) else 0.0 oh_mol = sol.total("OH", units="mol") if "OH" in str(sol.species) else 0.0 h_mol = sol.total("H", units="mol") if "H" in str(sol.species) else 0.0 # Alkalinity in meq/L = [HCO3-] + 2×[CO3-2] + [OH-] - [H+] alkalinity_meq_L = (hco3_mol + 2.0 * co3_mol + oh_mol - h_mol) * 1000.0 # Convert to mg/L as CaCO₃ (50 g/mol equivalent weight) alkalinity = alkalinity_meq_L * 50.0 except Exception as e: logger.warning(f"Could not calculate alkalinity from species: {e}") alkalinity = 0.0 # Get major species (mol/L) # In phreeqpython, sol.species returns a dict of {species_name: molality} species = {} for species_name, molality in sol.species.items(): if molality > 1e-9: # Only include significant species species[species_name] = molality # Get saturation indices for relevant minerals si_minerals = ["Calcite", "Aragonite", "Dolomite", "Gypsum", "Halite", "Siderite"] saturation_indices = {} for mineral in si_minerals: try: saturation_indices[mineral] = sol.si(mineral) except: pass # Mineral not in database # Calculate charge balance charge_balance = calculate_charge_balance(ions) # Create result (excluding raw_solution to avoid memory retention per Codex) result = SpeciationResult( pH=calculated_pH, pe=calculated_pe, temperature_C=temperature_C, ionic_strength_M=ionic_strength, alkalinity_mg_L_CaCO3=alkalinity, species=species, saturation_indices=saturation_indices, charge_balance_percent=charge_balance, raw_solution=None, # Don't keep PHREEQC solution object (memory leak) ) # Dispose of PHREEQC solution to prevent memory leak (per Codex guidance) try: sol.forget() except: pass # forget() may not be available in all phreeqpython versions return result def calculate_langelier_index( self, ions: Dict[str, float], temperature_C: float = 25.0, pH: Optional[float] = None, ) -> float: """ Calculate Langelier Saturation Index (LSI). LSI = pH - pH_s where pH_s is the pH at calcite saturation. Args: ions: Ion concentrations in mg/L temperature_C: Temperature in °C pH: Measured pH (if None, uses PHREEQC-calculated pH) Returns: LSI value (positive = scaling, negative = corrosive) """ result = self.run_speciation(ions, temperature_C, pH) # pH_s is the pH when SI(Calcite) = 0 # We can approximate: pH_s ≈ pH - SI(Calcite) si_calcite = result.saturation_indices.get("Calcite", 0.0) pH_s = result.pH - si_calcite lsi = result.pH - pH_s return lsi def predict_scaling_tendency( self, ions: Dict[str, float], temperature_C: float = 25.0, pH: Optional[float] = None, speciation_result: Optional[SpeciationResult] = None, ) -> Tuple[ScalingResult, SpeciationResult]: """ Predict scaling and corrosion tendency using multiple indices. Args: ions: Ion concentrations in mg/L temperature_C: Temperature in °C pH: Measured pH speciation_result: Optional pre-computed speciation (avoids double calculation) Returns: Tuple of (ScalingResult, SpeciationResult) for reuse """ # Reuse speciation if provided (per Codex: avoid double PHREEQC runs) if speciation_result is None: speciation_result = self.run_speciation(ions, temperature_C, pH) result = speciation_result # Langelier Saturation Index (LSI) si_calcite = result.saturation_indices.get("Calcite", 0.0) pH_s = result.pH - si_calcite lsi = result.pH - pH_s # Ryznar Stability Index (RSI) rsi = 2 * pH_s - result.pH # Puckorius Scaling Index (PSI) # PSI = 2 × pH_s - pH_eq # Simplified: PSI ≈ 2 × pH_s - pH psi = 2 * pH_s - result.pH # Larson Ratio (corrosivity indicator) # LR = (Cl⁻ + SO₄²⁻) / HCO₃⁻ in meq/L cl_meq = mg_L_to_meq_L(ions.get("Cl-", 0.0), 35.45, 1) so4_meq = mg_L_to_meq_L(ions.get("SO4-2", 0.0), 96.06, 2) hco3_meq = mg_L_to_meq_L(ions.get("HCO3-", 0.0), 61.02, 1) if hco3_meq > 0: larson_ratio = (cl_meq + so4_meq) / hco3_meq else: larson_ratio = float('inf') # Interpretation if lsi > 0.5: interpretation = "Scaling likely (LSI > 0.5)" elif lsi < -0.5: interpretation = "Corrosive (LSI < -0.5)" else: interpretation = "Near equilibrium (-0.5 < LSI < 0.5)" if larson_ratio > 1.0: interpretation += f"; High corrosivity (Larson ratio = {larson_ratio:.2f} > 1.0)" scaling_result = ScalingResult( lsi=lsi, rsi=rsi, puckorius_index=psi, larson_ratio=larson_ratio, interpretation=interpretation, ) return scaling_result, speciation_result # --------------------------------------------------------------------------- # Convenience functions # --------------------------------------------------------------------------- def validate_water_chemistry(ions: Dict[str, float], max_imbalance: float = 5.0) -> None: """ Validate water chemistry for acceptable charge balance. Args: ions: Ion concentrations in mg/L max_imbalance: Maximum acceptable charge imbalance (%) Raises: ValueError: If charge imbalance exceeds max_imbalance """ charge_balance = calculate_charge_balance(ions) if abs(charge_balance) > max_imbalance: raise ValueError( f"Charge imbalance {charge_balance:.1f}% exceeds threshold {max_imbalance}%. " "Check ion concentrations or adjust max_imbalance parameter." ) logger.info(f"Charge balance: {charge_balance:.2f}%")

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