Skip to main content

oxiphysics_materials/
corrosion.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Electrochemical corrosion models.
5//!
6//! Provides physics-based corrosion models including:
7//! - Evans diagram and Butler-Volmer electrode kinetics
8//! - Pitting corrosion (repassivation potential, pit growth)
9//! - Galvanic corrosion (mixed potential theory)
10//! - Corrosion rate in mpy and mm/year
11//! - Passivation (Flade potential, passive current density)
12//! - Stress corrosion cracking (KIscc, crack velocity)
13//! - Crevice corrosion (critical gap geometry)
14//! - Dealloying / selective dissolution
15//! - Cathodic protection design (impressed-current and sacrificial anode)
16//! - Corrosion inhibitor efficiency
17
18#![allow(dead_code)]
19
20// ---------------------------------------------------------------------------
21// Physical constants
22// ---------------------------------------------------------------------------
23
24/// Faraday constant \[C/mol\].
25pub const FARADAY: f64 = 96_485.332_12;
26
27/// Universal gas constant \[J/(mol·K)\].
28pub const GAS_CONSTANT: f64 = 8.314_462_618;
29
30// ---------------------------------------------------------------------------
31// Utility helpers
32// ---------------------------------------------------------------------------
33
34/// Clamp a value to the range \[lo, hi\].
35#[inline]
36fn clamp(x: f64, lo: f64, hi: f64) -> f64 {
37    x.max(lo).min(hi)
38}
39
40/// Safe natural logarithm: returns `f64::NEG_INFINITY` for x ≤ 0.
41#[inline]
42fn safe_ln(x: f64) -> f64 {
43    if x <= 0.0 { f64::NEG_INFINITY } else { x.ln() }
44}
45
46// ---------------------------------------------------------------------------
47// Butler-Volmer electrode kinetics
48// ---------------------------------------------------------------------------
49
50/// Butler-Volmer electrode kinetics.
51///
52/// Models the current–overpotential relationship:
53/// `i = i₀ · [exp(αa · F · η / RT) − exp(−αc · F · η / RT)]`
54#[derive(Debug, Clone)]
55pub struct ButlerVolmer {
56    /// Exchange current density i₀ \[A/m²\].
57    pub i0: f64,
58    /// Anodic charge-transfer coefficient αa (dimensionless, typically 0.5).
59    pub alpha_a: f64,
60    /// Cathodic charge-transfer coefficient αc (dimensionless, typically 0.5).
61    pub alpha_c: f64,
62    /// Absolute temperature T \[K\].
63    pub temperature: f64,
64}
65
66impl ButlerVolmer {
67    /// Create a new Butler-Volmer model.
68    pub fn new(i0: f64, alpha_a: f64, alpha_c: f64, temperature: f64) -> Self {
69        Self {
70            i0,
71            alpha_a,
72            alpha_c,
73            temperature,
74        }
75    }
76
77    /// Compute the dimensionless factor F/(RT) \[V⁻¹\].
78    #[inline]
79    pub fn f_over_rt(&self) -> f64 {
80        FARADAY / (GAS_CONSTANT * self.temperature.max(1e-6))
81    }
82
83    /// Current density \[A/m²\] at overpotential `eta` \[V\].
84    pub fn current_density(&self, eta: f64) -> f64 {
85        let q = self.f_over_rt();
86        self.i0 * ((self.alpha_a * q * eta).exp() - (-self.alpha_c * q * eta).exp())
87    }
88
89    /// Linearised charge-transfer resistance \[Ω·m²\] near equilibrium (η → 0).
90    ///
91    /// `Rct = RT / [i₀ · (αa + αc) · F]`
92    pub fn charge_transfer_resistance(&self) -> f64 {
93        GAS_CONSTANT * self.temperature
94            / (self.i0.max(1e-30) * (self.alpha_a + self.alpha_c) * FARADAY)
95    }
96
97    /// Anodic Tafel slope \[V/decade\].
98    ///
99    /// `ba = ln(10) · RT / (αa · F)`
100    pub fn tafel_slope_anodic(&self) -> f64 {
101        std::f64::consts::LN_10 / (self.alpha_a * self.f_over_rt())
102    }
103
104    /// Cathodic Tafel slope \[V/decade\].
105    ///
106    /// `bc = ln(10) · RT / (αc · F)`
107    pub fn tafel_slope_cathodic(&self) -> f64 {
108        std::f64::consts::LN_10 / (self.alpha_c * self.f_over_rt())
109    }
110
111    /// Overpotential \[V\] for a target current density using the Tafel approximation.
112    ///
113    /// Valid for |η| >> RT/F (large overpotential limit).
114    /// Sign follows the input `i_target`: positive → anodic branch.
115    pub fn tafel_overpotential(&self, i_target: f64) -> f64 {
116        if i_target > 0.0 {
117            self.tafel_slope_anodic() * safe_ln(i_target / self.i0.max(1e-30))
118                / std::f64::consts::LN_10
119        } else if i_target < 0.0 {
120            -self.tafel_slope_cathodic() * safe_ln((-i_target) / self.i0.max(1e-30))
121                / std::f64::consts::LN_10
122        } else {
123            0.0
124        }
125    }
126}
127
128// ---------------------------------------------------------------------------
129// Evans diagram (mixed-potential / corrosion potential)
130// ---------------------------------------------------------------------------
131
132/// An Evans diagram combining anodic dissolution and cathodic reaction curves.
133///
134/// The corrosion potential is found at the intersection where
135/// `i_anodic(E) + i_cathodic(E) = 0`.
136#[derive(Debug, Clone)]
137pub struct EvansDiagram {
138    /// Anodic half-reaction Butler-Volmer model.
139    pub anodic: ButlerVolmer,
140    /// Equilibrium potential of the anodic (dissolution) reaction \[V vs SHE\].
141    pub e_eq_anodic: f64,
142    /// Cathodic half-reaction Butler-Volmer model.
143    pub cathodic: ButlerVolmer,
144    /// Equilibrium potential of the cathodic (reduction) reaction \[V vs SHE\].
145    pub e_eq_cathodic: f64,
146}
147
148impl EvansDiagram {
149    /// Create a new Evans diagram.
150    pub fn new(
151        anodic: ButlerVolmer,
152        e_eq_anodic: f64,
153        cathodic: ButlerVolmer,
154        e_eq_cathodic: f64,
155    ) -> Self {
156        Self {
157            anodic,
158            e_eq_anodic,
159            cathodic,
160            e_eq_cathodic,
161        }
162    }
163
164    /// Net current density \[A/m²\] at electrode potential `e` \[V vs SHE\].
165    ///
166    /// Computed as the sum of the anodic dissolution current and the cathodic
167    /// reduction current (which is negative when E < E_eq_cathodic).
168    /// Positive = net anodic (dissolution), negative = net cathodic.
169    /// At the corrosion potential the net current equals zero.
170    pub fn net_current(&self, e: f64) -> f64 {
171        let eta_a = e - self.e_eq_anodic;
172        let eta_c = e - self.e_eq_cathodic;
173        self.anodic.current_density(eta_a) + self.cathodic.current_density(eta_c)
174    }
175
176    /// Find the mixed (corrosion) potential \[V\] by bisection over `[e_lo, e_hi]`.
177    ///
178    /// Returns `None` if no sign change is found in the interval.
179    pub fn corrosion_potential(&self, e_lo: f64, e_hi: f64, tol: f64) -> Option<f64> {
180        let fa = self.net_current(e_lo);
181        let fb = self.net_current(e_hi);
182        if fa * fb > 0.0 {
183            return None;
184        }
185        let mut lo = e_lo;
186        let mut hi = e_hi;
187        for _ in 0..100 {
188            let mid = (lo + hi) * 0.5;
189            if hi - lo < tol {
190                return Some(mid);
191            }
192            let fm = self.net_current(mid);
193            if fa * fm <= 0.0 {
194                hi = mid;
195            } else {
196                lo = mid;
197            }
198        }
199        Some((lo + hi) * 0.5)
200    }
201
202    /// Corrosion current density \[A/m²\] evaluated at the mixed potential.
203    pub fn corrosion_current(&self, e_corr: f64) -> f64 {
204        let eta_a = e_corr - self.e_eq_anodic;
205        self.anodic.current_density(eta_a).abs()
206    }
207}
208
209// ---------------------------------------------------------------------------
210// Corrosion rate conversions
211// ---------------------------------------------------------------------------
212
213/// Convert corrosion current density to mass-loss rate \[g/(m²·s)\].
214///
215/// # Arguments
216/// * `i_corr` – corrosion current density \[A/m²\]
217/// * `molar_mass` – metal molar mass \[g/mol\]
218/// * `n_electrons` – number of electrons exchanged per metal atom
219pub fn corrosion_mass_rate(i_corr: f64, molar_mass: f64, n_electrons: f64) -> f64 {
220    i_corr * molar_mass / (n_electrons * FARADAY)
221}
222
223/// Convert corrosion current density to penetration rate \[mm/year\].
224///
225/// # Arguments
226/// * `i_corr` – corrosion current density \[A/m²\]
227/// * `molar_mass` – metal molar mass \[g/mol\]
228/// * `n_electrons` – electrons per metal atom
229/// * `density` – metal density \[g/cm³\]
230pub fn corrosion_rate_mm_per_year(
231    i_corr: f64,
232    molar_mass: f64,
233    n_electrons: f64,
234    density: f64,
235) -> f64 {
236    // mass rate [g/(m²·s)] / density [g/cm³ = 1e6 g/m³] → thickness rate [m/s]
237    // then convert to mm/year
238    let mass_rate = corrosion_mass_rate(i_corr, molar_mass, n_electrons);
239    let thickness_rate_m_per_s = mass_rate / (density * 1e6); // density in g/m³
240    thickness_rate_m_per_s * 1e3 * 3.156e7 // mm/year
241}
242
243/// Convert corrosion current density to penetration rate \[mpy\] (mils per year).
244///
245/// 1 mpy = 0.0254 mm/year.
246pub fn corrosion_rate_mpy(i_corr: f64, molar_mass: f64, n_electrons: f64, density: f64) -> f64 {
247    corrosion_rate_mm_per_year(i_corr, molar_mass, n_electrons, density) / 0.0254
248}
249
250// ---------------------------------------------------------------------------
251// Passivation model
252// ---------------------------------------------------------------------------
253
254/// Passivation model for active–passive metals.
255///
256/// Describes the three regimes on the anodic polarisation curve:
257/// active dissolution → active-passive transition → passive plateau → transpassive.
258#[derive(Debug, Clone)]
259pub struct PassivationModel {
260    /// Flade potential (active/passive boundary) \[V vs SHE\].
261    pub flade_potential: f64,
262    /// Passivation current density (peak active) \[A/m²\].
263    pub i_passivation: f64,
264    /// Passive current density (maintained in passive regime) \[A/m²\].
265    pub i_passive: f64,
266    /// Transpassive potential (onset of transpassive dissolution) \[V vs SHE\].
267    pub e_transpassive: f64,
268    /// Butler-Volmer parameters for the active regime.
269    pub active_bv: ButlerVolmer,
270}
271
272impl PassivationModel {
273    /// Create a new passivation model.
274    pub fn new(
275        flade_potential: f64,
276        i_passivation: f64,
277        i_passive: f64,
278        e_transpassive: f64,
279        active_bv: ButlerVolmer,
280    ) -> Self {
281        Self {
282            flade_potential,
283            i_passivation,
284            i_passive,
285            e_transpassive,
286            active_bv,
287        }
288    }
289
290    /// Anodic current density \[A/m²\] at potential `e` \[V vs SHE\].
291    ///
292    /// Regime selection:
293    /// - e < Flade potential → active Butler-Volmer dissolution
294    /// - Flade ≤ e < transpassive → passive plateau (`i_passive`)
295    /// - e ≥ transpassive → transpassive (linear rise above passive)
296    pub fn current_density(&self, e: f64) -> f64 {
297        if e < self.flade_potential {
298            let eta = e - self.active_bv.f_over_rt() * 0.0; // overpotential relative to E_eq
299            self.active_bv.current_density(eta).max(0.0)
300        } else if e < self.e_transpassive {
301            self.i_passive
302        } else {
303            // Transpassive: rises from i_passive
304            self.i_passive + (e - self.e_transpassive) * self.i_passivation
305        }
306    }
307
308    /// Check whether the metal is in the passive state at potential `e`.
309    pub fn is_passive(&self, e: f64) -> bool {
310        e >= self.flade_potential && e < self.e_transpassive
311    }
312
313    /// Passive range width \[V\].
314    pub fn passive_range(&self) -> f64 {
315        (self.e_transpassive - self.flade_potential).max(0.0)
316    }
317}
318
319// ---------------------------------------------------------------------------
320// Pitting corrosion
321// ---------------------------------------------------------------------------
322
323/// Pitting corrosion model.
324///
325/// Characterised by the pitting potential Epit (onset of stable pitting)
326/// and the repassivation potential Erp (below which pits repassivate).
327#[derive(Debug, Clone)]
328pub struct PittingModel {
329    /// Pitting potential \[V vs SHE\]: above this potential, stable pits nucleate.
330    pub e_pit: f64,
331    /// Repassivation potential \[V vs SHE\]: below this, active pits repassivate.
332    pub e_rp: f64,
333    /// Critical chloride concentration for pitting \[mol/L\].
334    pub critical_chloride: f64,
335    /// Pit growth rate constant k \[m·s⁻¹·(A/m²)⁻¹\].
336    pub pit_growth_k: f64,
337}
338
339impl PittingModel {
340    /// Create a new pitting model.
341    pub fn new(e_pit: f64, e_rp: f64, critical_chloride: f64, pit_growth_k: f64) -> Self {
342        Self {
343            e_pit,
344            e_rp,
345            critical_chloride,
346            pit_growth_k,
347        }
348    }
349
350    /// Hysteresis width: Epit − Erp \[V\].
351    ///
352    /// A larger value indicates greater susceptibility to stable pitting.
353    pub fn hysteresis_width(&self) -> f64 {
354        (self.e_pit - self.e_rp).max(0.0)
355    }
356
357    /// Returns `true` if stable pit growth is expected at the given potential.
358    pub fn is_pitting_active(&self, e: f64) -> bool {
359        e >= self.e_pit
360    }
361
362    /// Returns `true` if an active pit will repassivate at potential `e`.
363    pub fn will_repassivate(&self, e: f64) -> bool {
364        e < self.e_rp
365    }
366
367    /// Hemispherical pit radius \[m\] after time `t` \[s\] at pit current `i_pit` \[A/m²\].
368    ///
369    /// Uses Faraday's law: V = M·Q/(n·F·ρ) with hemispherical geometry.
370    /// `r = (3·M·i_pit·t / (2·π·n·F·ρ))^(1/3)`
371    ///
372    /// # Arguments
373    /// * `i_pit` – pit current density \[A/m²\]
374    /// * `t` – time \[s\]
375    /// * `molar_mass` – \[g/mol\]
376    /// * `n` – electrons per atom
377    /// * `density` – \[g/cm³\]
378    pub fn pit_radius(&self, i_pit: f64, t: f64, molar_mass: f64, n: f64, density: f64) -> f64 {
379        let rho_si = density * 1e6; // g/m³
380        let numerator = 3.0 * molar_mass * i_pit * t;
381        let denominator = 2.0 * std::f64::consts::PI * n * FARADAY * rho_si;
382        if denominator <= 0.0 {
383            0.0
384        } else {
385            (numerator / denominator).cbrt()
386        }
387    }
388
389    /// Pitting initiation time \[s\] according to a simplified induction model.
390    ///
391    /// `t_i = A / (c_Cl / c_crit − 1)` where A is a material constant \[s\].
392    pub fn induction_time(&self, chloride_conc: f64, material_constant_s: f64) -> Option<f64> {
393        let ratio = chloride_conc / self.critical_chloride.max(1e-30);
394        if ratio <= 1.0 {
395            None // Below threshold — no pitting
396        } else {
397            Some(material_constant_s / (ratio - 1.0))
398        }
399    }
400}
401
402// ---------------------------------------------------------------------------
403// Galvanic corrosion — mixed potential theory
404// ---------------------------------------------------------------------------
405
406/// Galvanic pair: two dissimilar metals electrically connected in an electrolyte.
407///
408/// Computes the galvanic (mixed) potential and the galvanic current density
409/// using the mixed-potential (Wagner-Traud) theory.
410#[derive(Debug, Clone)]
411pub struct GalvanicPair {
412    /// Anodic metal (more active, dissolves preferentially).
413    pub anode: ButlerVolmer,
414    /// Equilibrium potential of the anode \[V vs SHE\].
415    pub e_eq_anode: f64,
416    /// Cathodic metal (nobler, protected).
417    pub cathode: ButlerVolmer,
418    /// Equilibrium potential of the cathode \[V vs SHE\].
419    pub e_eq_cathode: f64,
420    /// Area ratio: cathode area / anode area (dimensionless).
421    pub area_ratio: f64,
422}
423
424impl GalvanicPair {
425    /// Create a new galvanic pair.
426    pub fn new(
427        anode: ButlerVolmer,
428        e_eq_anode: f64,
429        cathode: ButlerVolmer,
430        e_eq_cathode: f64,
431        area_ratio: f64,
432    ) -> Self {
433        Self {
434            anode,
435            e_eq_anode,
436            cathode,
437            e_eq_cathode,
438            area_ratio,
439        }
440    }
441
442    /// Galvanic driving force \[V\]: difference in equilibrium potentials.
443    pub fn driving_force(&self) -> f64 {
444        (self.e_eq_cathode - self.e_eq_anode).abs()
445    }
446
447    /// Net current density at potential `e` \[A/m² referenced to anode area\].
448    ///
449    /// Sum of anodic dissolution (positive) and scaled cathodic reduction (negative
450    /// when E < E_eq_cathode).  Equals zero at the galvanic mixed potential.
451    pub fn net_current(&self, e: f64) -> f64 {
452        let i_a = self.anode.current_density(e - self.e_eq_anode);
453        let i_c = self.cathode.current_density(e - self.e_eq_cathode) * self.area_ratio;
454        i_a + i_c
455    }
456
457    /// Find the galvanic (mixed) potential \[V\] by bisection.
458    pub fn galvanic_potential(&self, e_lo: f64, e_hi: f64, tol: f64) -> Option<f64> {
459        let fa = self.net_current(e_lo);
460        let fb = self.net_current(e_hi);
461        if fa * fb > 0.0 {
462            return None;
463        }
464        let mut lo = e_lo;
465        let mut hi = e_hi;
466        for _ in 0..100 {
467            let mid = (lo + hi) * 0.5;
468            if hi - lo < tol {
469                return Some(mid);
470            }
471            let fm = self.net_current(mid);
472            if fa * fm <= 0.0 {
473                hi = mid;
474            } else {
475                lo = mid;
476            }
477        }
478        Some((lo + hi) * 0.5)
479    }
480}
481
482// ---------------------------------------------------------------------------
483// Stress corrosion cracking (SCC)
484// ---------------------------------------------------------------------------
485
486/// Stress corrosion cracking model.
487///
488/// Combines fracture mechanics (KI) with the electrochemical dissolution
489/// mechanism (anodic dissolution model).
490#[derive(Debug, Clone)]
491pub struct SccrModel {
492    /// Threshold stress intensity factor KIscc \[MPa·m^0.5\].
493    pub k_iscc: f64,
494    /// Fracture toughness KIc \[MPa·m^0.5\].
495    pub k_ic: f64,
496    /// Stage II (plateau) crack velocity \[m/s\].
497    pub v_plateau: f64,
498    /// Exponent in Stage I power-law velocity: `v = A·(KI − KIscc)^n`.
499    pub stage1_exponent: f64,
500    /// Coefficient A in Stage I velocity \[m/s · (MPa·m^0.5)^(−n)\].
501    pub stage1_coeff: f64,
502}
503
504impl SccrModel {
505    /// Create a new SCC model.
506    pub fn new(
507        k_iscc: f64,
508        k_ic: f64,
509        v_plateau: f64,
510        stage1_exponent: f64,
511        stage1_coeff: f64,
512    ) -> Self {
513        Self {
514            k_iscc,
515            k_ic,
516            v_plateau,
517            stage1_exponent,
518            stage1_coeff,
519        }
520    }
521
522    /// Crack propagation velocity \[m/s\] at stress intensity `ki` \[MPa·m^0.5\].
523    ///
524    /// Three-stage model:
525    /// - KI < KIscc → 0 (no crack growth)
526    /// - KIscc ≤ KI < KIc → Stage I/II (Plateau or power-law)
527    /// - KI ≥ KIc → catastrophic (returns `f64::INFINITY`)
528    pub fn crack_velocity(&self, ki: f64) -> f64 {
529        if ki < self.k_iscc {
530            0.0
531        } else if ki >= self.k_ic {
532            f64::INFINITY
533        } else {
534            let dk = ki - self.k_iscc;
535            let v_stage1 = self.stage1_coeff * dk.powf(self.stage1_exponent);
536            v_stage1.min(self.v_plateau)
537        }
538    }
539
540    /// Time to fracture \[s\] from an initial crack of length `a0` \[m\]
541    /// to the critical length `ac` \[m\] under constant stress `sigma` \[MPa\].
542    ///
543    /// Critical crack length: `ac = (KIc / (Y · σ · √π))²`
544    /// Uses simple trapezoidal integration over crack length.
545    ///
546    /// # Arguments
547    /// * `a0` – initial half-crack length \[m\]
548    /// * `sigma` – applied stress \[MPa\]
549    /// * `geometry_factor_y` – geometry correction factor Y (≈ 1.0 for centre crack)
550    /// * `steps` – integration steps
551    #[allow(clippy::too_many_arguments)]
552    pub fn time_to_fracture(
553        &self,
554        a0: f64,
555        sigma: f64,
556        geometry_factor_y: f64,
557        steps: usize,
558    ) -> f64 {
559        let ac = (self.k_ic / (geometry_factor_y * sigma * std::f64::consts::PI.sqrt())).powi(2);
560        if a0 >= ac {
561            return 0.0;
562        }
563        let da = (ac - a0) / steps.max(1) as f64;
564        let ki = |a: f64| geometry_factor_y * sigma * (std::f64::consts::PI * a).sqrt();
565        let dt = |a: f64| {
566            let v = self.crack_velocity(ki(a));
567            if v <= 0.0 || v.is_infinite() {
568                0.0
569            } else {
570                da / v
571            }
572        };
573        let mut t = 0.0f64;
574        let mut a = a0;
575        for _ in 0..steps {
576            t += dt(a);
577            a += da;
578        }
579        t
580    }
581
582    /// Susceptibility index (0–1): `(KIscc / KIc)`.
583    ///
584    /// Values close to 1 indicate low susceptibility (wide safe window).
585    pub fn susceptibility_index(&self) -> f64 {
586        (self.k_iscc / self.k_ic.max(1e-30)).min(1.0)
587    }
588}
589
590// ---------------------------------------------------------------------------
591// Crevice corrosion
592// ---------------------------------------------------------------------------
593
594/// Crevice corrosion model based on critical geometry criterion.
595///
596/// Crevice attack initiates when the IR drop within the crevice
597/// is large enough to shift the local potential below the critical crevice
598/// potential.
599#[derive(Debug, Clone)]
600pub struct CreviceModel {
601    /// Crevice gap width \[m\].
602    pub gap: f64,
603    /// Crevice depth \[m\].
604    pub depth: f64,
605    /// Electrolyte resistivity \[Ω·m\].
606    pub resistivity: f64,
607    /// Critical crevice potential (below which active dissolution occurs) \[V\].
608    pub e_crevice_critical: f64,
609    /// Passive current density \[A/m²\].
610    pub i_passive: f64,
611}
612
613impl CreviceModel {
614    /// Create a new crevice model.
615    pub fn new(
616        gap: f64,
617        depth: f64,
618        resistivity: f64,
619        e_crevice_critical: f64,
620        i_passive: f64,
621    ) -> Self {
622        Self {
623            gap,
624            depth,
625            resistivity,
626            e_crevice_critical,
627            i_passive,
628        }
629    }
630
631    /// IR drop along the crevice \[V\] using 1-D ohmic model.
632    ///
633    /// `ΔV = i_passive · ρ · L² / (2 · g)`
634    /// where L = depth, g = gap, ρ = resistivity.
635    pub fn ir_drop(&self) -> f64 {
636        self.i_passive * self.resistivity * self.depth.powi(2) / (2.0 * self.gap.max(1e-15))
637    }
638
639    /// Critical depth \[m\] at which crevice attack initiates for a given external potential.
640    ///
641    /// `L_crit = sqrt(2 · g · ΔV_crit / (i_passive · ρ))`
642    pub fn critical_depth(&self, e_external: f64) -> Option<f64> {
643        let dv_crit = e_external - self.e_crevice_critical;
644        if dv_crit <= 0.0 {
645            return None;
646        }
647        let arg =
648            2.0 * self.gap * dv_crit / (self.i_passive.max(1e-30) * self.resistivity.max(1e-30));
649        Some(arg.sqrt())
650    }
651
652    /// Geometry factor x = i_passive · ρ · depth² / (2 · gap · ΔV).
653    ///
654    /// Crevice attack expected when x > 1.
655    pub fn geometry_factor(&self, e_external: f64) -> f64 {
656        let dv = (e_external - self.e_crevice_critical).max(1e-30);
657        self.i_passive * self.resistivity * self.depth.powi(2) / (2.0 * self.gap.max(1e-15) * dv)
658    }
659}
660
661// ---------------------------------------------------------------------------
662// Dealloying / selective dissolution
663// ---------------------------------------------------------------------------
664
665/// Selective dissolution (dealloying) model.
666///
667/// Describes the preferential dissolution of the more active component
668/// from a binary alloy (e.g., Cu from brass, Zn from α-brass).
669#[derive(Debug, Clone)]
670pub struct DealloyingModel {
671    /// Initial mole fraction of the active component (0–1).
672    pub x0: f64,
673    /// Dealloying threshold potential \[V vs SHE\].
674    pub e_threshold: f64,
675    /// Diffusion coefficient of the active component in the dealloyed layer \[m²/s\].
676    pub d_alloy: f64,
677    /// Dissolution rate constant \[mol/(m²·s)\] above the threshold.
678    pub k_dissolution: f64,
679}
680
681impl DealloyingModel {
682    /// Create a new dealloying model.
683    pub fn new(x0: f64, e_threshold: f64, d_alloy: f64, k_dissolution: f64) -> Self {
684        Self {
685            x0,
686            e_threshold,
687            d_alloy,
688            k_dissolution,
689        }
690    }
691
692    /// Returns `true` if dealloying is thermodynamically active at potential `e`.
693    pub fn is_active(&self, e: f64) -> bool {
694        e >= self.e_threshold
695    }
696
697    /// Dealloyed layer thickness \[m\] after time `t` \[s\] (parabolic growth law).
698    ///
699    /// `δ = sqrt(2 · D · t)` — valid for diffusion-controlled regime.
700    pub fn layer_thickness(&self, t: f64) -> f64 {
701        (2.0 * self.d_alloy * t).sqrt()
702    }
703
704    /// Residual active-component fraction in the dealloyed layer after depth `delta` \[m\].
705    ///
706    /// Uses an exponential depletion profile: `x(δ) = x0 · exp(−k · δ / D)`.
707    pub fn residual_fraction(&self, delta: f64) -> f64 {
708        let decay = (self.k_dissolution * delta / self.d_alloy.max(1e-30)).min(700.0);
709        self.x0 * (-decay).exp()
710    }
711
712    /// Total active-component mass dissolved per unit area \[mol/m²\] up to time `t`.
713    pub fn dissolved_moles_per_area(&self, t: f64) -> f64 {
714        self.k_dissolution * t
715    }
716}
717
718// ---------------------------------------------------------------------------
719// Cathodic protection
720// ---------------------------------------------------------------------------
721
722/// Cathodic protection design by impressed current.
723///
724/// Determines the required protective current and anode specifications
725/// to achieve a target protective potential on a steel structure.
726#[derive(Debug, Clone)]
727pub struct ImpressedCurrentCp {
728    /// Structure surface area to be protected \[m²\].
729    pub surface_area: f64,
730    /// Protective current density (typically 0.01–0.1 A/m² for bare steel) \[A/m²\].
731    pub protective_current_density: f64,
732    /// Current utilisation efficiency of the anode (0–1, accounts for stray current).
733    pub efficiency: f64,
734    /// Design lifetime \[years\].
735    pub design_life_years: f64,
736}
737
738impl ImpressedCurrentCp {
739    /// Create a new impressed-current CP design.
740    pub fn new(
741        surface_area: f64,
742        protective_current_density: f64,
743        efficiency: f64,
744        design_life_years: f64,
745    ) -> Self {
746        Self {
747            surface_area,
748            protective_current_density,
749            efficiency,
750            design_life_years,
751        }
752    }
753
754    /// Required total current output \[A\].
755    pub fn required_current(&self) -> f64 {
756        self.surface_area * self.protective_current_density
757    }
758
759    /// Required rectifier output current \[A\] accounting for efficiency.
760    pub fn rectifier_current(&self) -> f64 {
761        self.required_current() / self.efficiency.max(1e-6)
762    }
763
764    /// Total charge \[A·h\] needed over the design lifetime.
765    pub fn total_charge_ah(&self) -> f64 {
766        self.rectifier_current() * self.design_life_years * 8760.0
767    }
768}
769
770/// Sacrificial (galvanic) anode cathodic protection design.
771///
772/// Computes the required anode mass for a given protective current
773/// and design life using the electrochemical capacity of the anode material.
774#[derive(Debug, Clone)]
775pub struct SacrificialAnode {
776    /// Electrochemical capacity of the anode material \[A·h/kg\]
777    /// (e.g., zinc ≈ 780, aluminium ≈ 2600, magnesium ≈ 1100).
778    pub electrochemical_capacity: f64,
779    /// Current output per anode \[A\].
780    pub current_output: f64,
781    /// Design lifetime \[years\].
782    pub design_life_years: f64,
783    /// Current efficiency (fraction of capacity delivering useful protection, 0–1).
784    pub current_efficiency: f64,
785}
786
787impl SacrificialAnode {
788    /// Create a new sacrificial anode specification.
789    pub fn new(
790        electrochemical_capacity: f64,
791        current_output: f64,
792        design_life_years: f64,
793        current_efficiency: f64,
794    ) -> Self {
795        Self {
796            electrochemical_capacity,
797            current_output,
798            design_life_years,
799            current_efficiency,
800        }
801    }
802
803    /// Mass of one anode required \[kg\].
804    pub fn anode_mass(&self) -> f64 {
805        let ah_required = self.current_output * self.design_life_years * 8760.0;
806        ah_required / (self.electrochemical_capacity * self.current_efficiency.max(1e-6))
807    }
808
809    /// Number of anodes required to supply `total_current` \[A\].
810    pub fn anode_count(&self, total_current: f64) -> u64 {
811        let n = (total_current / self.current_output.max(1e-30)).ceil();
812        n as u64
813    }
814}
815
816// ---------------------------------------------------------------------------
817// Corrosion inhibitor efficiency
818// ---------------------------------------------------------------------------
819
820/// Corrosion inhibitor performance model.
821///
822/// Inhibitors adsorb onto the metal surface and reduce the exposed area
823/// available for electrochemical dissolution.  Langmuir adsorption
824/// isotherm is used to model surface coverage.
825#[derive(Debug, Clone)]
826pub struct CorrosionInhibitor {
827    /// Langmuir adsorption equilibrium constant K \[L/mol\].
828    pub langmuir_k: f64,
829    /// Inhibitor concentration in solution \[mol/L\].
830    pub concentration: f64,
831    /// Uninhibited corrosion rate (reference) \[mm/year\].
832    pub uninhibited_rate: f64,
833}
834
835impl CorrosionInhibitor {
836    /// Create a new inhibitor model.
837    pub fn new(langmuir_k: f64, concentration: f64, uninhibited_rate: f64) -> Self {
838        Self {
839            langmuir_k,
840            concentration,
841            uninhibited_rate,
842        }
843    }
844
845    /// Surface coverage θ via Langmuir isotherm.
846    ///
847    /// `θ = K·C / (1 + K·C)`
848    pub fn surface_coverage(&self) -> f64 {
849        let kc = self.langmuir_k * self.concentration;
850        kc / (1.0 + kc)
851    }
852
853    /// Inhibited corrosion rate \[mm/year\]: `r = r₀ · (1 − θ)`.
854    pub fn inhibited_rate(&self) -> f64 {
855        self.uninhibited_rate * (1.0 - self.surface_coverage())
856    }
857
858    /// Inhibitor efficiency IE \[%\]: `IE = (1 − r/r₀) × 100`.
859    pub fn efficiency_percent(&self) -> f64 {
860        100.0 * self.surface_coverage()
861    }
862
863    /// Minimum inhibitor concentration \[mol/L\] for target efficiency `ie_target` (0–1).
864    ///
865    /// Inverts the Langmuir isotherm: `C = θ / (K · (1 − θ))`.
866    pub fn concentration_for_efficiency(&self, ie_target: f64) -> Option<f64> {
867        let theta = clamp(ie_target, 0.0, 0.9999);
868        if self.langmuir_k <= 0.0 {
869            return None;
870        }
871        Some(theta / (self.langmuir_k * (1.0 - theta)))
872    }
873}
874
875// ---------------------------------------------------------------------------
876// Nernst equation and equilibrium potential
877// ---------------------------------------------------------------------------
878
879/// Nernst equation for equilibrium potential.
880///
881/// `E = E° + (RT / nF) · ln(a_ox / a_red)`
882#[derive(Debug, Clone)]
883pub struct NernstEquation {
884    /// Standard electrode potential E° \[V vs SHE\].
885    pub e_standard: f64,
886    /// Number of electrons transferred.
887    pub n_electrons: f64,
888    /// Absolute temperature \[K\].
889    pub temperature: f64,
890}
891
892impl NernstEquation {
893    /// Create a new Nernst model.
894    pub fn new(e_standard: f64, n_electrons: f64, temperature: f64) -> Self {
895        Self {
896            e_standard,
897            n_electrons,
898            temperature,
899        }
900    }
901
902    /// Compute the equilibrium potential \[V\] for given oxidised and reduced activities.
903    pub fn equilibrium_potential(&self, a_ox: f64, a_red: f64) -> f64 {
904        let rt_nf = GAS_CONSTANT * self.temperature / (self.n_electrons * FARADAY);
905        self.e_standard + rt_nf * safe_ln(a_ox / a_red.max(1e-30))
906    }
907
908    /// Nernst slope \[V per decade of concentration ratio\].
909    pub fn nernst_slope_per_decade(&self) -> f64 {
910        GAS_CONSTANT * self.temperature * std::f64::consts::LN_10 / (self.n_electrons * FARADAY)
911    }
912}
913
914// ---------------------------------------------------------------------------
915// Pourbaix diagram helper
916// ---------------------------------------------------------------------------
917
918/// A single line (boundary) in a simplified Pourbaix (E–pH) diagram.
919///
920/// Represents either:
921/// - A pH-independent line: `E = const`
922/// - A pH-dependent line: `E = a + b·pH`
923#[derive(Debug, Clone)]
924pub struct PourbaixLine {
925    /// Line label (e.g., "Fe/Fe²⁺ dissolution boundary").
926    pub label: String,
927    /// Constant term a \[V\].
928    pub a: f64,
929    /// pH slope b \[V/pH unit\].
930    pub b: f64,
931}
932
933impl PourbaixLine {
934    /// Create a new Pourbaix diagram line.
935    pub fn new(label: &str, a: f64, b: f64) -> Self {
936        Self {
937            label: label.to_string(),
938            a,
939            b,
940        }
941    }
942
943    /// Potential \[V\] at the given pH.
944    pub fn potential(&self, ph: f64) -> f64 {
945        self.a + self.b * ph
946    }
947
948    /// pH at which this line intersects potential `e`.
949    ///
950    /// Returns `None` if the line is pH-independent (b ≈ 0).
951    pub fn ph_at_potential(&self, e: f64) -> Option<f64> {
952        if self.b.abs() < 1e-15 {
953            None
954        } else {
955            Some((e - self.a) / self.b)
956        }
957    }
958}
959
960/// A simplified Pourbaix (E–pH) diagram for a metal.
961#[derive(Debug, Clone)]
962pub struct PourbaixDiagram {
963    /// Collection of boundary lines.
964    pub lines: Vec<PourbaixLine>,
965    /// Metal name.
966    pub metal: String,
967}
968
969impl PourbaixDiagram {
970    /// Create a new Pourbaix diagram.
971    pub fn new(metal: &str) -> Self {
972        Self {
973            metal: metal.to_string(),
974            lines: Vec::new(),
975        }
976    }
977
978    /// Add a boundary line.
979    pub fn add_line(&mut self, line: PourbaixLine) {
980        self.lines.push(line);
981    }
982
983    /// Build a simplified iron Pourbaix diagram with typical lines.
984    pub fn iron() -> Self {
985        let mut d = Self::new("Fe");
986        // Fe/Fe²⁺: E = -0.44 - 0.0592*pH  (simplified)
987        d.add_line(PourbaixLine::new("Fe/Fe2+", -0.44, -0.0592));
988        // Fe²⁺/Fe₃O₄: E ≈ -0.059 - 0.0888*pH
989        d.add_line(PourbaixLine::new("Fe2+/Fe3O4", -0.059, -0.0888));
990        // Fe₃O₄/Fe₂O₃: E ≈ 0.221 - 0.0592*pH
991        d.add_line(PourbaixLine::new("Fe3O4/Fe2O3", 0.221, -0.0592));
992        // H₂/H₂O: E = 0 - 0.0592*pH
993        d.add_line(PourbaixLine::new("H2/H2O", 0.0, -0.0592));
994        // O₂/H₂O: E = 1.229 - 0.0592*pH
995        d.add_line(PourbaixLine::new("O2/H2O", 1.229, -0.0592));
996        d
997    }
998
999    /// Evaluate all line potentials at a given pH and return sorted values.
1000    pub fn potentials_at_ph(&self, ph: f64) -> Vec<(String, f64)> {
1001        let mut v: Vec<(String, f64)> = self
1002            .lines
1003            .iter()
1004            .map(|l| (l.label.clone(), l.potential(ph)))
1005            .collect();
1006        v.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1007        v
1008    }
1009}
1010
1011// ---------------------------------------------------------------------------
1012// Uniform corrosion under mass transport limitation
1013// ---------------------------------------------------------------------------
1014
1015/// Limiting diffusion current density model.
1016///
1017/// When the cathodic reaction (e.g., oxygen reduction) is transport-limited,
1018/// the corrosion rate is controlled by the flux of oxidant to the surface.
1019/// `i_lim = n · F · D · c_bulk / δ`
1020#[derive(Debug, Clone)]
1021pub struct DiffusionLimitedCorrosion {
1022    /// Number of electrons in the cathodic reaction.
1023    pub n_electrons: f64,
1024    /// Diffusion coefficient of the oxidant \[m²/s\].
1025    pub diffusivity: f64,
1026    /// Bulk oxidant concentration \[mol/m³\].
1027    pub c_bulk: f64,
1028    /// Diffusion layer thickness \[m\].
1029    pub delta: f64,
1030}
1031
1032impl DiffusionLimitedCorrosion {
1033    /// Create a new diffusion-limited corrosion model.
1034    pub fn new(n_electrons: f64, diffusivity: f64, c_bulk: f64, delta: f64) -> Self {
1035        Self {
1036            n_electrons,
1037            diffusivity,
1038            c_bulk,
1039            delta,
1040        }
1041    }
1042
1043    /// Limiting current density \[A/m²\].
1044    pub fn limiting_current(&self) -> f64 {
1045        self.n_electrons * FARADAY * self.diffusivity * self.c_bulk / self.delta.max(1e-15)
1046    }
1047
1048    /// Effective corrosion rate \[mm/year\] if the corrosion current equals the limiting current.
1049    pub fn corrosion_rate_mm_per_year(&self, molar_mass: f64, n_anodic: f64, density: f64) -> f64 {
1050        corrosion_rate_mm_per_year(self.limiting_current(), molar_mass, n_anodic, density)
1051    }
1052}
1053
1054// ---------------------------------------------------------------------------
1055// Unit tests
1056// ---------------------------------------------------------------------------
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::*;
1061
1062    fn standard_bv() -> ButlerVolmer {
1063        ButlerVolmer::new(1e-3, 0.5, 0.5, 298.15)
1064    }
1065
1066    // ── Butler-Volmer ─────────────────────────────────────────────────────────
1067
1068    #[test]
1069    fn test_bv_zero_overpotential() {
1070        let bv = standard_bv();
1071        let i = bv.current_density(0.0);
1072        assert!(i.abs() < 1e-10, "i at η=0 should be 0, got {i}");
1073    }
1074
1075    #[test]
1076    fn test_bv_positive_overpotential_anodic() {
1077        let bv = standard_bv();
1078        assert!(bv.current_density(0.1) > 0.0);
1079    }
1080
1081    #[test]
1082    fn test_bv_negative_overpotential_cathodic() {
1083        let bv = standard_bv();
1084        assert!(bv.current_density(-0.1) < 0.0);
1085    }
1086
1087    #[test]
1088    fn test_bv_charge_transfer_resistance_positive() {
1089        let bv = standard_bv();
1090        assert!(bv.charge_transfer_resistance() > 0.0);
1091    }
1092
1093    #[test]
1094    fn test_bv_tafel_slopes_positive() {
1095        let bv = standard_bv();
1096        assert!(bv.tafel_slope_anodic() > 0.0);
1097        assert!(bv.tafel_slope_cathodic() > 0.0);
1098    }
1099
1100    #[test]
1101    fn test_bv_tafel_slope_symmetric() {
1102        let bv = standard_bv(); // αa = αc = 0.5
1103        let diff = (bv.tafel_slope_anodic() - bv.tafel_slope_cathodic()).abs();
1104        assert!(
1105            diff < 1e-10,
1106            "symmetric αa=αc should give equal Tafel slopes, diff={diff}"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_bv_tafel_overpotential_sign() {
1112        let bv = standard_bv();
1113        assert!(bv.tafel_overpotential(1e-2) > 0.0);
1114        assert!(bv.tafel_overpotential(-1e-2) < 0.0);
1115        assert_eq!(bv.tafel_overpotential(0.0), 0.0);
1116    }
1117
1118    #[test]
1119    fn test_bv_f_over_rt_positive() {
1120        let bv = standard_bv();
1121        assert!(bv.f_over_rt() > 0.0);
1122    }
1123
1124    // ── Evans diagram ─────────────────────────────────────────────────────────
1125
1126    #[test]
1127    fn test_evans_corrosion_potential_found() {
1128        // Bracket spans both equilibrium potentials (-0.5 and 0.0)
1129        // → net current changes sign and corrosion potential exists between them
1130        let anodic = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1131        let cathodic = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1132        let diagram = EvansDiagram::new(anodic, -0.5, cathodic, 0.0);
1133        let e_corr = diagram.corrosion_potential(-0.5, 0.0, 1e-6);
1134        assert!(e_corr.is_some());
1135    }
1136
1137    #[test]
1138    fn test_evans_net_current_sign_change() {
1139        let anodic = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1140        let cathodic = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1141        let diagram = EvansDiagram::new(anodic, -0.5, cathodic, 0.0);
1142        // At e=-0.5 (anode eq): anodic = 0, cathodic(eta=-0.5) < 0 → net < 0
1143        // At e=0.0 (cathode eq): cathodic = 0, anodic(eta=0.5) > 0 → net > 0
1144        let ia = diagram.net_current(-0.5);
1145        let ib = diagram.net_current(0.0);
1146        assert!(ia < 0.0, "net at e_eq_anode should be < 0, got {ia}");
1147        assert!(ib > 0.0, "net at e_eq_cathode should be > 0, got {ib}");
1148    }
1149
1150    #[test]
1151    fn test_evans_corrosion_current_positive() {
1152        let anodic = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1153        let cathodic = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1154        let diagram = EvansDiagram::new(anodic, -0.5, cathodic, 0.0);
1155        if let Some(e_corr) = diagram.corrosion_potential(-0.5, 0.0, 1e-6) {
1156            assert!(diagram.corrosion_current(e_corr) >= 0.0);
1157        }
1158    }
1159
1160    // ── Corrosion rate conversions ────────────────────────────────────────────
1161
1162    #[test]
1163    fn test_corrosion_mass_rate_positive() {
1164        // Iron: M=55.845 g/mol, n=2
1165        let rate = corrosion_mass_rate(1.0, 55.845, 2.0);
1166        assert!(rate > 0.0);
1167    }
1168
1169    #[test]
1170    fn test_corrosion_rate_mm_per_year_positive() {
1171        let rate = corrosion_rate_mm_per_year(1.0, 55.845, 2.0, 7.87);
1172        assert!(rate > 0.0);
1173    }
1174
1175    #[test]
1176    fn test_corrosion_rate_mpy_greater_than_mm() {
1177        let mm = corrosion_rate_mm_per_year(1.0, 55.845, 2.0, 7.87);
1178        let mpy = corrosion_rate_mpy(1.0, 55.845, 2.0, 7.87);
1179        assert!(mpy > mm, "mpy={mpy} should be > mm/year={mm}");
1180    }
1181
1182    // ── Passivation ───────────────────────────────────────────────────────────
1183
1184    #[test]
1185    fn test_passivation_is_passive() {
1186        let bv = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1187        let model = PassivationModel::new(-0.2, 1e-2, 1e-5, 1.5, bv);
1188        assert!(model.is_passive(0.5));
1189        assert!(!model.is_passive(-0.5));
1190        assert!(!model.is_passive(2.0));
1191    }
1192
1193    #[test]
1194    fn test_passivation_passive_range() {
1195        let bv = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1196        let model = PassivationModel::new(-0.2, 1e-2, 1e-5, 1.5, bv);
1197        assert!((model.passive_range() - 1.7).abs() < 1e-10);
1198    }
1199
1200    #[test]
1201    fn test_passivation_current_passive_plateau() {
1202        let bv = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1203        let model = PassivationModel::new(-0.2, 1e-2, 1e-5, 1.5, bv);
1204        let i = model.current_density(0.5);
1205        assert!(
1206            (i - 1e-5).abs() < 1e-12,
1207            "expected passive i_passive, got {i}"
1208        );
1209    }
1210
1211    // ── Pitting ───────────────────────────────────────────────────────────────
1212
1213    #[test]
1214    fn test_pitting_active() {
1215        let model = PittingModel::new(0.3, 0.1, 0.01, 1e-8);
1216        assert!(model.is_pitting_active(0.5));
1217        assert!(!model.is_pitting_active(0.2));
1218    }
1219
1220    #[test]
1221    fn test_pitting_repassivation() {
1222        let model = PittingModel::new(0.3, 0.1, 0.01, 1e-8);
1223        assert!(model.will_repassivate(0.05));
1224        assert!(!model.will_repassivate(0.15));
1225    }
1226
1227    #[test]
1228    fn test_pitting_pit_radius_grows_with_time() {
1229        let model = PittingModel::new(0.3, 0.1, 0.01, 1e-8);
1230        let r1 = model.pit_radius(100.0, 3600.0, 55.845, 2.0, 7.87);
1231        let r2 = model.pit_radius(100.0, 7200.0, 55.845, 2.0, 7.87);
1232        assert!(r2 > r1);
1233    }
1234
1235    #[test]
1236    fn test_pitting_induction_time_none_below_threshold() {
1237        let model = PittingModel::new(0.3, 0.1, 0.01, 1e-8);
1238        let t = model.induction_time(0.005, 1000.0); // below critical_chloride
1239        assert!(t.is_none());
1240    }
1241
1242    #[test]
1243    fn test_pitting_induction_time_some_above_threshold() {
1244        let model = PittingModel::new(0.3, 0.1, 0.01, 1e-8);
1245        let t = model.induction_time(0.02, 1000.0);
1246        assert!(t.is_some());
1247        assert!(t.unwrap() > 0.0);
1248    }
1249
1250    #[test]
1251    fn test_pitting_hysteresis_width() {
1252        let model = PittingModel::new(0.5, 0.2, 0.01, 1e-8);
1253        assert!((model.hysteresis_width() - 0.3).abs() < 1e-10);
1254    }
1255
1256    // ── Galvanic corrosion ────────────────────────────────────────────────────
1257
1258    #[test]
1259    fn test_galvanic_driving_force() {
1260        let a = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1261        let c = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1262        let pair = GalvanicPair::new(a, -0.44, c, 0.34, 10.0);
1263        assert!((pair.driving_force() - 0.78).abs() < 1e-10);
1264    }
1265
1266    #[test]
1267    fn test_galvanic_potential_found() {
1268        // Bracket spans both equilibrium potentials → guaranteed sign change
1269        let a = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1270        let c = ButlerVolmer::new(1e-4, 0.5, 0.5, 298.15);
1271        let pair = GalvanicPair::new(a, -0.44, c, 0.34, 1.0);
1272        // At e=-0.44: anode at eq, cathodic term dominates → net < 0
1273        // At e=0.34: cathode at eq, anodic term dominates → net > 0
1274        let e_g = pair.galvanic_potential(-0.44, 0.34, 1e-6);
1275        assert!(e_g.is_some(), "galvanic potential should be found");
1276    }
1277
1278    // ── SCC ───────────────────────────────────────────────────────────────────
1279
1280    #[test]
1281    fn test_scc_no_growth_below_kiscc() {
1282        let model = SccrModel::new(10.0, 50.0, 1e-7, 2.0, 1e-9);
1283        assert_eq!(model.crack_velocity(5.0), 0.0);
1284    }
1285
1286    #[test]
1287    fn test_scc_infinite_at_kic() {
1288        let model = SccrModel::new(10.0, 50.0, 1e-7, 2.0, 1e-9);
1289        assert!(model.crack_velocity(50.0).is_infinite());
1290    }
1291
1292    #[test]
1293    fn test_scc_velocity_finite_midrange() {
1294        let model = SccrModel::new(10.0, 50.0, 1e-7, 2.0, 1e-9);
1295        let v = model.crack_velocity(25.0);
1296        assert!(v.is_finite() && v >= 0.0);
1297    }
1298
1299    #[test]
1300    fn test_scc_susceptibility_index_bounded() {
1301        let model = SccrModel::new(10.0, 50.0, 1e-7, 2.0, 1e-9);
1302        let s = model.susceptibility_index();
1303        assert!((0.0..=1.0).contains(&s));
1304    }
1305
1306    #[test]
1307    fn test_scc_time_to_fracture_positive() {
1308        let model = SccrModel::new(10.0, 80.0, 1e-7, 2.0, 1e-9);
1309        let t = model.time_to_fracture(1e-4, 100.0, 1.0, 100);
1310        assert!(t > 0.0 && t.is_finite());
1311    }
1312
1313    // ── Crevice ───────────────────────────────────────────────────────────────
1314
1315    #[test]
1316    fn test_crevice_ir_drop_positive() {
1317        let model = CreviceModel::new(1e-4, 1e-2, 0.1, -0.3, 1e-5);
1318        assert!(model.ir_drop() > 0.0);
1319    }
1320
1321    #[test]
1322    fn test_crevice_critical_depth_some() {
1323        let model = CreviceModel::new(1e-4, 1e-2, 0.1, -0.3, 1e-5);
1324        let d = model.critical_depth(-0.1);
1325        assert!(d.is_some());
1326        assert!(d.unwrap() > 0.0);
1327    }
1328
1329    #[test]
1330    fn test_crevice_critical_depth_none_below_critical() {
1331        let model = CreviceModel::new(1e-4, 1e-2, 0.1, 0.5, 1e-5);
1332        // e_external = 0.3 < e_crevice_critical = 0.5 → None
1333        let d = model.critical_depth(0.3);
1334        assert!(d.is_none());
1335    }
1336
1337    // ── Dealloying ────────────────────────────────────────────────────────────
1338
1339    #[test]
1340    fn test_dealloying_active() {
1341        let model = DealloyingModel::new(0.3, 0.1, 1e-15, 1e-8);
1342        assert!(model.is_active(0.2));
1343        assert!(!model.is_active(0.05));
1344    }
1345
1346    #[test]
1347    fn test_dealloying_layer_grows_with_time() {
1348        let model = DealloyingModel::new(0.3, 0.1, 1e-15, 1e-8);
1349        let d1 = model.layer_thickness(1000.0);
1350        let d2 = model.layer_thickness(4000.0);
1351        assert!(d2 > d1);
1352    }
1353
1354    #[test]
1355    fn test_dealloying_residual_fraction_decreases() {
1356        let model = DealloyingModel::new(0.3, 0.1, 1e-15, 1e-8);
1357        let x1 = model.residual_fraction(1e-6);
1358        let x2 = model.residual_fraction(1e-5);
1359        assert!(x2 <= x1);
1360    }
1361
1362    // ── Cathodic protection ───────────────────────────────────────────────────
1363
1364    #[test]
1365    fn test_impressed_current_required() {
1366        let cp = ImpressedCurrentCp::new(500.0, 0.02, 0.9, 20.0);
1367        assert!((cp.required_current() - 10.0).abs() < 1e-10);
1368    }
1369
1370    #[test]
1371    fn test_impressed_current_rectifier_greater_than_required() {
1372        let cp = ImpressedCurrentCp::new(500.0, 0.02, 0.9, 20.0);
1373        assert!(cp.rectifier_current() >= cp.required_current());
1374    }
1375
1376    #[test]
1377    fn test_sacrificial_anode_mass_positive() {
1378        let anode = SacrificialAnode::new(780.0, 0.1, 20.0, 0.85);
1379        assert!(anode.anode_mass() > 0.0);
1380    }
1381
1382    #[test]
1383    fn test_sacrificial_anode_count() {
1384        let anode = SacrificialAnode::new(780.0, 0.1, 20.0, 0.85);
1385        let n = anode.anode_count(1.0);
1386        assert!(n > 0);
1387    }
1388
1389    // ── Inhibitor ─────────────────────────────────────────────────────────────
1390
1391    #[test]
1392    fn test_inhibitor_coverage_between_0_and_1() {
1393        let inh = CorrosionInhibitor::new(100.0, 0.01, 5.0);
1394        let theta = inh.surface_coverage();
1395        assert!(theta > 0.0 && theta < 1.0);
1396    }
1397
1398    #[test]
1399    fn test_inhibitor_rate_less_than_uninhibited() {
1400        let inh = CorrosionInhibitor::new(100.0, 0.01, 5.0);
1401        assert!(inh.inhibited_rate() < inh.uninhibited_rate);
1402    }
1403
1404    #[test]
1405    fn test_inhibitor_efficiency_positive() {
1406        let inh = CorrosionInhibitor::new(50.0, 0.05, 3.0);
1407        assert!(inh.efficiency_percent() > 0.0 && inh.efficiency_percent() < 100.0);
1408    }
1409
1410    #[test]
1411    fn test_inhibitor_concentration_for_efficiency() {
1412        let inh = CorrosionInhibitor::new(100.0, 0.0, 5.0); // concentration = 0 → θ = 0
1413        let c = inh.concentration_for_efficiency(0.9);
1414        assert!(c.is_some());
1415        assert!(c.unwrap() > 0.0);
1416    }
1417
1418    // ── Nernst equation ───────────────────────────────────────────────────────
1419
1420    #[test]
1421    fn test_nernst_standard_potential_at_unit_activity() {
1422        let nernst = NernstEquation::new(-0.44, 2.0, 298.15);
1423        let e = nernst.equilibrium_potential(1.0, 1.0);
1424        assert!((e - (-0.44)).abs() < 1e-10);
1425    }
1426
1427    #[test]
1428    fn test_nernst_slope_positive() {
1429        let nernst = NernstEquation::new(0.0, 1.0, 298.15);
1430        assert!(nernst.nernst_slope_per_decade() > 0.0);
1431    }
1432
1433    #[test]
1434    fn test_nernst_59mv_rule() {
1435        // At 298.15 K, n=1: slope ≈ 0.05916 V/decade
1436        let nernst = NernstEquation::new(0.0, 1.0, 298.15);
1437        let slope = nernst.nernst_slope_per_decade();
1438        assert!(
1439            (slope - 0.05916).abs() < 2e-4,
1440            "Expected ~59.16 mV/decade, got {}",
1441            slope * 1000.0
1442        );
1443    }
1444
1445    // ── Pourbaix diagram ──────────────────────────────────────────────────────
1446
1447    #[test]
1448    fn test_pourbaix_line_potential() {
1449        let line = PourbaixLine::new("test", 0.0, -0.0592);
1450        let e = line.potential(7.0);
1451        assert!((e - (-0.0592 * 7.0)).abs() < 1e-10);
1452    }
1453
1454    #[test]
1455    fn test_pourbaix_line_ph_at_potential() {
1456        let line = PourbaixLine::new("test", 0.0, -0.0592);
1457        // E = -0.0592 * pH  →  pH = E / -0.0592
1458        let e_target = -0.0592 * 7.0; // exact value for pH=7
1459        let ph = line.ph_at_potential(e_target);
1460        assert!(ph.is_some());
1461        assert!(
1462            (ph.unwrap() - 7.0).abs() < 1e-9,
1463            "expected pH≈7 but got {}",
1464            ph.unwrap()
1465        );
1466    }
1467
1468    #[test]
1469    fn test_pourbaix_iron_lines_count() {
1470        let d = PourbaixDiagram::iron();
1471        assert!(d.lines.len() >= 5);
1472    }
1473
1474    #[test]
1475    fn test_pourbaix_potentials_at_ph_sorted() {
1476        let d = PourbaixDiagram::iron();
1477        let potentials = d.potentials_at_ph(7.0);
1478        for i in 1..potentials.len() {
1479            assert!(potentials[i].1 >= potentials[i - 1].1);
1480        }
1481    }
1482
1483    // ── Diffusion-limited corrosion ───────────────────────────────────────────
1484
1485    #[test]
1486    fn test_diffusion_limiting_current_positive() {
1487        // Oxygen reduction in seawater: D≈2e-9 m²/s, c≈8 mol/m³ (≈8 mg/L), δ≈0.1 mm
1488        let model = DiffusionLimitedCorrosion::new(4.0, 2e-9, 8.0, 1e-4);
1489        assert!(model.limiting_current() > 0.0);
1490    }
1491
1492    #[test]
1493    fn test_diffusion_corrosion_rate_positive() {
1494        let model = DiffusionLimitedCorrosion::new(4.0, 2e-9, 8.0, 1e-4);
1495        let rate = model.corrosion_rate_mm_per_year(55.845, 2.0, 7.87);
1496        assert!(rate > 0.0);
1497    }
1498}