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
"""
Unit tests for PHREEQC Chemistry Backend
Tests unit conversions, charge balance validation, thread safety,
and PHREEQC integration for aqueous speciation and scaling prediction.
Target coverage: ≥85% (per Codex guidance)
"""
import pytest
import threading
import json
from pathlib import Path
import sys
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from core.chemistry_backend import (
PHREEQCBackend,
mg_L_to_mol_L,
mol_L_to_mg_L,
mg_L_to_meq_L,
calculate_charge_balance,
validate_water_chemistry,
VALID_IONS,
ION_TO_PHREEQC,
)
class TestUnitConversions:
"""Test unit conversion helper functions"""
def test_mg_L_to_mol_L_sodium(self):
"""Test mg/L to mol/L conversion for sodium"""
# 1000 mg/L Na+ with MW=22.99 g/mol
# = 1000 / 22.99 / 1000 = 0.0435 mol/L
result = mg_L_to_mol_L(1000.0, 22.99)
assert abs(result - 0.0435) < 0.001
def test_mol_L_to_mg_L_chloride(self):
"""Test mol/L to mg/L conversion for chloride"""
# 0.01 mol/L Cl- with MW=35.45 g/mol
# = 0.01 * 35.45 * 1000 = 354.5 mg/L
result = mol_L_to_mg_L(0.01, 35.45)
assert abs(result - 354.5) < 0.1
def test_mg_L_to_meq_L_calcium(self):
"""Test mg/L to meq/L conversion for calcium"""
# 100 mg/L Ca2+ with MW=40.08 g/mol, charge=2
# = (100 / 40.08 / 1000) * 2 * 1000 = 4.99 meq/L
result = mg_L_to_meq_L(100.0, 40.08, 2)
assert abs(result - 4.99) < 0.01
def test_roundtrip_conversion(self):
"""Test roundtrip mg/L → mol/L → mg/L"""
original_mg_L = 500.0
mw = 96.06 # Sulfate
mol_L = mg_L_to_mol_L(original_mg_L, mw)
final_mg_L = mol_L_to_mg_L(mol_L, mw)
assert abs(final_mg_L - original_mg_L) < 1e-6
class TestChargeBalance:
"""Test charge balance calculations"""
def test_charge_balance_perfect(self):
"""Test perfectly balanced water (Na+ Cl- solution)"""
# 1000 mg/L NaCl
# Na+: 1000 mg/L / 22.99 g/mol * 1 = 43.5 meq/L
# Cl-: 1500 mg/L / 35.45 g/mol * 1 = 42.3 meq/L
# Close to balanced
ions = {
"Na+": 1000.0,
"Cl-": 1545.0, # Adjusted for perfect balance
}
balance = calculate_charge_balance(ions)
assert abs(balance) < 1.0 # Within ±1%
def test_charge_balance_excess_cations(self):
"""Test water with excess cations"""
ions = {
"Na+": 2000.0, # High sodium
"Cl-": 1000.0,
}
balance = calculate_charge_balance(ions)
assert balance > 10.0 # Significant excess cations
def test_charge_balance_seawater(self):
"""Test charge balance for seawater composition"""
ions = {
"Na+": 10770.0,
"Mg2+": 1290.0,
"Ca2+": 412.0,
"K+": 399.0,
"Cl-": 19350.0,
"SO4-2": 2712.0,
"HCO3-": 142.0,
}
balance = calculate_charge_balance(ions)
assert abs(balance) < 5.0 # Seawater should be well-balanced
def test_validate_water_chemistry_pass(self):
"""Test validation passes for balanced water"""
ions = {
"Na+": 1000.0,
"Cl-": 1545.0,
}
# Should not raise
validate_water_chemistry(ions, max_imbalance=5.0)
def test_validate_water_chemistry_fail(self):
"""Test validation fails for highly imbalanced water"""
ions = {
"Na+": 5000.0, # Huge excess cations
"Cl-": 500.0,
}
# Should raise ValueError
with pytest.raises(ValueError, match="Charge imbalance"):
validate_water_chemistry(ions, max_imbalance=5.0)
class TestIonMappings:
"""Test ion to PHREEQC keyword mappings"""
def test_valid_ions_coverage(self):
"""Test that VALID_IONS dictionary is comprehensive"""
# Check major ions are present
required_ions = ["Na+", "Ca2+", "Mg2+", "Cl-", "SO4-2", "HCO3-"]
for ion in required_ions:
assert ion in VALID_IONS
assert "charge" in VALID_IONS[ion]
assert "mw" in VALID_IONS[ion]
assert "name" in VALID_IONS[ion]
def test_ion_to_phreeqc_mapping(self):
"""Test ION_TO_PHREEQC mapping is correct"""
# Sodium: 1:1 mapping
assert ION_TO_PHREEQC["Na+"] == ("Na", 1.0)
# Sulfate: Convert to sulfur basis
keyword, conversion = ION_TO_PHREEQC["SO4-2"]
assert keyword == "S(6)"
assert abs(conversion - (96.06 / 32.07)) < 0.01
# Bicarbonate: Convert HCO3- to CaCO3 equivalents (per Codex fix BUG-006)
keyword, conversion = ION_TO_PHREEQC["HCO3-"]
assert keyword == "Alkalinity"
assert abs(conversion - (61.02 / 50.0)) < 0.01 # HCO3- to CaCO3
def test_ion_to_phreeqc_coverage(self):
"""Test that all major ions have PHREEQC mappings"""
major_ions = ["Na+", "Ca2+", "Mg2+", "K+", "Cl-", "SO4-2", "HCO3-"]
for ion in major_ions:
assert ion in ION_TO_PHREEQC
class TestPHREEQCBackend:
"""Test PHREEQC backend integration"""
def test_backend_initialization(self):
"""Test that backend initializes successfully"""
backend = PHREEQCBackend()
assert backend.database == "phreeqc.dat"
def test_convert_to_phreeqc_solution(self):
"""Test ion dictionary conversion to PHREEQC format"""
backend = PHREEQCBackend()
ions = {
"Na+": 1000.0,
"Cl-": 1500.0,
"Ca2+": 100.0,
"HCO3-": 200.0,
}
phreeqc_sol = backend.convert_to_phreeqc_solution(ions)
assert "Na" in phreeqc_sol
assert phreeqc_sol["Na"] == 1000.0 # 1:1 conversion
assert "Cl" in phreeqc_sol
assert phreeqc_sol["Cl"] == 1500.0
assert "Ca" in phreeqc_sol
assert phreeqc_sol["Ca"] == 100.0
assert "Alkalinity" in phreeqc_sol
# HCO3- converted to CaCO3 equivalents (per Codex fix BUG-006)
expected_alkalinity = 200.0 / (61.02 / 50.0) # ~163.9
assert abs(phreeqc_sol["Alkalinity"] - expected_alkalinity) < 1.0
def test_run_speciation_simple(self):
"""Test basic speciation calculation"""
backend = PHREEQCBackend()
ions = {
"Na+": 1000.0,
"Cl-": 1545.0, # Balanced
}
result = backend.run_speciation(ions, temperature_C=25.0)
# Check basic properties
assert 5.0 <= result.pH <= 9.0 # Reasonable pH range
assert result.temperature_C == 25.0
assert result.ionic_strength_M > 0.0
assert result.charge_balance_percent < 5.0
def test_run_speciation_with_pH(self):
"""Test speciation with specified pH"""
backend = PHREEQCBackend()
ions = {
"Na+": 1000.0,
"Cl-": 1545.0,
}
result = backend.run_speciation(ions, temperature_C=25.0, pH=7.5)
# pH should be close to specified value
assert abs(result.pH - 7.5) < 0.1
def test_run_speciation_seawater(self):
"""Test speciation for seawater composition"""
backend = PHREEQCBackend()
ions = {
"Na+": 10770.0,
"Mg2+": 1290.0,
"Ca2+": 412.0,
"K+": 399.0,
"Cl-": 19350.0,
"SO4-2": 2712.0,
"HCO3-": 142.0,
}
result = backend.run_speciation(ions, temperature_C=25.0)
# Seawater pH can vary, but should be slightly acidic to neutral
# (CO2 equilibration without atmosphere gives ~7.0-8.3)
assert 6.5 <= result.pH <= 8.5
# Ionic strength should be ~0.7 M
assert 0.5 <= result.ionic_strength_M <= 0.9
# Should have saturation indices
assert "Calcite" in result.saturation_indices
def test_run_speciation_hard_water(self):
"""Test speciation for hard water (high Ca, HCO3)"""
backend = PHREEQCBackend()
ions = {
"Ca2+": 150.0,
"Mg2+": 50.0,
"HCO3-": 250.0,
"SO4-2": 80.0,
"Cl-": 50.0,
"Na+": 100.0,
}
result = backend.run_speciation(ions, temperature_C=25.0)
# Hard water should have positive SI for calcite
si_calcite = result.saturation_indices.get("Calcite", -999)
assert si_calcite > -1.0 # Should be close to saturation or supersaturated
def test_calculate_langelier_index(self):
"""Test LSI calculation"""
backend = PHREEQCBackend()
ions = {
"Ca2+": 120.0,
"HCO3-": 250.0,
"Cl-": 150.0,
"Na+": 100.0,
}
lsi = backend.calculate_langelier_index(ions, temperature_C=25.0, pH=7.8)
# LSI should be reasonable (-3 to +3)
assert -3.0 <= lsi <= 3.0
def test_predict_scaling_tendency(self):
"""Test scaling prediction with multiple indices"""
backend = PHREEQCBackend()
ions = {
"Ca2+": 120.0,
"Mg2+": 30.0,
"HCO3-": 250.0,
"Cl-": 150.0,
"SO4-2": 80.0,
"Na+": 100.0,
}
result, speciation = backend.predict_scaling_tendency(ions, temperature_C=25.0, pH=7.8)
# Check all indices are present
assert hasattr(result, "lsi")
assert hasattr(result, "rsi")
assert hasattr(result, "puckorius_index")
assert hasattr(result, "larson_ratio")
assert hasattr(result, "interpretation")
# LSI and RSI should be inversely related
# High LSI → Low RSI (scaling)
# Low LSI → High RSI (corrosive)
assert result.lsi + result.rsi > 0 # Basic sanity check
# Larson ratio should be positive
assert result.larson_ratio >= 0.0
def test_thread_safety(self):
"""Test that multiple threads can use backend simultaneously"""
backend = PHREEQCBackend()
ions = {
"Na+": 1000.0,
"Cl-": 1545.0,
}
results = []
def run_speciation_thread():
result = backend.run_speciation(ions, temperature_C=25.0)
results.append(result.pH)
# Create 5 threads
threads = [threading.Thread(target=run_speciation_thread) for _ in range(5)]
# Start all threads
for t in threads:
t.start()
# Wait for all threads to complete
for t in threads:
t.join()
# All results should be similar (same inputs)
assert len(results) == 5
avg_pH = sum(results) / len(results)
for pH in results:
assert abs(pH - avg_pH) < 0.01 # All threads get same result
class TestSpeciationResult:
"""Test SpeciationResult dataclass"""
def test_speciation_result_structure(self):
"""Test that speciation result has all required fields"""
backend = PHREEQCBackend()
ions = {
"Na+": 1000.0,
"Cl-": 1545.0,
}
result = backend.run_speciation(ions, temperature_C=25.0)
# Check all required fields
assert hasattr(result, "pH")
assert hasattr(result, "pe")
assert hasattr(result, "temperature_C")
assert hasattr(result, "ionic_strength_M")
assert hasattr(result, "alkalinity_mg_L_CaCO3")
assert hasattr(result, "species")
assert hasattr(result, "saturation_indices")
assert hasattr(result, "charge_balance_percent")
assert hasattr(result, "raw_solution")
# Check types
assert isinstance(result.pH, float)
assert isinstance(result.species, dict)
assert isinstance(result.saturation_indices, dict)
class TestScalingResult:
"""Test ScalingResult dataclass"""
def test_scaling_result_structure(self):
"""Test that scaling result has all required fields"""
backend = PHREEQCBackend()
ions = {
"Ca2+": 120.0,
"HCO3-": 250.0,
"Cl-": 150.0,
"SO4-2": 80.0,
"Na+": 100.0,
}
result, speciation = backend.predict_scaling_tendency(ions, temperature_C=25.0, pH=7.8)
# Check all required fields
assert hasattr(result, "lsi")
assert hasattr(result, "rsi")
assert hasattr(result, "puckorius_index")
assert hasattr(result, "larson_ratio")
assert hasattr(result, "interpretation")
# Check types
assert isinstance(result.lsi, float)
assert isinstance(result.rsi, float)
assert isinstance(result.interpretation, str)
class TestEdgeCases:
"""Test edge cases and error handling"""
def test_empty_ions_dict(self):
"""Test behavior with empty ions dictionary"""
# Empty dict has no ions to validate, so charge balance is 0/0 = 0%
# This is technically "balanced" but meaningless
# The function should handle this gracefully
balance = calculate_charge_balance({})
assert balance == 0.0 # No ions = no imbalance
def test_unknown_ion(self):
"""Test handling of unknown ion in charge balance"""
ions = {
"Na+": 1000.0,
"Cl-": 1545.0,
"UnknownIon": 100.0, # Not in VALID_IONS
}
# Should not crash, but log warning
balance = calculate_charge_balance(ions)
assert isinstance(balance, float)
def test_very_dilute_solution(self):
"""Test speciation for very dilute solution"""
backend = PHREEQCBackend()
ions = {
"Na+": 10.0, # Very dilute
"Cl-": 15.45,
}
result = backend.run_speciation(ions, temperature_C=25.0)
# Should have very low ionic strength
assert result.ionic_strength_M < 0.001
def test_very_concentrated_solution(self):
"""Test speciation for highly concentrated solution"""
backend = PHREEQCBackend()
ions = {
"Na+": 50000.0, # Highly concentrated
"Cl-": 77250.0,
}
result = backend.run_speciation(ions, temperature_C=25.0)
# Should have high ionic strength
assert result.ionic_strength_M > 1.0
def test_temperature_effects(self):
"""Test that temperature affects speciation"""
backend = PHREEQCBackend()
ions = {
"Ca2+": 120.0,
"HCO3-": 250.0,
"Cl-": 150.0,
"Na+": 100.0,
}
result_25C = backend.run_speciation(ions, temperature_C=25.0, pH=7.5)
result_60C = backend.run_speciation(ions, temperature_C=60.0, pH=7.5)
# Higher temperature should change saturation indices
si_calcite_25C = result_25C.saturation_indices.get("Calcite", 0.0)
si_calcite_60C = result_60C.saturation_indices.get("Calcite", 0.0)
# Calcite solubility decreases with temperature (retrograde solubility)
# So SI should increase at higher temperature
assert si_calcite_60C > si_calcite_25C
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])