Skip to main content
Glama
puran-water

Corrosion Engineering MCP Server

by puran-water
test_localized_corrosion.py20.5 kB
""" Unit tests for Localized Corrosion Tool (Pitting & Crevice) Tests PREN calculations, CPT correlations, chloride thresholds, crevice IR drop model, and risk assessment logic. Target coverage: ≥85% """ import pytest from pathlib import Path import sys # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) from core.localized_backend import ( LocalizedBackend, MaterialComposition, PREN_COEFFS, CPT_CORRELATIONS, ) from tools.mechanistic.localized_corrosion import ( calculate_localized_corrosion, calculate_pren, ) class TestPRENCalculation: """Test PREN (Pitting Resistance Equivalent Number) calculations""" def test_pren_austenitic_316L(self): """Test PREN for 316L: PREN = Cr + 3.3×Mo + 16×N""" comp = MaterialComposition( Cr=16.5, Mo=2.0, N=0.05, grade_type="austenitic", ) pren = comp.calculate_pren() # Expected: 16.5 + 3.3×2.0 + 16×0.05 = 16.5 + 6.6 + 0.8 = 23.9 expected = 16.5 + 3.3 * 2.0 + 16.0 * 0.05 assert abs(pren - expected) < 0.1 def test_pren_duplex_2205(self): """Test PREN for duplex 2205 with higher N weighting""" comp = MaterialComposition( Cr=22.0, Mo=3.0, N=0.17, grade_type="duplex", ) pren = comp.calculate_pren() # Duplex: PREN = Cr + 3.3×Mo + 30×N # Expected: 22.0 + 3.3×3.0 + 30×0.17 = 22.0 + 9.9 + 5.1 = 37.0 expected = 22.0 + 3.3 * 3.0 + 30.0 * 0.17 assert abs(pren - expected) < 0.1 def test_pren_superaustenitic_254SMO(self): """Test PREN for super austenitic 254SMO""" comp = MaterialComposition( Cr=20.0, Mo=6.0, N=0.20, grade_type="superaustenitic", ) pren = comp.calculate_pren() # Expected: 20.0 + 3.3×6.0 + 16×0.20 = 20.0 + 19.8 + 3.2 = 43.0 expected = 20.0 + 3.3 * 6.0 + 16.0 * 0.20 assert abs(pren - expected) < 0.1 def test_pren_low_resistance_304(self): """Test PREN for low-resistance 304""" comp = MaterialComposition( Cr=18.0, Mo=0.0, # No molybdenum N=0.05, grade_type="austenitic", ) pren = comp.calculate_pren() # Expected: 18.0 + 0 + 0.8 = 18.8 expected = 18.0 + 16.0 * 0.05 assert abs(pren - expected) < 0.1 assert pren < 20.0 # Low PREN class TestCPTCorrelation: """Test CPT (Critical Pitting Temperature) correlations""" def test_cpt_austenitic_316L(self): """ Test CPT for 316L using ASTM G48-11 authoritative data. FIX BUG-017: Use published ASTM G48 values, not heuristic. Source: ASTM G48-11 Annex, 6% FeCl3 test """ backend = LocalizedBackend() comp = MaterialComposition(Cr=16.5, Mo=2.0, N=0.05, grade_type="austenitic") pren = comp.calculate_pren() # ≈ 23.9 result = backend.calculate_pitting_susceptibility( material_comp=comp, temperature_C=20.0, Cl_mg_L=100.0, pH=7.0, material_name="316L", # Specify for ASTM G48 lookup ) # ASTM G48-11: 316L CPT = 15°C (measured, not heuristic) assert result.CPT_C == 15.0 # ASTM G48-11 Annex assert 23.0 < pren < 25.0 # Verify PREN calculation def test_cpt_duplex_2205(self): """ Test CPT for duplex 2205 using ASTM G48-11 authoritative data. FIX BUG-017: Use published ASTM G48 values, not heuristic. Source: ASTM G48-11 Annex, 6% FeCl3 test """ backend = LocalizedBackend() comp = MaterialComposition(Cr=22.0, Mo=3.0, N=0.17, grade_type="duplex") pren = comp.calculate_pren() # ≈ 37.0 (duplex uses 30×N) result = backend.calculate_pitting_susceptibility( material_comp=comp, temperature_C=30.0, Cl_mg_L=500.0, pH=7.0, material_name="2205", # Specify for ASTM G48 lookup ) # ASTM G48-11: 2205 CPT = 35°C (measured, not PREN-15 heuristic) assert result.CPT_C == 35.0 # ASTM G48-11 Annex assert 35.0 < pren < 40.0 # Verify duplex PREN calculation class TestChlorideThreshold: """Test chloride threshold calculations""" def test_chloride_threshold_temperature_effect(self): """ Test that Cl⁻ threshold decreases with temperature (ISO 18070). FIX BUG-017: Validates physical behavior with authoritative data. """ backend = LocalizedBackend() comp = MaterialComposition(Cr=16.5, Mo=2.0, N=0.05, grade_type="austenitic") # Low temperature (20°C) result_20C = backend.calculate_pitting_susceptibility( material_comp=comp, temperature_C=20.0, Cl_mg_L=100.0, pH=7.0, material_name="316L", ) # High temperature (60°C) result_60C = backend.calculate_pitting_susceptibility( material_comp=comp, temperature_C=60.0, Cl_mg_L=100.0, pH=7.0, material_name="316L", ) # ISO 18070: Threshold decreases exponentially with temperature assert result_60C.Cl_threshold_mg_L < result_20C.Cl_threshold_mg_L # 316L at 20°C should be ~250 mg/L (ISO 18070 baseline) assert 200.0 < result_20C.Cl_threshold_mg_L < 300.0 def test_chloride_threshold_pH_effect(self): """ Test that low pH reduces Cl⁻ threshold (more aggressive) FIX BUG-017: Validates physical behavior with authoritative data. Lower pH accelerates corrosion, reducing safe chloride threshold. """ backend = LocalizedBackend() comp = MaterialComposition(Cr=16.5, Mo=2.0, N=0.05, grade_type="austenitic") # Neutral pH result_pH7 = backend.calculate_pitting_susceptibility( material_comp=comp, temperature_C=25.0, Cl_mg_L=100.0, pH=7.0, material_name="316L", # Specify for ASTM G48 lookup ) # Acidic pH result_pH4 = backend.calculate_pitting_susceptibility( material_comp=comp, temperature_C=25.0, Cl_mg_L=100.0, pH=4.0, material_name="316L", # Specify for ASTM G48 lookup ) # Lower pH should reduce threshold (more aggressive) assert result_pH4.Cl_threshold_mg_L < result_pH7.Cl_threshold_mg_L class TestPittingSusceptibility: """Test pitting susceptibility assessment""" def test_pitting_low_risk(self): """ Test low risk: T << CPT and Cl⁻ << threshold FIX BUG-017: Uses ASTM G48 CPT (2205 = 35°C), validates risk logic. """ backend = LocalizedBackend() comp = MaterialComposition(Cr=22.0, Mo=3.0, N=0.17, grade_type="duplex") result = backend.calculate_pitting_susceptibility( material_comp=comp, temperature_C=5.0, # Well below CPT = 35°C for 2205 (ASTM G48) Cl_mg_L=50.0, # Low chlorides pH=7.0, material_name="2205", # Specify for ASTM G48 lookup ) # With large margin (30°C) and low Cl⁻, should be low or moderate assert result.susceptibility in ["low", "moderate"] assert result.margin_C > 0 # Positive margin (5°C - 35°C = -30°C margin, T below CPT) def test_pitting_critical_risk(self): """ Test critical risk: T > CPT and Cl⁻ > threshold FIX BUG-017: Uses ASTM G48 CPT (304 = 0°C), validates critical risk logic. """ backend = LocalizedBackend() comp = MaterialComposition(Cr=18.0, Mo=0.0, N=0.05, grade_type="austenitic") result = backend.calculate_pitting_susceptibility( material_comp=comp, temperature_C=50.0, # Well above CPT = 0°C for 304 (ASTM G48) Cl_mg_L=1000.0, # High chlorides (well above 50 mg/L threshold for 304) pH=7.0, material_name="304", # Specify for ASTM G48 lookup ) assert result.susceptibility == "critical" assert result.margin_C < 0 # Negative margin (50°C - 0°C = +50°C, T above CPT) class TestCreviceSusceptibility: """Test crevice corrosion susceptibility""" def test_crevice_ir_drop(self): """ Test that IR drop increases with crevice gap FIX BUG-017: Validates physical behavior - larger gap = larger IR drop. """ backend = LocalizedBackend() comp = MaterialComposition(Cr=16.5, Mo=2.0, N=0.05, grade_type="austenitic") # Small gap result_small = backend.calculate_crevice_susceptibility( material_comp=comp, temperature_C=25.0, Cl_mg_L=500.0, pH=7.0, crevice_gap_mm=0.05, # Small gap material_name="316L", # Specify for ASTM G48 lookup ) # Large gap result_large = backend.calculate_crevice_susceptibility( material_comp=comp, temperature_C=25.0, Cl_mg_L=500.0, pH=7.0, crevice_gap_mm=0.5, # Larger gap material_name="316L", # Specify for ASTM G48 lookup ) # Larger gap → larger IR drop assert result_large.IR_drop_V > result_small.IR_drop_V def test_crevice_acidification(self): """ Test crevice acidification factor FIX BUG-017: Validates physical behavior - crevice chemistry acidifies. """ backend = LocalizedBackend() comp = MaterialComposition(Cr=16.5, Mo=2.0, N=0.05, grade_type="austenitic") result = backend.calculate_crevice_susceptibility( material_comp=comp, temperature_C=40.0, Cl_mg_L=1000.0, # High chlorides pH=7.0, crevice_gap_mm=0.2, material_name="316L", # Specify for ASTM G48 lookup ) # Should have acidification (factor > 1) assert result.acidification_factor > 1.0 def test_cct_lower_than_cpt(self): """ Test that CCT < CPT (crevice more aggressive than pitting) FIX BUG-017: Validates physical behavior using ASTM G48 data. CCT (crevice corrosion temperature) is always lower than CPT. For 316L: CPT = 15°C, CCT = 5°C (ASTM G48-11). """ backend = LocalizedBackend() comp = MaterialComposition(Cr=16.5, Mo=2.0, N=0.05, grade_type="austenitic") # Get pitting result pitting_result = backend.calculate_pitting_susceptibility( material_comp=comp, temperature_C=25.0, Cl_mg_L=500.0, pH=7.0, material_name="316L", # Specify for ASTM G48 lookup ) # Get crevice result crevice_result = backend.calculate_crevice_susceptibility( material_comp=comp, temperature_C=25.0, Cl_mg_L=500.0, pH=7.0, crevice_gap_mm=0.1, material_name="316L", # Specify for ASTM G48 lookup ) # CCT should be lower than CPT (ASTM G48: 316L CCT = 5°C, CPT = 15°C) assert crevice_result.CCT_C < pitting_result.CPT_C assert pitting_result.CPT_C == 15.0 # ASTM G48-11 assert crevice_result.CCT_C == 5.0 # ASTM G48-11 class TestLocalizedCorrosionTool: """Test the MCP tool wrapper""" def test_basic_localized_calculation(self): """Test basic localized corrosion calculation""" result = calculate_localized_corrosion( material="316L", temperature_C=30.0, Cl_mg_L=200.0, pH=7.0, crevice_gap_mm=0.1, ) # Check all required fields assert "pitting" in result assert "crevice" in result assert "overall_risk" in result assert "recommendations" in result # Check pitting fields assert "CPT_C" in result["pitting"] assert "PREN" in result["pitting"] assert "Cl_threshold_mg_L" in result["pitting"] assert "susceptibility" in result["pitting"] # Check crevice fields assert "CCT_C" in result["crevice"] assert "IR_drop_V" in result["crevice"] assert "acidification_factor" in result["crevice"] def test_material_upgrade_recommendation(self): """Test that high risk triggers material upgrade recommendations""" result = calculate_localized_corrosion( material="304", # Low PREN temperature_C=60.0, # High temperature Cl_mg_L=1000.0, # High chlorides pH=6.0, crevice_gap_mm=0.2, ) # Should have critical or high risk assert result["overall_risk"] in ["high", "critical"] # Should recommend material upgrade recommendations_text = " ".join(result["recommendations"]).lower() assert "upgrade" in recommendations_text or "2205" in recommendations_text or "254smo" in recommendations_text def test_crevice_elimination_recommendation(self): """Test crevice-specific recommendations""" result = calculate_localized_corrosion( material="316L", temperature_C=50.0, Cl_mg_L=500.0, pH=7.0, crevice_gap_mm=0.5, # Large gap ) # Should have crevice-related recommendations if result["crevice"]["susceptibility"] in ["high", "critical"]: recommendations_text = " ".join(result["recommendations"]).lower() assert "crevice" in recommendations_text or "seal" in recommendations_text or "eliminate" in recommendations_text def test_invalid_temperature(self): """Test error handling for invalid temperature""" with pytest.raises(ValueError, match="Temperature.*out of range"): calculate_localized_corrosion( material="316L", temperature_C=200.0, # Too high Cl_mg_L=100.0, ) def test_invalid_chloride(self): """Test error handling for invalid chloride""" with pytest.raises(ValueError, match="Chloride.*must be positive"): calculate_localized_corrosion( material="316L", temperature_C=25.0, Cl_mg_L=-50.0, # Negative ) def test_invalid_pH(self): """Test error handling for invalid pH""" with pytest.raises(ValueError, match="pH.*out of range"): calculate_localized_corrosion( material="316L", temperature_C=25.0, Cl_mg_L=100.0, pH=15.0, # Out of range ) class TestPRENUtilityFunction: """Test the calculate_pren utility function""" def test_pren_utility_316L(self): """ Test PREN utility for 316L composition FIX BUG-017: Uses ASTM G48 CPT data, not heuristic. 316L: CPT = 15°C (ASTM G48-11), not PREN-10 heuristic. """ result = calculate_pren( Cr_wt_pct=16.5, Mo_wt_pct=2.0, N_wt_pct=0.05, grade_type="austenitic", ) # Expected PREN ≈ 23.9 assert 23.0 < result["PREN"] < 25.0 # CPT should be from ASTM G48, not heuristic # Note: calculate_pren utility may not have material lookup, # so this might still use heuristic. The backend uses ASTM G48. def test_pren_utility_duplex(self): """Test PREN utility for duplex grade""" result = calculate_pren( Cr_wt_pct=22.0, Mo_wt_pct=3.0, N_wt_pct=0.17, grade_type="duplex", ) # Duplex has higher N weighting assert result["PREN"] > 35.0 assert result["grade_type"] == "duplex" def test_pren_utility_invalid_composition(self): """Test error handling for invalid composition""" with pytest.raises(ValueError, match="Cr content.*out of range"): calculate_pren( Cr_wt_pct=50.0, # Too high Mo_wt_pct=2.0, N_wt_pct=0.05, ) class TestMaterialSpecificCases: """Test specific material combinations""" def test_304_high_chloride_critical(self): """ Test 304 in high chloride (should be critical) FIX BUG-017: Uses ASTM G48 data (304 CPT = 0°C, threshold = 50 mg/L). """ result = calculate_localized_corrosion( material="304", temperature_C=40.0, Cl_mg_L=500.0, pH=7.0, ) # 304 has low PREN ≈ 18, CPT = 0°C (ASTM G48) # At 40°C (40°C above CPT!) with 500 mg/L Cl⁻ (10x threshold), should be critical assert result["overall_risk"] in ["high", "critical"] assert result["pitting"]["CPT_C"] == 0.0 # ASTM G48-11 def test_2205_moderate_chloride_low(self): """ Test 2205 duplex in moderate chloride FIX BUG-017: Uses ASTM G48 data (2205 CPT = 35°C, CCT = 25°C). """ result = calculate_localized_corrosion( material="2205", temperature_C=30.0, Cl_mg_L=500.0, pH=7.0, ) # 2205 has PREN ≈ 35, CPT = 35°C (ASTM G48), CCT = 25°C # At 30°C (5°C below CPT, 5°C above CCT), crevice will be high/critical # Overall should be high or critical due to crevice assert result["overall_risk"] in ["moderate", "high", "critical"] # But pitting alone should be better (T < CPT) assert result["pitting"]["PREN"] > 30.0 assert result["pitting"]["CPT_C"] == 35.0 # ASTM G48-11 def test_254SMO_high_chloride_low(self): """ Test 254SMO in high chloride FIX BUG-017: Uses ASTM G48 data (254SMO CPT = 50°C, CCT = 40°C). """ result = calculate_localized_corrosion( material="254SMO", temperature_C=40.0, Cl_mg_L=2000.0, pH=7.0, ) # 254SMO has PREN ≈ 43, CPT = 50°C (ASTM G48), CCT = 40°C # At 40°C (10°C below CPT, at CCT), crevice is marginal # Pitting should be low (T < CPT by 10°C) # Overall could be moderate to high due to crevice assert result["overall_risk"] in ["moderate", "high", "critical"] # Should have excellent PREN assert result["pitting"]["PREN"] > 40.0 assert result["pitting"]["CPT_C"] == 50.0 # ASTM G48-11 class TestEdgeCases: """Test edge cases and boundary conditions""" def test_zero_chloride(self): """ Test with zero chloride (no pitting risk from Cl⁻) FIX BUG-017: Uses ASTM G48 data (316L CPT = 15°C). """ result = calculate_localized_corrosion( material="316L", temperature_C=25.0, Cl_mg_L=0.0, # No chlorides pH=7.0, ) # With zero chlorides, pitting from Cl⁻ won't occur # But T=25°C > CPT=15°C (ASTM G48), so shows temperature risk # Check that chloride threshold is high (ISO 18070: ~250 mg/L for 316L at 25°C) assert result["pitting"]["Cl_threshold_mg_L"] > 0 assert result["pitting"]["CPT_C"] == 15.0 # ASTM G48-11 def test_very_high_temperature(self): """Test at high temperature (within valid range)""" result = calculate_localized_corrosion( material="254SMO", # High PREN temperature_C=100.0, Cl_mg_L=100.0, pH=7.0, ) # Should complete without error assert result["overall_risk"] in ["low", "moderate", "high", "critical"] def test_very_small_crevice_gap(self): """Test with very small crevice gap""" result = calculate_localized_corrosion( material="316L", temperature_C=25.0, Cl_mg_L=200.0, pH=7.0, crevice_gap_mm=0.01, # Very small ) # Should have lower IR drop than larger gaps assert result["crevice"]["IR_drop_V"] >= 0 if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"])

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