1#![allow(dead_code)]
19
20pub const FARADAY: f64 = 96_485.332_12;
26
27pub const GAS_CONSTANT: f64 = 8.314_462_618;
29
30#[inline]
36fn clamp(x: f64, lo: f64, hi: f64) -> f64 {
37 x.max(lo).min(hi)
38}
39
40#[inline]
42fn safe_ln(x: f64) -> f64 {
43 if x <= 0.0 { f64::NEG_INFINITY } else { x.ln() }
44}
45
46#[derive(Debug, Clone)]
55pub struct ButlerVolmer {
56 pub i0: f64,
58 pub alpha_a: f64,
60 pub alpha_c: f64,
62 pub temperature: f64,
64}
65
66impl ButlerVolmer {
67 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 #[inline]
79 pub fn f_over_rt(&self) -> f64 {
80 FARADAY / (GAS_CONSTANT * self.temperature.max(1e-6))
81 }
82
83 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 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 pub fn tafel_slope_anodic(&self) -> f64 {
101 std::f64::consts::LN_10 / (self.alpha_a * self.f_over_rt())
102 }
103
104 pub fn tafel_slope_cathodic(&self) -> f64 {
108 std::f64::consts::LN_10 / (self.alpha_c * self.f_over_rt())
109 }
110
111 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#[derive(Debug, Clone)]
137pub struct EvansDiagram {
138 pub anodic: ButlerVolmer,
140 pub e_eq_anodic: f64,
142 pub cathodic: ButlerVolmer,
144 pub e_eq_cathodic: f64,
146}
147
148impl EvansDiagram {
149 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 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 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 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
209pub fn corrosion_mass_rate(i_corr: f64, molar_mass: f64, n_electrons: f64) -> f64 {
220 i_corr * molar_mass / (n_electrons * FARADAY)
221}
222
223pub fn corrosion_rate_mm_per_year(
231 i_corr: f64,
232 molar_mass: f64,
233 n_electrons: f64,
234 density: f64,
235) -> f64 {
236 let mass_rate = corrosion_mass_rate(i_corr, molar_mass, n_electrons);
239 let thickness_rate_m_per_s = mass_rate / (density * 1e6); thickness_rate_m_per_s * 1e3 * 3.156e7 }
242
243pub 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#[derive(Debug, Clone)]
259pub struct PassivationModel {
260 pub flade_potential: f64,
262 pub i_passivation: f64,
264 pub i_passive: f64,
266 pub e_transpassive: f64,
268 pub active_bv: ButlerVolmer,
270}
271
272impl PassivationModel {
273 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 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; self.active_bv.current_density(eta).max(0.0)
300 } else if e < self.e_transpassive {
301 self.i_passive
302 } else {
303 self.i_passive + (e - self.e_transpassive) * self.i_passivation
305 }
306 }
307
308 pub fn is_passive(&self, e: f64) -> bool {
310 e >= self.flade_potential && e < self.e_transpassive
311 }
312
313 pub fn passive_range(&self) -> f64 {
315 (self.e_transpassive - self.flade_potential).max(0.0)
316 }
317}
318
319#[derive(Debug, Clone)]
328pub struct PittingModel {
329 pub e_pit: f64,
331 pub e_rp: f64,
333 pub critical_chloride: f64,
335 pub pit_growth_k: f64,
337}
338
339impl PittingModel {
340 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 pub fn hysteresis_width(&self) -> f64 {
354 (self.e_pit - self.e_rp).max(0.0)
355 }
356
357 pub fn is_pitting_active(&self, e: f64) -> bool {
359 e >= self.e_pit
360 }
361
362 pub fn will_repassivate(&self, e: f64) -> bool {
364 e < self.e_rp
365 }
366
367 pub fn pit_radius(&self, i_pit: f64, t: f64, molar_mass: f64, n: f64, density: f64) -> f64 {
379 let rho_si = density * 1e6; 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 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 } else {
397 Some(material_constant_s / (ratio - 1.0))
398 }
399 }
400}
401
402#[derive(Debug, Clone)]
411pub struct GalvanicPair {
412 pub anode: ButlerVolmer,
414 pub e_eq_anode: f64,
416 pub cathode: ButlerVolmer,
418 pub e_eq_cathode: f64,
420 pub area_ratio: f64,
422}
423
424impl GalvanicPair {
425 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 pub fn driving_force(&self) -> f64 {
444 (self.e_eq_cathode - self.e_eq_anode).abs()
445 }
446
447 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 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#[derive(Debug, Clone)]
491pub struct SccrModel {
492 pub k_iscc: f64,
494 pub k_ic: f64,
496 pub v_plateau: f64,
498 pub stage1_exponent: f64,
500 pub stage1_coeff: f64,
502}
503
504impl SccrModel {
505 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 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 #[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 pub fn susceptibility_index(&self) -> f64 {
586 (self.k_iscc / self.k_ic.max(1e-30)).min(1.0)
587 }
588}
589
590#[derive(Debug, Clone)]
600pub struct CreviceModel {
601 pub gap: f64,
603 pub depth: f64,
605 pub resistivity: f64,
607 pub e_crevice_critical: f64,
609 pub i_passive: f64,
611}
612
613impl CreviceModel {
614 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 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 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 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#[derive(Debug, Clone)]
670pub struct DealloyingModel {
671 pub x0: f64,
673 pub e_threshold: f64,
675 pub d_alloy: f64,
677 pub k_dissolution: f64,
679}
680
681impl DealloyingModel {
682 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 pub fn is_active(&self, e: f64) -> bool {
694 e >= self.e_threshold
695 }
696
697 pub fn layer_thickness(&self, t: f64) -> f64 {
701 (2.0 * self.d_alloy * t).sqrt()
702 }
703
704 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 pub fn dissolved_moles_per_area(&self, t: f64) -> f64 {
714 self.k_dissolution * t
715 }
716}
717
718#[derive(Debug, Clone)]
727pub struct ImpressedCurrentCp {
728 pub surface_area: f64,
730 pub protective_current_density: f64,
732 pub efficiency: f64,
734 pub design_life_years: f64,
736}
737
738impl ImpressedCurrentCp {
739 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 pub fn required_current(&self) -> f64 {
756 self.surface_area * self.protective_current_density
757 }
758
759 pub fn rectifier_current(&self) -> f64 {
761 self.required_current() / self.efficiency.max(1e-6)
762 }
763
764 pub fn total_charge_ah(&self) -> f64 {
766 self.rectifier_current() * self.design_life_years * 8760.0
767 }
768}
769
770#[derive(Debug, Clone)]
775pub struct SacrificialAnode {
776 pub electrochemical_capacity: f64,
779 pub current_output: f64,
781 pub design_life_years: f64,
783 pub current_efficiency: f64,
785}
786
787impl SacrificialAnode {
788 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 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 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#[derive(Debug, Clone)]
826pub struct CorrosionInhibitor {
827 pub langmuir_k: f64,
829 pub concentration: f64,
831 pub uninhibited_rate: f64,
833}
834
835impl CorrosionInhibitor {
836 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 pub fn surface_coverage(&self) -> f64 {
849 let kc = self.langmuir_k * self.concentration;
850 kc / (1.0 + kc)
851 }
852
853 pub fn inhibited_rate(&self) -> f64 {
855 self.uninhibited_rate * (1.0 - self.surface_coverage())
856 }
857
858 pub fn efficiency_percent(&self) -> f64 {
860 100.0 * self.surface_coverage()
861 }
862
863 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#[derive(Debug, Clone)]
883pub struct NernstEquation {
884 pub e_standard: f64,
886 pub n_electrons: f64,
888 pub temperature: f64,
890}
891
892impl NernstEquation {
893 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 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 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#[derive(Debug, Clone)]
924pub struct PourbaixLine {
925 pub label: String,
927 pub a: f64,
929 pub b: f64,
931}
932
933impl PourbaixLine {
934 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 pub fn potential(&self, ph: f64) -> f64 {
945 self.a + self.b * ph
946 }
947
948 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#[derive(Debug, Clone)]
962pub struct PourbaixDiagram {
963 pub lines: Vec<PourbaixLine>,
965 pub metal: String,
967}
968
969impl PourbaixDiagram {
970 pub fn new(metal: &str) -> Self {
972 Self {
973 metal: metal.to_string(),
974 lines: Vec::new(),
975 }
976 }
977
978 pub fn add_line(&mut self, line: PourbaixLine) {
980 self.lines.push(line);
981 }
982
983 pub fn iron() -> Self {
985 let mut d = Self::new("Fe");
986 d.add_line(PourbaixLine::new("Fe/Fe2+", -0.44, -0.0592));
988 d.add_line(PourbaixLine::new("Fe2+/Fe3O4", -0.059, -0.0888));
990 d.add_line(PourbaixLine::new("Fe3O4/Fe2O3", 0.221, -0.0592));
992 d.add_line(PourbaixLine::new("H2/H2O", 0.0, -0.0592));
994 d.add_line(PourbaixLine::new("O2/H2O", 1.229, -0.0592));
996 d
997 }
998
999 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#[derive(Debug, Clone)]
1021pub struct DiffusionLimitedCorrosion {
1022 pub n_electrons: f64,
1024 pub diffusivity: f64,
1026 pub c_bulk: f64,
1028 pub delta: f64,
1030}
1031
1032impl DiffusionLimitedCorrosion {
1033 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 pub fn limiting_current(&self) -> f64 {
1045 self.n_electrons * FARADAY * self.diffusivity * self.c_bulk / self.delta.max(1e-15)
1046 }
1047
1048 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#[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 #[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(); 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 #[test]
1127 fn test_evans_corrosion_potential_found() {
1128 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 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 #[test]
1163 fn test_corrosion_mass_rate_positive() {
1164 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 #[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 #[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); 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 #[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 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 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 #[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 #[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 let d = model.critical_depth(0.3);
1334 assert!(d.is_none());
1335 }
1336
1337 #[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 #[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 #[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); let c = inh.concentration_for_efficiency(0.9);
1414 assert!(c.is_some());
1415 assert!(c.unwrap() > 0.0);
1416 }
1417
1418 #[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 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 #[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 let e_target = -0.0592 * 7.0; 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 #[test]
1486 fn test_diffusion_limiting_current_positive() {
1487 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}