1#![allow(dead_code)]
20#![allow(clippy::too_many_arguments)]
21
22pub const FARADAY: f64 = 96_485.0;
24pub const GAS_CONSTANT: f64 = 8.314_462_618;
26
27#[derive(Debug, Clone, PartialEq)]
31pub enum ElectrodeType {
32 Graphite,
34 LFP,
36 NMC,
38 LCO,
40 Silicon,
42 Custom(String),
44}
45
46#[derive(Debug, Clone)]
50pub struct ElectrodeMaterial {
51 pub electrode_type: ElectrodeType,
53 pub specific_capacity_mah_g: f64,
55 pub average_voltage: f64,
57 pub density_g_cm3: f64,
59 pub intercalation_strain: f64,
61}
62
63impl ElectrodeMaterial {
64 pub fn new(
66 electrode_type: ElectrodeType,
67 specific_capacity_mah_g: f64,
68 average_voltage: f64,
69 density_g_cm3: f64,
70 intercalation_strain: f64,
71 ) -> Self {
72 Self {
73 electrode_type,
74 specific_capacity_mah_g,
75 average_voltage,
76 density_g_cm3,
77 intercalation_strain,
78 }
79 }
80
81 pub fn volumetric_energy_density(&self) -> f64 {
86 self.specific_capacity_mah_g * self.average_voltage * self.density_g_cm3
87 }
88
89 pub fn gravimetric_energy_density(&self) -> f64 {
93 self.specific_capacity_mah_g * self.average_voltage
94 }
95
96 pub fn graphite() -> Self {
98 Self::new(ElectrodeType::Graphite, 372.0, 0.1, 2.26, 0.1)
99 }
100
101 pub fn lfp() -> Self {
103 Self::new(ElectrodeType::LFP, 170.0, 3.4, 3.6, 0.06)
104 }
105
106 pub fn nmc() -> Self {
108 Self::new(ElectrodeType::NMC, 200.0, 3.7, 4.7, 0.05)
109 }
110
111 pub fn lco() -> Self {
113 Self::new(ElectrodeType::LCO, 140.0, 3.9, 5.1, 0.04)
114 }
115
116 pub fn silicon() -> Self {
118 Self::new(ElectrodeType::Silicon, 3580.0, 0.4, 2.33, 0.4)
119 }
120}
121
122#[derive(Debug, Clone)]
126pub struct ElectrolyteMaterial {
127 pub ionic_conductivity_s_m: f64,
129 pub electrochemical_window: Vec<f64>,
131 pub transference_number: f64,
133 pub viscosity: f64,
135}
136
137impl ElectrolyteMaterial {
138 pub fn new(
140 ionic_conductivity_s_m: f64,
141 electrochemical_window: Vec<f64>,
142 transference_number: f64,
143 viscosity: f64,
144 ) -> Self {
145 Self {
146 ionic_conductivity_s_m,
147 electrochemical_window,
148 transference_number,
149 viscosity,
150 }
151 }
152
153 pub fn lipf6_ec_dmc() -> Self {
155 Self::new(1.0, vec![0.0, 4.5], 0.38, 2.5e-3)
156 }
157
158 pub fn stability_window_width(&self) -> f64 {
160 if self.electrochemical_window.len() >= 2 {
161 self.electrochemical_window[1] - self.electrochemical_window[0]
162 } else {
163 0.0
164 }
165 }
166}
167
168#[derive(Debug, Clone)]
172pub struct SolidElectrolyteInterphase {
173 pub thickness_nm: f64,
175 pub ionic_resistance: f64,
177 pub activation_energy: f64,
179}
180
181impl SolidElectrolyteInterphase {
182 pub fn new(thickness_nm: f64, ionic_resistance: f64, activation_energy: f64) -> Self {
184 Self {
185 thickness_nm,
186 ionic_resistance,
187 activation_energy,
188 }
189 }
190
191 pub fn growth_rate(&self, temperature: f64, soc: f64) -> f64 {
197 let k0 = 1.0e-3; let soc_factor = (1.0 - soc).max(0.0);
199 let arrhenius = (-self.activation_energy / (GAS_CONSTANT * temperature)).exp();
200 let thickness = self.thickness_nm.max(1e-10);
201 k0 / thickness * arrhenius * soc_factor
202 }
203}
204
205#[derive(Debug, Clone)]
212pub struct DiffusionInElectrode {
213 pub diffusivity: f64,
215 pub particle_radius: f64,
217 pub c_surface: f64,
219 pub c_bulk: f64,
221 pub c_max: f64,
223}
224
225impl DiffusionInElectrode {
226 pub fn new(diffusivity: f64, particle_radius: f64, c_surface: f64, c_bulk: f64) -> Self {
228 Self {
229 diffusivity,
230 particle_radius,
231 c_surface,
232 c_bulk,
233 c_max: 30_000.0,
234 }
235 }
236
237 pub fn with_c_max(
239 diffusivity: f64,
240 particle_radius: f64,
241 c_surface: f64,
242 c_bulk: f64,
243 c_max: f64,
244 ) -> Self {
245 Self {
246 diffusivity,
247 particle_radius,
248 c_surface,
249 c_bulk,
250 c_max,
251 }
252 }
253
254 pub fn concentration_profile(&self, r: f64) -> f64 {
259 let r_clamped = r.clamp(0.0, 1.0);
260 self.c_bulk + (self.c_surface - self.c_bulk) * r_clamped * r_clamped
261 }
262
263 pub fn diffusion_length(&self) -> f64 {
267 self.particle_radius
269 }
270
271 pub fn diffusion_time_scale(&self) -> f64 {
273 self.particle_radius * self.particle_radius / self.diffusivity.max(1e-30)
274 }
275
276 pub fn max_c_rate(&self) -> f64 {
281 let tau_s = self.particle_radius * self.particle_radius
282 / (std::f64::consts::PI * std::f64::consts::PI * self.diffusivity.max(1e-30));
283 let tau_h = tau_s / 3600.0;
284 1.0 / tau_h.max(1e-30)
285 }
286
287 pub fn surface_concentration_gradient(&self) -> f64 {
291 (self.c_surface - self.c_bulk) / self.particle_radius.max(1e-30)
292 }
293
294 pub fn soc_from_concentration(&self) -> f64 {
298 let c_avg = self.c_bulk + 0.6 * (self.c_surface - self.c_bulk);
299 (c_avg / self.c_max.max(1e-30)).clamp(0.0, 1.0)
300 }
301
302 pub fn surface_flux(&self) -> f64 {
306 -self.diffusivity * self.surface_concentration_gradient()
307 }
308}
309
310#[derive(Debug, Clone)]
317pub struct ElectrodeKinetics {
318 pub i0: f64,
320 pub alpha_a: f64,
322 pub alpha_c: f64,
324 pub temperature: f64,
326 pub n_electrons: usize,
328}
329
330impl ElectrodeKinetics {
331 pub fn new(i0: f64, alpha_a: f64, alpha_c: f64, temperature: f64, n_electrons: usize) -> Self {
333 Self {
334 i0,
335 alpha_a,
336 alpha_c,
337 temperature,
338 n_electrons,
339 }
340 }
341
342 pub fn symmetric(i0: f64, temperature: f64) -> Self {
344 Self::new(i0, 0.5, 0.5, temperature, 1)
345 }
346
347 pub fn current_density(&self, eta: f64) -> f64 {
351 let f_over_rt = FARADAY / (GAS_CONSTANT * self.temperature);
352 self.i0 * ((self.alpha_a * f_over_rt * eta).exp() - (-self.alpha_c * f_over_rt * eta).exp())
353 }
354
355 pub fn anodic_tafel_slope(&self) -> f64 {
359 2.303 * GAS_CONSTANT * self.temperature / (self.alpha_a * FARADAY)
360 }
361
362 pub fn cathodic_tafel_slope(&self) -> f64 {
366 2.303 * GAS_CONSTANT * self.temperature / (self.alpha_c * FARADAY)
367 }
368
369 pub fn charge_transfer_resistance(&self) -> f64 {
373 GAS_CONSTANT * self.temperature / (self.n_electrons as f64 * FARADAY * self.i0.max(1e-30))
374 }
375
376 pub fn eis_impedance(&self, omega: f64) -> (f64, f64) {
384 let r_ct = self.charge_transfer_resistance();
385 let c_dl = 0.2_f64; let denom = 1.0 + (omega * r_ct * c_dl).powi(2);
387 let z_real = r_ct / denom;
388 let z_imag = -omega * r_ct * r_ct * c_dl / denom;
389 (z_real, z_imag)
390 }
391
392 pub fn eis_magnitude(&self, omega: f64) -> f64 {
394 let (zr, zi) = self.eis_impedance(omega);
395 (zr * zr + zi * zi).sqrt()
396 }
397
398 pub fn tafel_overpotential_anodic(&self, j: f64) -> f64 {
403 if j <= 0.0 || j <= self.i0 {
404 return 0.0;
405 }
406 self.anodic_tafel_slope() * (j / self.i0).log10()
407 }
408
409 pub fn nernst_correction(&self, e_ref: f64, c_ratio: f64) -> f64 {
413 let n = self.n_electrons as f64;
414 e_ref - GAS_CONSTANT * self.temperature / (n * FARADAY) * c_ratio.max(1e-300).ln()
415 }
416}
417
418#[derive(Debug, Clone)]
425pub struct SolidElectrolyte {
426 pub sigma_0: f64,
428 pub activation_energy: f64,
430 pub transference_number: f64,
432 pub electronic_conductivity: f64,
434 pub grain_boundary_resistance: f64,
436}
437
438impl SolidElectrolyte {
439 pub fn new(
441 sigma_0: f64,
442 activation_energy: f64,
443 transference_number: f64,
444 electronic_conductivity: f64,
445 grain_boundary_resistance: f64,
446 ) -> Self {
447 Self {
448 sigma_0,
449 activation_energy,
450 transference_number,
451 electronic_conductivity,
452 grain_boundary_resistance,
453 }
454 }
455
456 pub fn llzo() -> Self {
458 Self::new(1.0e5, 30_000.0, 0.99, 1e-8, 1.0e-3)
460 }
461
462 pub fn lgps() -> Self {
464 Self::new(2.0e5, 24_000.0, 0.98, 1e-7, 5.0e-4)
466 }
467
468 pub fn ionic_conductivity(&self, temperature: f64) -> f64 {
472 self.sigma_0 * (-self.activation_energy / (GAS_CONSTANT * temperature)).exp()
473 }
474
475 pub fn apparent_activation_energy_ev(&self, _t1: f64, _t2: f64) -> f64 {
479 let ev_per_j_per_mol = 1.0 / (GAS_CONSTANT * 96_485.0 / FARADAY);
480 self.activation_energy / (GAS_CONSTANT * 96_485.0 / FARADAY) * ev_per_j_per_mol
482 }
483
484 pub fn effective_conductivity(&self, temperature: f64, thickness: f64) -> f64 {
488 let sigma_bulk = self.ionic_conductivity(temperature);
489 let r_bulk = thickness / sigma_bulk.max(1e-30);
490 let r_total = r_bulk + self.grain_boundary_resistance;
491 thickness / r_total.max(1e-30)
492 }
493
494 pub fn li_transference_number(&self) -> f64 {
498 self.transference_number
499 }
500
501 pub fn is_predominantly_ionic(&self, temperature: f64) -> bool {
505 let sigma_ion = self.ionic_conductivity(temperature);
506 self.electronic_conductivity / sigma_ion.max(1e-30) < 1e-6
507 }
508
509 pub fn geis_impedance(&self, temperature: f64, omega: f64) -> (f64, f64) {
514 let sigma = self.ionic_conductivity(temperature);
515 let epsilon_r = 30.0_f64;
516 let epsilon_0 = 8.854e-12_f64; let tau = epsilon_0 * epsilon_r / sigma.max(1e-30);
518 let r_bulk = 1.0 / sigma.max(1e-30); let denom = 1.0 + (omega * tau).powi(2);
520 let z_real = r_bulk / denom;
521 let z_imag = -omega * tau * r_bulk / denom;
522 (z_real, z_imag)
523 }
524
525 pub fn relaxation_time(&self, temperature: f64) -> f64 {
527 let sigma = self.ionic_conductivity(temperature);
528 let epsilon_r = 30.0_f64;
529 let epsilon_0 = 8.854e-12_f64;
530 epsilon_0 * epsilon_r / sigma.max(1e-30)
531 }
532}
533
534#[derive(Debug, Clone)]
541pub struct BatteryDegradation {
542 pub initial_capacity: f64,
544 pub cycle_count: usize,
546 pub sei_thickness_nm: f64,
548 pub sei_k: f64,
550 pub plating_onset_eta: f64,
552 pub dead_li_fraction: f64,
554 pub dead_li_capacity: f64,
556 pub calendar_fade_rate: f64,
558}
559
560impl BatteryDegradation {
561 pub fn new(
563 initial_capacity: f64,
564 sei_k: f64,
565 plating_onset_eta: f64,
566 dead_li_fraction: f64,
567 calendar_fade_rate: f64,
568 ) -> Self {
569 Self {
570 initial_capacity,
571 cycle_count: 0,
572 sei_thickness_nm: 2.0, sei_k,
574 plating_onset_eta,
575 dead_li_fraction,
576 dead_li_capacity: 0.0,
577 calendar_fade_rate,
578 }
579 }
580
581 pub fn cycle(&mut self, eta_anode: f64, plated_capacity_ah: f64) {
586 self.cycle_count += 1;
587 let n = self.cycle_count as f64;
589 self.sei_thickness_nm = (2.0_f64.powi(2) + self.sei_k * n).sqrt();
590 if eta_anode < self.plating_onset_eta {
592 self.dead_li_capacity += plated_capacity_ah * self.dead_li_fraction;
593 }
594 }
595
596 pub fn current_capacity(&self) -> f64 {
600 let sei_capacity_loss = self.sei_thickness_nm * 1e-9 * 1e4; (self.initial_capacity - self.dead_li_capacity - sei_capacity_loss).max(0.0)
602 }
603
604 pub fn capacity_retention(&self) -> f64 {
606 self.current_capacity() / self.initial_capacity.max(1e-30)
607 }
608
609 pub fn sei_resistance(&self) -> f64 {
613 let sigma_sei = 1e-7_f64; self.sei_thickness_nm * 1e-9 / sigma_sei
615 }
616
617 pub fn is_plating(&self, eta: f64) -> bool {
619 eta < self.plating_onset_eta
620 }
621
622 pub fn predict_cycle_life(&self, retention_target: f64) -> f64 {
626 if retention_target >= 1.0 {
627 return 0.0;
628 }
629 let q_target = self.initial_capacity * retention_target;
630 let q_loss_target = self.initial_capacity - q_target;
631 if self.sei_k > 0.0 {
634 (q_loss_target / (self.sei_k.sqrt() + 1e-30)).powi(2)
635 } else {
636 f64::INFINITY
637 }
638 }
639}
640
641#[derive(Debug, Clone)]
645pub struct BatteryAgingModel {
646 pub cycle_count: usize,
648 pub capacity_fade_rate: f64,
650 pub resistance_growth_rate: f64,
652 pub initial_capacity: f64,
654}
655
656impl BatteryAgingModel {
657 pub fn new(
659 initial_capacity: f64,
660 capacity_fade_rate: f64,
661 resistance_growth_rate: f64,
662 ) -> Self {
663 Self {
664 cycle_count: 0,
665 initial_capacity,
666 capacity_fade_rate,
667 resistance_growth_rate,
668 }
669 }
670
671 pub fn capacity_at_cycle(&self, n: usize) -> f64 {
675 let fade = self.capacity_fade_rate * n as f64;
676 self.initial_capacity * (1.0 - fade).max(0.0)
677 }
678
679 pub fn resistance_at_cycle(&self, n: usize) -> f64 {
683 1.0 + self.resistance_growth_rate * n as f64
684 }
685
686 pub fn cycle_life(&self, threshold: f64) -> usize {
688 if self.capacity_fade_rate <= 0.0 {
689 return usize::MAX;
690 }
691 let cycles = (1.0 - threshold) / self.capacity_fade_rate;
692 cycles.floor() as usize
693 }
694}
695
696#[derive(Debug, Clone)]
703pub struct ThermalBattery {
704 pub thermal_mass: f64,
706 pub cooling_coefficient: f64,
708 pub ambient_temperature: f64,
710 pub temperature: f64,
712 pub entropic_coefficient: f64,
714}
715
716impl ThermalBattery {
717 pub fn new(
719 thermal_mass: f64,
720 cooling_coefficient: f64,
721 ambient_temperature: f64,
722 entropic_coefficient: f64,
723 ) -> Self {
724 Self {
725 thermal_mass,
726 cooling_coefficient,
727 ambient_temperature,
728 temperature: ambient_temperature,
729 entropic_coefficient,
730 }
731 }
732
733 pub fn bernardi_heat_generation(&self, current: f64, eta_total: f64, r_ohm: f64) -> f64 {
742 let irreversible = current * eta_total + current * current * r_ohm;
743 let reversible = current * self.temperature * self.entropic_coefficient;
744 irreversible - reversible
745 }
746
747 pub fn step(&mut self, q_gen: f64, dt: f64) {
751 let q_cool = self.cooling_coefficient * (self.temperature - self.ambient_temperature);
752 let d_t_dt = (q_gen - q_cool) / self.thermal_mass.max(1e-30);
753 self.temperature += d_t_dt * dt;
754 }
755
756 pub fn steady_state_temperature(&self, q_gen: f64) -> f64 {
760 self.ambient_temperature + q_gen / self.cooling_coefficient.max(1e-30)
761 }
762
763 pub fn temperature_rise(&self) -> f64 {
765 self.temperature - self.ambient_temperature
766 }
767
768 pub fn thermal_runaway_risk(&self, t_threshold: f64) -> bool {
770 self.temperature >= t_threshold
771 }
772
773 pub fn temperature_non_uniformity(&self, q_gen: f64, length: f64, k_thermal: f64) -> f64 {
778 q_gen * length * length / (8.0 * k_thermal.max(1e-30))
779 }
780
781 pub fn time_to_temperature(&self, t_target: f64, q_gen: f64) -> f64 {
786 let t_ss = self.steady_state_temperature(q_gen);
787 if (t_target - t_ss).abs() < 1e-10 {
788 return f64::INFINITY;
789 }
790 let ratio = (t_target - t_ss) / (self.temperature - t_ss);
791 if ratio <= 0.0 {
792 return f64::INFINITY;
793 }
794 -self.thermal_mass / self.cooling_coefficient.max(1e-30) * ratio.ln()
795 }
796}
797
798#[derive(Debug, Clone)]
805pub struct BatteryCycling {
806 pub nominal_capacity: f64,
808 pub soc: f64,
810 pub v_max: f64,
812 pub v_min: f64,
814 pub charge_current: f64,
816 pub cv_cutoff_current: f64,
818 pub charge_delivered: f64,
820 pub discharge_delivered: f64,
822 pub coulombic_efficiency: f64,
824 pub cycle_count: usize,
826 pub r_internal: f64,
828 pub ocv_range: [f64; 2],
830}
831
832impl BatteryCycling {
833 pub fn new(
835 nominal_capacity: f64,
836 v_max: f64,
837 v_min: f64,
838 charge_current: f64,
839 cv_cutoff_current: f64,
840 r_internal: f64,
841 ) -> Self {
842 Self {
843 nominal_capacity,
844 soc: 0.0,
845 v_max,
846 v_min,
847 charge_current,
848 cv_cutoff_current,
849 charge_delivered: 0.0,
850 discharge_delivered: 0.0,
851 coulombic_efficiency: 1.0,
852 cycle_count: 0,
853 r_internal,
854 ocv_range: [v_min, v_max],
855 }
856 }
857
858 pub fn ocv(&self, soc: f64) -> f64 {
862 self.ocv_range[0] + soc * (self.ocv_range[1] - self.ocv_range[0])
863 }
864
865 pub fn terminal_voltage_charge(&self) -> f64 {
869 self.ocv(self.soc) + self.charge_current * self.r_internal
870 }
871
872 pub fn terminal_voltage_discharge(&self, i_dis: f64) -> f64 {
876 self.ocv(self.soc) - i_dis * self.r_internal
877 }
878
879 pub fn cc_charge_step(&mut self, dt: f64) -> bool {
883 let dq = self.charge_current * dt; self.soc = (self.soc + dq / self.nominal_capacity).min(1.0);
885 self.charge_delivered += dq;
886 self.terminal_voltage_charge() >= self.v_max
887 }
888
889 pub fn cc_discharge_step(&mut self, i_dis: f64, dt: f64) -> bool {
893 let dq = i_dis * dt;
894 self.soc = (self.soc - dq / self.nominal_capacity).max(0.0);
895 self.discharge_delivered += dq;
896 self.terminal_voltage_discharge(i_dis) <= self.v_min
897 }
898
899 pub fn full_cycle(&mut self, i_dis: f64, dt: f64, n_steps: usize) {
903 let q_charge_start = self.charge_delivered;
904 let q_dis_start = self.discharge_delivered;
905
906 for _ in 0..n_steps {
908 if self.cc_charge_step(dt) {
909 break;
910 }
911 }
912 for _ in 0..n_steps {
914 if self.cc_discharge_step(i_dis, dt) {
915 break;
916 }
917 }
918
919 let q_charged = self.charge_delivered - q_charge_start;
920 let q_discharged = self.discharge_delivered - q_dis_start;
921 if q_charged > 1e-15 {
922 self.coulombic_efficiency = (q_discharged / q_charged).min(1.0);
923 }
924 self.cycle_count += 1;
925 }
926
927 pub fn c_rate(&self) -> f64 {
931 self.charge_current / self.nominal_capacity.max(1e-30)
932 }
933
934 pub fn cc_charge_time(&self, soc_target: f64) -> f64 {
938 let delta_soc = (soc_target - self.soc).max(0.0);
939 delta_soc * self.nominal_capacity / self.charge_current.max(1e-30)
940 }
941
942 pub fn energy_efficiency(&self, v_avg_discharge: f64, v_avg_charge: f64) -> f64 {
946 self.coulombic_efficiency * v_avg_discharge / v_avg_charge.max(1e-30)
947 }
948
949 pub fn reset_cycle_counters(&mut self) {
951 self.charge_delivered = 0.0;
952 self.discharge_delivered = 0.0;
953 }
954}
955
956pub fn butler_volmer(i0: f64, alpha_a: f64, alpha_c: f64, eta: f64, t: f64) -> f64 {
969 let f_over_rt = FARADAY / (GAS_CONSTANT * t);
970 i0 * ((alpha_a * f_over_rt * eta).exp() - (-alpha_c * f_over_rt * eta).exp())
971}
972
973pub fn nernst_equation(e0: f64, r: f64, t: f64, n: usize, q: f64) -> f64 {
986 let n_f64 = n as f64;
987 e0 - (r * t / (n_f64 * FARADAY)) * q.max(1e-300).ln()
988}
989
990#[cfg(test)]
993mod tests {
994 use super::*;
995
996 const EPS: f64 = 1e-9;
997
998 #[test]
1000 fn test_bv_zero_overpotential() {
1001 let j = butler_volmer(1.0, 0.5, 0.5, 0.0, 298.15);
1002 assert!(j.abs() < EPS, "BV at η=0 should be 0, got {j}");
1003 }
1004
1005 #[test]
1007 fn test_bv_positive_overpotential() {
1008 let j = butler_volmer(1.0, 0.5, 0.5, 0.1, 298.15);
1009 assert!(
1010 j > 0.0,
1011 "Positive overpotential should give positive current, got {j}"
1012 );
1013 }
1014
1015 #[test]
1017 fn test_bv_negative_overpotential() {
1018 let j = butler_volmer(1.0, 0.5, 0.5, -0.1, 298.15);
1019 assert!(
1020 j < 0.0,
1021 "Negative overpotential should give negative current, got {j}"
1022 );
1023 }
1024
1025 #[test]
1027 fn test_bv_scales_with_i0() {
1028 let j1 = butler_volmer(1.0, 0.5, 0.5, 0.05, 298.15);
1029 let j2 = butler_volmer(2.0, 0.5, 0.5, 0.05, 298.15);
1030 assert!(
1031 (j2 - 2.0 * j1).abs() < 1e-10,
1032 "BV should scale linearly with i0"
1033 );
1034 }
1035
1036 #[test]
1038 fn test_nernst_q_unity() {
1039 let e = nernst_equation(1.5, GAS_CONSTANT, 298.15, 1, 1.0);
1040 assert!(
1041 (e - 1.5).abs() < EPS,
1042 "Nernst at Q=1 should equal E0=1.5, got {e}"
1043 );
1044 }
1045
1046 #[test]
1048 fn test_nernst_q_greater_one() {
1049 let e = nernst_equation(1.5, GAS_CONSTANT, 298.15, 1, 10.0);
1050 assert!(e < 1.5, "Nernst at Q>1 should reduce potential");
1051 }
1052
1053 #[test]
1055 fn test_nernst_q_less_one() {
1056 let e = nernst_equation(1.5, GAS_CONSTANT, 298.15, 1, 0.1);
1057 assert!(e > 1.5, "Nernst at Q<1 should increase potential");
1058 }
1059
1060 #[test]
1062 fn test_nernst_temperature_dependence() {
1063 let e_low = nernst_equation(0.0, GAS_CONSTANT, 200.0, 1, 10.0);
1064 let e_high = nernst_equation(0.0, GAS_CONSTANT, 400.0, 1, 10.0);
1065 assert!(
1066 e_high.abs() > e_low.abs(),
1067 "Higher T should give larger Nernst correction"
1068 );
1069 }
1070
1071 #[test]
1073 fn test_graphite_volumetric_energy() {
1074 let g = ElectrodeMaterial::graphite();
1075 assert!(g.volumetric_energy_density() > 0.0);
1076 }
1077
1078 #[test]
1080 fn test_silicon_gravimetric_higher_than_nmc() {
1081 let si = ElectrodeMaterial::silicon();
1082 let nmc = ElectrodeMaterial::nmc();
1083 assert!(
1084 si.gravimetric_energy_density() > nmc.gravimetric_energy_density(),
1085 "Silicon should have higher gravimetric energy than NMC"
1086 );
1087 }
1088
1089 #[test]
1091 fn test_lfp_voltage() {
1092 let lfp = ElectrodeMaterial::lfp();
1093 assert!((lfp.average_voltage - 3.4).abs() < 0.01);
1094 }
1095
1096 #[test]
1098 fn test_lco_density() {
1099 let lco = ElectrodeMaterial::lco();
1100 assert!(lco.density_g_cm3 > 5.0, "LCO density should be > 5 g/cm³");
1101 }
1102
1103 #[test]
1105 fn test_custom_electrode_type() {
1106 let mat = ElectrodeMaterial::new(
1107 ElectrodeType::Custom("SnO2".to_string()),
1108 782.0,
1109 0.6,
1110 6.95,
1111 0.3,
1112 );
1113 assert_eq!(
1114 mat.electrode_type,
1115 ElectrodeType::Custom("SnO2".to_string())
1116 );
1117 }
1118
1119 #[test]
1121 fn test_electrolyte_stability_window() {
1122 let e = ElectrolyteMaterial::lipf6_ec_dmc();
1123 assert!((e.stability_window_width() - 4.5).abs() < 0.01);
1124 }
1125
1126 #[test]
1128 fn test_electrolyte_transference_number_range() {
1129 let e = ElectrolyteMaterial::lipf6_ec_dmc();
1130 assert!(
1131 e.transference_number > 0.0 && e.transference_number < 1.0,
1132 "Transference number must be in (0,1)"
1133 );
1134 }
1135
1136 #[test]
1138 fn test_sei_growth_rate_positive() {
1139 let sei = SolidElectrolyteInterphase::new(5.0, 0.01, 30_000.0);
1140 let rate = sei.growth_rate(298.15, 0.5);
1141 assert!(rate > 0.0, "SEI growth rate should be positive");
1142 }
1143
1144 #[test]
1146 fn test_sei_growth_rate_zero_at_full_soc() {
1147 let sei = SolidElectrolyteInterphase::new(5.0, 0.01, 30_000.0);
1148 let rate = sei.growth_rate(298.15, 1.0);
1149 assert!(rate.abs() < EPS, "SEI growth should stop at SOC=1");
1150 }
1151
1152 #[test]
1154 fn test_sei_growth_rate_decreases_with_thickness() {
1155 let sei_thin = SolidElectrolyteInterphase::new(1.0, 0.01, 30_000.0);
1156 let sei_thick = SolidElectrolyteInterphase::new(10.0, 0.01, 30_000.0);
1157 let r_thin = sei_thin.growth_rate(298.15, 0.2);
1158 let r_thick = sei_thick.growth_rate(298.15, 0.2);
1159 assert!(r_thin > r_thick, "Thinner SEI should grow faster");
1160 }
1161
1162 #[test]
1164 fn test_diffusion_profile_at_centre() {
1165 let d = DiffusionInElectrode::new(1e-14, 5e-6, 25_000.0, 15_000.0);
1166 let c = d.concentration_profile(0.0);
1167 assert!(
1168 (c - d.c_bulk).abs() < EPS,
1169 "Profile at r=0 should equal c_bulk"
1170 );
1171 }
1172
1173 #[test]
1175 fn test_diffusion_profile_at_surface() {
1176 let d = DiffusionInElectrode::new(1e-14, 5e-6, 25_000.0, 15_000.0);
1177 let c = d.concentration_profile(1.0);
1178 assert!(
1179 (c - d.c_surface).abs() < EPS,
1180 "Profile at r=1 should equal c_surface"
1181 );
1182 }
1183
1184 #[test]
1186 fn test_diffusion_profile_monotonic() {
1187 let d = DiffusionInElectrode::new(1e-14, 5e-6, 25_000.0, 15_000.0);
1188 let c0 = d.concentration_profile(0.0);
1189 let c1 = d.concentration_profile(1.0);
1190 let cm = d.concentration_profile(0.5);
1191 assert!(
1192 cm > c0 && cm < c1,
1193 "Profile should be monotonically increasing"
1194 );
1195 }
1196
1197 #[test]
1199 fn test_diffusion_length() {
1200 let r = 7.5e-6;
1201 let d = DiffusionInElectrode::new(1e-14, r, 20_000.0, 10_000.0);
1202 assert!(
1203 (d.diffusion_length() - r).abs() < 1e-15,
1204 "Diffusion length should equal radius"
1205 );
1206 }
1207
1208 #[test]
1210 fn test_diffusion_time_scale() {
1211 let d_val = 1e-14_f64;
1212 let r = 5e-6_f64;
1213 let d = DiffusionInElectrode::new(d_val, r, 0.0, 0.0);
1214 let expected = r * r / d_val;
1215 assert!(
1216 (d.diffusion_time_scale() - expected).abs() < 1.0,
1217 "Diffusion time scale mismatch"
1218 );
1219 }
1220
1221 #[test]
1223 fn test_aging_initial_capacity() {
1224 let model = BatteryAgingModel::new(100.0, 0.0001, 0.0002);
1225 assert!((model.capacity_at_cycle(0) - 100.0).abs() < EPS);
1226 }
1227
1228 #[test]
1230 fn test_aging_capacity_decreases() {
1231 let model = BatteryAgingModel::new(100.0, 0.0001, 0.0002);
1232 let q500 = model.capacity_at_cycle(500);
1233 let q1000 = model.capacity_at_cycle(1000);
1234 assert!(q500 > q1000, "Capacity should decrease with cycles");
1235 }
1236
1237 #[test]
1239 fn test_aging_capacity_non_negative() {
1240 let model = BatteryAgingModel::new(100.0, 0.001, 0.002);
1241 let q = model.capacity_at_cycle(10_000);
1242 assert!(q >= 0.0, "Capacity cannot be negative");
1243 }
1244
1245 #[test]
1247 fn test_aging_resistance_grows() {
1248 let model = BatteryAgingModel::new(100.0, 0.0001, 0.0002);
1249 let r1 = model.resistance_at_cycle(0);
1250 let r2 = model.resistance_at_cycle(500);
1251 assert!(r2 > r1, "Resistance should grow with cycles");
1252 }
1253
1254 #[test]
1256 fn test_aging_cycle_life_80pct() {
1257 let model = BatteryAgingModel::new(100.0, 0.0002, 0.0004);
1258 let life = model.cycle_life(0.8);
1259 assert!(
1260 (life as i64 - 1000).abs() <= 1,
1261 "80% life should be ~1000 cycles"
1262 );
1263 }
1264
1265 #[test]
1267 fn test_nernst_electron_count() {
1268 let corr1 = nernst_equation(0.0, GAS_CONSTANT, 298.15, 1, 10.0);
1269 let corr2 = nernst_equation(0.0, GAS_CONSTANT, 298.15, 2, 10.0);
1270 assert!(
1271 (corr2 - corr1 / 2.0).abs() < 1e-10,
1272 "n=2 gives half correction vs n=1"
1273 );
1274 }
1275
1276 #[test]
1278 fn test_bv_large_overpotential() {
1279 let j = butler_volmer(1.0, 0.5, 0.5, 1.0, 298.15);
1280 assert!(
1281 j > 1e8,
1282 "Large overpotential should give very large current"
1283 );
1284 }
1285
1286 #[test]
1288 fn test_electrode_gravimetric_formula() {
1289 let mat = ElectrodeMaterial::new(ElectrodeType::NMC, 200.0, 3.7, 4.7, 0.05);
1290 let expected = 200.0 * 3.7;
1291 assert!((mat.gravimetric_energy_density() - expected).abs() < EPS);
1292 }
1293
1294 #[test]
1296 fn test_electrode_volumetric_formula() {
1297 let mat = ElectrodeMaterial::new(ElectrodeType::LFP, 170.0, 3.4, 3.6, 0.06);
1298 let expected = 170.0 * 3.4 * 3.6;
1299 assert!((mat.volumetric_energy_density() - expected).abs() < 1e-6);
1300 }
1301
1302 #[test]
1304 fn test_sei_arrhenius_temperature() {
1305 let sei = SolidElectrolyteInterphase::new(3.0, 0.01, 50_000.0);
1306 let r_low = sei.growth_rate(250.0, 0.3);
1307 let r_high = sei.growth_rate(350.0, 0.3);
1308 assert!(
1309 r_high > r_low,
1310 "Higher temperature should increase SEI growth rate"
1311 );
1312 }
1313
1314 #[test]
1316 fn test_electrolyte_single_value_window() {
1317 let e = ElectrolyteMaterial::new(0.5, vec![3.0], 0.4, 1e-3);
1318 assert!(
1319 (e.stability_window_width()).abs() < EPS,
1320 "Single-value window width is 0"
1321 );
1322 }
1323
1324 #[test]
1326 fn test_diffusion_profile_clamp() {
1327 let d = DiffusionInElectrode::new(1e-14, 5e-6, 20_000.0, 10_000.0);
1328 let c_neg = d.concentration_profile(-1.0);
1329 let c_over = d.concentration_profile(2.0);
1330 assert!(
1331 (c_neg - d.c_bulk).abs() < EPS,
1332 "Negative r should clamp to bulk"
1333 );
1334 assert!(
1335 (c_over - d.c_surface).abs() < EPS,
1336 "r>1 should clamp to surface"
1337 );
1338 }
1339
1340 #[test]
1344 fn test_electrode_kinetics_bv_zero_eta() {
1345 let ek = ElectrodeKinetics::symmetric(1.0, 298.15);
1346 assert!(ek.current_density(0.0).abs() < EPS);
1347 }
1348
1349 #[test]
1351 fn test_electrode_kinetics_tafel_slope_positive() {
1352 let ek = ElectrodeKinetics::symmetric(1.0, 298.15);
1353 assert!(ek.anodic_tafel_slope() > 0.0);
1354 }
1355
1356 #[test]
1358 fn test_electrode_kinetics_tafel_slopes_symmetric() {
1359 let ek = ElectrodeKinetics::symmetric(1.0, 298.15);
1360 assert!((ek.anodic_tafel_slope() - ek.cathodic_tafel_slope()).abs() < 1e-12);
1361 }
1362
1363 #[test]
1365 fn test_electrode_kinetics_rct_positive() {
1366 let ek = ElectrodeKinetics::symmetric(1.0, 298.15);
1367 assert!(ek.charge_transfer_resistance() > 0.0);
1368 }
1369
1370 #[test]
1372 fn test_electrode_kinetics_eis_real_positive() {
1373 let ek = ElectrodeKinetics::symmetric(1.0, 298.15);
1374 let (zr, _) = ek.eis_impedance(100.0);
1375 assert!(zr > 0.0);
1376 }
1377
1378 #[test]
1380 fn test_electrode_kinetics_eis_imaginary_negative() {
1381 let ek = ElectrodeKinetics::symmetric(1.0, 298.15);
1382 let (_, zi) = ek.eis_impedance(100.0);
1383 assert!(zi < 0.0, "Capacitive response: imag < 0, zi={zi}");
1384 }
1385
1386 #[test]
1388 fn test_electrode_kinetics_eis_magnitude_decreases() {
1389 let ek = ElectrodeKinetics::symmetric(0.1, 298.15);
1390 let z_low = ek.eis_magnitude(1.0);
1391 let z_high = ek.eis_magnitude(1e6);
1392 assert!(
1393 z_high < z_low,
1394 "EIS magnitude should decrease at high frequency"
1395 );
1396 }
1397
1398 #[test]
1400 fn test_electrode_kinetics_nernst_correction_unity() {
1401 let ek = ElectrodeKinetics::symmetric(1.0, 298.15);
1402 let e = ek.nernst_correction(2.0, 1.0);
1403 assert!((e - 2.0).abs() < EPS);
1404 }
1405
1406 #[test]
1408 fn test_electrode_kinetics_rct_inversely_with_i0() {
1409 let ek1 = ElectrodeKinetics::symmetric(1.0, 298.15);
1410 let ek2 = ElectrodeKinetics::symmetric(10.0, 298.15);
1411 assert!(ek1.charge_transfer_resistance() > ek2.charge_transfer_resistance());
1412 }
1413
1414 #[test]
1418 fn test_solid_electrolyte_llzo_conductivity() {
1419 let se = SolidElectrolyte::llzo();
1420 assert!(se.ionic_conductivity(300.0) > 0.0);
1421 }
1422
1423 #[test]
1425 fn test_solid_electrolyte_arrhenius_temperature() {
1426 let se = SolidElectrolyte::llzo();
1427 let s_low = se.ionic_conductivity(300.0);
1428 let s_high = se.ionic_conductivity(500.0);
1429 assert!(
1430 s_high > s_low,
1431 "Higher T should increase ionic conductivity"
1432 );
1433 }
1434
1435 #[test]
1437 fn test_solid_electrolyte_predominantly_ionic() {
1438 let se = SolidElectrolyte::llzo();
1439 assert!(se.is_predominantly_ionic(300.0));
1440 }
1441
1442 #[test]
1444 fn test_solid_electrolyte_transference_number() {
1445 let se = SolidElectrolyte::llzo();
1446 assert!(se.li_transference_number() > 0.95);
1447 }
1448
1449 #[test]
1451 fn test_solid_electrolyte_geis_real_positive() {
1452 let se = SolidElectrolyte::llzo();
1453 let (zr, _) = se.geis_impedance(300.0, 1000.0);
1454 assert!(zr > 0.0);
1455 }
1456
1457 #[test]
1459 fn test_solid_electrolyte_geis_imag_negative() {
1460 let se = SolidElectrolyte::llzo();
1461 let (_, zi) = se.geis_impedance(300.0, 1000.0);
1462 assert!(zi < 0.0);
1463 }
1464
1465 #[test]
1467 fn test_solid_electrolyte_relaxation_time() {
1468 let se = SolidElectrolyte::llzo();
1469 let tau = se.relaxation_time(300.0);
1470 assert!(tau > 0.0 && tau.is_finite());
1471 }
1472
1473 #[test]
1477 fn test_battery_degradation_initial_capacity() {
1478 let model = BatteryDegradation::new(10.0, 0.1, -0.05, 0.2, 1e-5);
1479 assert!((model.current_capacity() - model.initial_capacity).abs() < 0.1);
1480 }
1481
1482 #[test]
1484 fn test_battery_degradation_retention_at_most_one() {
1485 let model = BatteryDegradation::new(10.0, 0.1, -0.05, 0.2, 1e-5);
1486 assert!(model.capacity_retention() <= 1.0);
1487 }
1488
1489 #[test]
1491 fn test_battery_degradation_sei_grows() {
1492 let mut model = BatteryDegradation::new(10.0, 0.1, -0.05, 0.2, 1e-5);
1493 let sei0 = model.sei_thickness_nm;
1494 model.cycle(0.0, 0.0);
1495 assert!(
1496 model.sei_thickness_nm > sei0,
1497 "SEI should grow after cycling"
1498 );
1499 }
1500
1501 #[test]
1503 fn test_battery_degradation_plating_detection() {
1504 let model = BatteryDegradation::new(10.0, 0.1, -0.05, 0.2, 1e-5);
1505 assert!(model.is_plating(-0.1));
1506 assert!(!model.is_plating(0.1));
1507 }
1508
1509 #[test]
1511 fn test_battery_degradation_dead_li_accumulation() {
1512 let mut model = BatteryDegradation::new(10.0, 0.1, -0.05, 0.2, 1e-5);
1513 let dl0 = model.dead_li_capacity;
1514 model.cycle(-0.2, 0.5); assert!(model.dead_li_capacity > dl0, "Dead Li should accumulate");
1516 }
1517
1518 #[test]
1520 fn test_battery_degradation_sei_resistance() {
1521 let model = BatteryDegradation::new(10.0, 0.1, -0.05, 0.2, 1e-5);
1522 assert!(model.sei_resistance() > 0.0);
1523 }
1524
1525 #[test]
1529 fn test_thermal_battery_initial_temperature() {
1530 let tb = ThermalBattery::new(100.0, 2.0, 298.15, 1e-4);
1531 assert!((tb.temperature - 298.15).abs() < EPS);
1532 }
1533
1534 #[test]
1536 fn test_thermal_battery_bernardi_positive() {
1537 let tb = ThermalBattery::new(100.0, 2.0, 298.15, 1e-4);
1538 let q = tb.bernardi_heat_generation(2.0, 0.05, 0.1);
1539 assert!(q > 0.0);
1540 }
1541
1542 #[test]
1544 fn test_thermal_battery_temperature_rises() {
1545 let mut tb = ThermalBattery::new(100.0, 0.001, 298.15, 1e-4);
1546 let t0 = tb.temperature;
1547 tb.step(10.0, 1.0);
1548 assert!(
1549 tb.temperature > t0,
1550 "Temperature should rise with heat generation"
1551 );
1552 }
1553
1554 #[test]
1556 fn test_thermal_battery_steady_state() {
1557 let tb = ThermalBattery::new(100.0, 2.0, 298.15, 1e-4);
1558 let t_ss = tb.steady_state_temperature(4.0); assert!((t_ss - 300.15).abs() < EPS, "t_ss={t_ss}");
1560 }
1561
1562 #[test]
1564 fn test_thermal_battery_temperature_rise() {
1565 let mut tb = ThermalBattery::new(100.0, 1.0, 300.0, 1e-4);
1566 tb.temperature = 310.0;
1567 assert!((tb.temperature_rise() - 10.0).abs() < EPS);
1568 }
1569
1570 #[test]
1572 fn test_thermal_battery_runaway_detection() {
1573 let mut tb = ThermalBattery::new(100.0, 1.0, 300.0, 1e-4);
1574 tb.temperature = 400.0;
1575 assert!(tb.thermal_runaway_risk(380.0));
1576 assert!(!tb.thermal_runaway_risk(420.0));
1577 }
1578
1579 #[test]
1581 fn test_thermal_battery_non_uniformity() {
1582 let tb = ThermalBattery::new(100.0, 2.0, 298.15, 1e-4);
1583 let du = tb.temperature_non_uniformity(5.0, 0.01, 1.0);
1584 assert!(du > 0.0);
1585 }
1586
1587 #[test]
1591 fn test_battery_cycling_initial_soc() {
1592 let bc = BatteryCycling::new(5.0, 4.2, 2.5, 1.0, 0.05, 0.05);
1593 assert!((bc.soc).abs() < EPS);
1594 }
1595
1596 #[test]
1598 fn test_battery_cycling_ocv_soc_zero() {
1599 let bc = BatteryCycling::new(5.0, 4.2, 2.5, 1.0, 0.05, 0.05);
1600 assert!((bc.ocv(0.0) - 2.5).abs() < EPS);
1601 }
1602
1603 #[test]
1605 fn test_battery_cycling_ocv_soc_one() {
1606 let bc = BatteryCycling::new(5.0, 4.2, 2.5, 1.0, 0.05, 0.05);
1607 assert!((bc.ocv(1.0) - 4.2).abs() < EPS);
1608 }
1609
1610 #[test]
1612 fn test_battery_cycling_cc_charge_increases_soc() {
1613 let mut bc = BatteryCycling::new(5.0, 4.2, 2.5, 1.0, 0.05, 0.05);
1614 let soc0 = bc.soc;
1615 bc.cc_charge_step(0.1);
1616 assert!(bc.soc > soc0);
1617 }
1618
1619 #[test]
1621 fn test_battery_cycling_cc_discharge_decreases_soc() {
1622 let mut bc = BatteryCycling::new(5.0, 4.2, 2.5, 1.0, 0.05, 0.05);
1623 bc.soc = 0.8;
1624 let soc0 = bc.soc;
1625 bc.cc_discharge_step(1.0, 0.1);
1626 assert!(bc.soc < soc0);
1627 }
1628
1629 #[test]
1631 fn test_battery_cycling_c_rate() {
1632 let bc = BatteryCycling::new(5.0, 4.2, 2.5, 5.0, 0.05, 0.05);
1633 assert!((bc.c_rate() - 1.0).abs() < EPS, "1C rate: 5A / 5Ah = 1");
1634 }
1635
1636 #[test]
1638 fn test_battery_cycling_cc_charge_time() {
1639 let bc = BatteryCycling::new(5.0, 4.2, 2.5, 5.0, 0.05, 0.05); let t = bc.cc_charge_time(1.0);
1641 assert!(
1642 (t - 1.0).abs() < EPS,
1643 "1C charge from 0 to 100% takes 1 h, t={t}"
1644 );
1645 }
1646
1647 #[test]
1649 fn test_battery_cycling_coulombic_efficiency_range() {
1650 let mut bc = BatteryCycling::new(5.0, 4.2, 2.5, 1.0, 0.05, 0.02);
1651 bc.soc = 0.0;
1652 bc.full_cycle(1.0, 0.01, 200);
1653 assert!(bc.coulombic_efficiency >= 0.0 && bc.coulombic_efficiency <= 1.0);
1654 }
1655
1656 #[test]
1658 fn test_battery_cycling_cycle_count_increments() {
1659 let mut bc = BatteryCycling::new(5.0, 4.2, 2.5, 1.0, 0.05, 0.02);
1660 bc.full_cycle(1.0, 0.01, 10);
1661 assert_eq!(bc.cycle_count, 1);
1662 }
1663
1664 #[test]
1666 fn test_battery_cycling_terminal_voltage_charge_above_ocv() {
1667 let bc = BatteryCycling::new(5.0, 4.2, 2.5, 1.0, 0.05, 0.1);
1668 bc.ocv(bc.soc); let v_term = bc.terminal_voltage_charge();
1670 let v_ocv = bc.ocv(bc.soc);
1671 assert!(v_term > v_ocv, "Terminal voltage during charge > OCV");
1672 }
1673
1674 #[test]
1676 fn test_diffusion_max_c_rate_positive() {
1677 let d = DiffusionInElectrode::new(1e-14, 5e-6, 20_000.0, 10_000.0);
1678 assert!(d.max_c_rate() > 0.0);
1679 }
1680
1681 #[test]
1683 fn test_diffusion_max_c_rate_scales_with_diffusivity() {
1684 let d_slow = DiffusionInElectrode::new(1e-15, 5e-6, 20_000.0, 10_000.0);
1685 let d_fast = DiffusionInElectrode::new(1e-13, 5e-6, 20_000.0, 10_000.0);
1686 assert!(d_fast.max_c_rate() > d_slow.max_c_rate());
1687 }
1688
1689 #[test]
1691 fn test_diffusion_soc_in_range() {
1692 let d = DiffusionInElectrode::with_c_max(1e-14, 5e-6, 20_000.0, 15_000.0, 30_000.0);
1693 let soc = d.soc_from_concentration();
1694 assert!((0.0..=1.0).contains(&soc), "SOC={soc}");
1695 }
1696
1697 #[test]
1699 fn test_solid_electrolyte_lgps_vs_llzo() {
1700 let llzo = SolidElectrolyte::llzo();
1701 let lgps = SolidElectrolyte::lgps();
1702 let s_llzo = llzo.ionic_conductivity(300.0);
1704 let s_lgps = lgps.ionic_conductivity(300.0);
1705 assert!(s_llzo > 0.0 && s_lgps > 0.0);
1707 }
1708
1709 #[test]
1711 fn test_battery_degradation_predict_cycle_life() {
1712 let model = BatteryDegradation::new(10.0, 0.01, -0.05, 0.2, 1e-5);
1713 let life = model.predict_cycle_life(0.8);
1714 assert!(life > 0.0, "Predicted cycle life should be positive");
1715 }
1716
1717 #[test]
1719 fn test_electrode_kinetics_tafel_below_i0() {
1720 let ek = ElectrodeKinetics::symmetric(2.0, 298.15);
1721 let eta = ek.tafel_overpotential_anodic(1.0); assert!((eta).abs() < EPS);
1723 }
1724}