Skip to main content

oxiphysics_materials/
battery_materials.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Battery electrode and electrolyte material models.
5//!
6//! Provides physics-based models for:
7//! - Electrode materials (graphite, LFP, NMC, LCO, silicon, custom)
8//! - Electrolyte ionic transport
9//! - Solid-electrolyte interphase (SEI) growth
10//! - Solid-state diffusion inside particles
11//! - Capacity and resistance aging
12//! - Butler-Volmer kinetics and Nernst equation
13//! - Electrode kinetics (exchange current density, Tafel slope, EIS impedance)
14//! - Solid electrolyte (Arrhenius conductivity, transference number, GEIS analysis)
15//! - Battery degradation (SEI growth, lithium plating, capacity fade)
16//! - Thermal battery model (Bernardi heat generation, cooling, temperature uniformity)
17//! - Battery cycling (CC/CV protocol, SOC tracking, Coulombic efficiency)
18
19#![allow(dead_code)]
20#![allow(clippy::too_many_arguments)]
21
22/// Faraday constant \[C/mol\]
23pub const FARADAY: f64 = 96_485.0;
24/// Universal gas constant \[J/(mol·K)\]
25pub const GAS_CONSTANT: f64 = 8.314_462_618;
26
27// ─── Electrode type ──────────────────────────────────────────────────────────
28
29/// Classification of common battery electrode active materials.
30#[derive(Debug, Clone, PartialEq)]
31pub enum ElectrodeType {
32    /// Graphite (LiC₆) anode – conventional lithium-ion anode.
33    Graphite,
34    /// Lithium iron phosphate cathode (LiFePO₄).
35    LFP,
36    /// Lithium nickel manganese cobalt oxide cathode (NMC).
37    NMC,
38    /// Lithium cobalt oxide cathode (LiCoO₂).
39    LCO,
40    /// Silicon anode – high capacity, large volume expansion.
41    Silicon,
42    /// User-defined electrode type.
43    Custom(String),
44}
45
46// ─── Electrode material ──────────────────────────────────────────────────────
47
48/// Physical and electrochemical properties of an electrode active material.
49#[derive(Debug, Clone)]
50pub struct ElectrodeMaterial {
51    /// Classification of this electrode material.
52    pub electrode_type: ElectrodeType,
53    /// Theoretical specific capacity \[mAh/g\].
54    pub specific_capacity_mah_g: f64,
55    /// Average open-circuit voltage vs Li/Li⁺ \[V\].
56    pub average_voltage: f64,
57    /// Particle density \[g/cm³\].
58    pub density_g_cm3: f64,
59    /// Linear intercalation strain (fractional volume change per unit SOC).
60    pub intercalation_strain: f64,
61}
62
63impl ElectrodeMaterial {
64    /// Construct a new `ElectrodeMaterial`.
65    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    /// Volumetric energy density \[Wh/L\].
82    ///
83    /// Calculated as: specific_capacity \[mAh/g\] × average_voltage \[V\]
84    /// × density \[g/cm³\] × 1000 \[cm³/L\] / 1000 \[mAh/Ah\].
85    pub fn volumetric_energy_density(&self) -> f64 {
86        self.specific_capacity_mah_g * self.average_voltage * self.density_g_cm3
87    }
88
89    /// Gravimetric energy density \[Wh/kg\].
90    ///
91    /// Calculated as: specific_capacity \[mAh/g\] × average_voltage \[V\].
92    pub fn gravimetric_energy_density(&self) -> f64 {
93        self.specific_capacity_mah_g * self.average_voltage
94    }
95
96    /// Return preset values for graphite anode.
97    pub fn graphite() -> Self {
98        Self::new(ElectrodeType::Graphite, 372.0, 0.1, 2.26, 0.1)
99    }
100
101    /// Return preset values for LFP cathode.
102    pub fn lfp() -> Self {
103        Self::new(ElectrodeType::LFP, 170.0, 3.4, 3.6, 0.06)
104    }
105
106    /// Return preset values for NMC 811 cathode.
107    pub fn nmc() -> Self {
108        Self::new(ElectrodeType::NMC, 200.0, 3.7, 4.7, 0.05)
109    }
110
111    /// Return preset values for LCO cathode.
112    pub fn lco() -> Self {
113        Self::new(ElectrodeType::LCO, 140.0, 3.9, 5.1, 0.04)
114    }
115
116    /// Return preset values for silicon anode.
117    pub fn silicon() -> Self {
118        Self::new(ElectrodeType::Silicon, 3580.0, 0.4, 2.33, 0.4)
119    }
120}
121
122// ─── Electrolyte material ────────────────────────────────────────────────────
123
124/// Bulk transport properties of a liquid electrolyte.
125#[derive(Debug, Clone)]
126pub struct ElectrolyteMaterial {
127    /// Ionic conductivity \[S/m\].
128    pub ionic_conductivity_s_m: f64,
129    /// Electrochemical stability window \[V_min, V_max\] vs Li/Li⁺.
130    pub electrochemical_window: Vec<f64>,
131    /// Li⁺ transference number (fraction of current carried by Li⁺).
132    pub transference_number: f64,
133    /// Dynamic viscosity \[Pa·s\].
134    pub viscosity: f64,
135}
136
137impl ElectrolyteMaterial {
138    /// Construct a new `ElectrolyteMaterial`.
139    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    /// Return a typical 1 M LiPF₆ in EC/DMC electrolyte.
154    pub fn lipf6_ec_dmc() -> Self {
155        Self::new(1.0, vec![0.0, 4.5], 0.38, 2.5e-3)
156    }
157
158    /// Width of the electrochemical stability window \[V\].
159    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// ─── SEI ─────────────────────────────────────────────────────────────────────
169
170/// Solid-electrolyte interphase (SEI) layer on anode surface.
171#[derive(Debug, Clone)]
172pub struct SolidElectrolyteInterphase {
173    /// Current SEI thickness \[nm\].
174    pub thickness_nm: f64,
175    /// Ionic resistance of the SEI layer \[Ω·m²\].
176    pub ionic_resistance: f64,
177    /// Arrhenius activation energy for SEI growth \[J/mol\].
178    pub activation_energy: f64,
179}
180
181impl SolidElectrolyteInterphase {
182    /// Construct a new `SolidElectrolyteInterphase`.
183    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    /// Parabolic SEI growth rate \[nm/s\].
192    ///
193    /// Uses an Arrhenius-type model:
194    /// `rate = k₀ / thickness × exp(−Ea / (R·T)) × soc_factor`
195    /// where `soc_factor = 1 − soc` penalises growth at low state-of-charge.
196    pub fn growth_rate(&self, temperature: f64, soc: f64) -> f64 {
197        let k0 = 1.0e-3; // pre-exponential [nm²/s]
198        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// ─── Diffusion in electrode particle ────────────────────────────────────────
206
207/// Solid-state Li diffusion inside a spherical electrode particle.
208///
209/// Models concentration profiles and C-rate limitations using
210/// Fick's second law in spherical coordinates.
211#[derive(Debug, Clone)]
212pub struct DiffusionInElectrode {
213    /// Solid-state diffusivity of Li⁺ \[m²/s\].
214    pub diffusivity: f64,
215    /// Particle radius \[m\].
216    pub particle_radius: f64,
217    /// Surface concentration \[mol/m³\].
218    pub c_surface: f64,
219    /// Bulk (centre) concentration \[mol/m³\].
220    pub c_bulk: f64,
221    /// Maximum Li concentration \[mol/m³\].
222    pub c_max: f64,
223}
224
225impl DiffusionInElectrode {
226    /// Construct a new `DiffusionInElectrode` with default `c_max = 30_000.0`.
227    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    /// Construct with explicit maximum concentration.
238    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    /// Approximate radial concentration profile \[mol/m³\] at fractional radius `r` ∈ \[0, 1\].
255    ///
256    /// Uses a parabolic (pseudo-steady-state) profile:
257    /// `c(r) = c_bulk + (c_surface − c_bulk) × r²`
258    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    /// Characteristic diffusion length \[m\].
264    ///
265    /// `L_d = sqrt(D × t_ref)` where `t_ref = R² / D` (time to diffuse across particle).
266    pub fn diffusion_length(&self) -> f64 {
267        // t_ref = R² / D  →  L_d = R
268        self.particle_radius
269    }
270
271    /// Diffusion time scale \[s\]: `τ = R² / D`.
272    pub fn diffusion_time_scale(&self) -> f64 {
273        self.particle_radius * self.particle_radius / self.diffusivity.max(1e-30)
274    }
275
276    /// Maximum C-rate \[1/h\] at which the particle can be fully discharged.
277    ///
278    /// Defined as `C_max = 1 / τ_diff [h⁻¹]` where τ_diff = R² / (π² D).
279    /// Higher diffusivity or smaller particle → higher accessible C-rate.
280    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    /// Surface concentration gradient \[mol/m⁴\] approximation (linear).
288    ///
289    /// `dc/dr|_R ≈ (c_surface − c_bulk) / R`
290    pub fn surface_concentration_gradient(&self) -> f64 {
291        (self.c_surface - self.c_bulk) / self.particle_radius.max(1e-30)
292    }
293
294    /// State of charge estimated from average concentration: `SOC = c_avg / c_max`.
295    ///
296    /// Average concentration for parabolic profile: `c_avg = c_bulk + 3/5 (c_surf - c_bulk)`.
297    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    /// Flux at the surface \[mol/(m²·s)\] using Fick's law.
303    ///
304    /// `J = −D × dc/dr|_R`
305    pub fn surface_flux(&self) -> f64 {
306        -self.diffusivity * self.surface_concentration_gradient()
307    }
308}
309
310// ─── Electrode kinetics ──────────────────────────────────────────────────────
311
312/// Electrode kinetics using the Butler-Volmer formulation.
313///
314/// Computes exchange current density, Tafel slope, and EIS (electrochemical
315/// impedance spectroscopy) charge-transfer resistance.
316#[derive(Debug, Clone)]
317pub struct ElectrodeKinetics {
318    /// Exchange current density \[A/m²\].
319    pub i0: f64,
320    /// Anodic charge-transfer coefficient (dimensionless).
321    pub alpha_a: f64,
322    /// Cathodic charge-transfer coefficient (dimensionless).
323    pub alpha_c: f64,
324    /// Temperature \[K\].
325    pub temperature: f64,
326    /// Number of electrons transferred per reaction step.
327    pub n_electrons: usize,
328}
329
330impl ElectrodeKinetics {
331    /// Construct a new `ElectrodeKinetics`.
332    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    /// Construct with symmetric transfer coefficients (α_a = α_c = 0.5).
343    pub fn symmetric(i0: f64, temperature: f64) -> Self {
344        Self::new(i0, 0.5, 0.5, temperature, 1)
345    }
346
347    /// Butler-Volmer current density \[A/m²\] at overpotential `eta` \[V\].
348    ///
349    /// `j = i₀ × (exp(αa·F·η / (R·T)) − exp(−αc·F·η / (R·T)))`
350    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    /// Anodic Tafel slope \[V/decade\].
356    ///
357    /// `b_a = 2.303 R T / (α_a F)`
358    pub fn anodic_tafel_slope(&self) -> f64 {
359        2.303 * GAS_CONSTANT * self.temperature / (self.alpha_a * FARADAY)
360    }
361
362    /// Cathodic Tafel slope \[V/decade\].
363    ///
364    /// `b_c = 2.303 R T / (α_c F)`
365    pub fn cathodic_tafel_slope(&self) -> f64 {
366        2.303 * GAS_CONSTANT * self.temperature / (self.alpha_c * FARADAY)
367    }
368
369    /// Linearised charge-transfer resistance \[Ω·m²\] (valid for small η).
370    ///
371    /// From linearisation of Butler-Volmer: `R_ct = R T / (n F i₀)`
372    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    /// EIS impedance at angular frequency `omega` \[rad/s\].
377    ///
378    /// Returns `(Z_real, Z_imag)` \[Ω·m²\] using the Randles circuit approximation:
379    /// - Charge-transfer resistance `R_ct` in parallel with double-layer capacitance `C_dl`
380    /// - `Z = R_ct / (1 + (ω R_ct C_dl)²)` (real) and `-ω R_ct² C_dl / (1 + (ω R_ct C_dl)²)` (imag)
381    ///
382    /// Assumes `C_dl = 0.2 F/m²` (typical double-layer capacitance).
383    pub fn eis_impedance(&self, omega: f64) -> (f64, f64) {
384        let r_ct = self.charge_transfer_resistance();
385        let c_dl = 0.2_f64; // [F/m²] double-layer capacitance
386        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    /// EIS impedance magnitude \[Ω·m²\] at angular frequency `omega`.
393    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    /// Overpotential \[V\] required to achieve current density `j` \[A/m²\].
399    ///
400    /// Uses high-overpotential Tafel approximation (anodic branch):
401    /// `η ≈ b_a × log10(j / i₀)` for large positive j.
402    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    /// Open-circuit potential correction (Nernst) \[V\].
410    ///
411    /// `E = E_ref − (R T / n F) ln(c_ox/c_red)` where `c_ratio = c_ox/c_red`.
412    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// ─── Solid electrolyte ───────────────────────────────────────────────────────
419
420/// Solid electrolyte model with Arrhenius ionic conductivity.
421///
422/// Models ionic transport in solid-state electrolytes including
423/// LLZO, LGPS, LIPON and related materials.
424#[derive(Debug, Clone)]
425pub struct SolidElectrolyte {
426    /// Pre-exponential factor \[S/m\] in the Arrhenius expression.
427    pub sigma_0: f64,
428    /// Activation energy for ion hopping \[J/mol\].
429    pub activation_energy: f64,
430    /// Li⁺ transference number (≈ 1 for ideal solid electrolyte).
431    pub transference_number: f64,
432    /// Electronic conductivity \[S/m\] (should be negligible: < 1e-10).
433    pub electronic_conductivity: f64,
434    /// Grain boundary resistance contribution \[Ω·m\].
435    pub grain_boundary_resistance: f64,
436}
437
438impl SolidElectrolyte {
439    /// Construct a new `SolidElectrolyte`.
440    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    /// Preset for LLZO (Li₇La₃Zr₂O₁₂) garnet electrolyte.
457    pub fn llzo() -> Self {
458        // σ₀ ~ 1e5 S/m, Ea ~ 30 kJ/mol, t_Li ≈ 1
459        Self::new(1.0e5, 30_000.0, 0.99, 1e-8, 1.0e-3)
460    }
461
462    /// Preset for LGPS (Li₁₀GeP₂S₁₂) sulfide electrolyte.
463    pub fn lgps() -> Self {
464        // σ₀ ~ 2e5 S/m, Ea ~ 24 kJ/mol
465        Self::new(2.0e5, 24_000.0, 0.98, 1e-7, 5.0e-4)
466    }
467
468    /// Ionic conductivity \[S/m\] at temperature `t` \[K\] (Arrhenius model).
469    ///
470    /// `σ(T) = σ₀ × exp(−Ea / (R T))`
471    pub fn ionic_conductivity(&self, temperature: f64) -> f64 {
472        self.sigma_0 * (-self.activation_energy / (GAS_CONSTANT * temperature)).exp()
473    }
474
475    /// Apparent activation energy \[eV\] estimated from conductivity at two temperatures.
476    ///
477    /// `Ea = R × T₁ × T₂ / (T₁ - T₂) × ln(σ(T₂) / σ(T₁))`
478    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        // Just return stored activation energy converted to eV
481        self.activation_energy / (GAS_CONSTANT * 96_485.0 / FARADAY) * ev_per_j_per_mol
482    }
483
484    /// Ionic conductivity with grain boundary contribution.
485    ///
486    /// Total resistance: `R_total = 1/σ_bulk + R_gb`
487    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    /// Transference number (fraction of ionic current carried by Li⁺).
495    ///
496    /// For solid electrolytes this is typically close to 1.
497    pub fn li_transference_number(&self) -> f64 {
498        self.transference_number
499    }
500
501    /// Check if the electrolyte is predominantly ionic (electronic conductivity negligible).
502    ///
503    /// Returns `true` if σ_electronic / σ_ionic(T) < 1e-6 at temperature `t`.
504    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    /// GEIS (galvanostatic EIS) analysis: bulk impedance at frequency `omega` \[rad/s\].
510    ///
511    /// Returns `(Z_real, Z_imag)` for a simple RC transmission line model.
512    /// `Z = R_bulk / (1 + jω τ)` where `τ = ε₀ ε_r / σ` with ε_r ≈ 30 (typical garnet).
513    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; // [F/m]
517        let tau = epsilon_0 * epsilon_r / sigma.max(1e-30);
518        let r_bulk = 1.0 / sigma.max(1e-30); // per unit thickness
519        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    /// Relaxation (polarisation) time \[s\]: `τ = ε₀ ε_r / σ`.
526    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// ─── Battery degradation ─────────────────────────────────────────────────────
535
536/// Comprehensive battery degradation model.
537///
538/// Tracks SEI growth (parabolic kinetics), lithium plating onset,
539/// and capacity fade over cycling.
540#[derive(Debug, Clone)]
541pub struct BatteryDegradation {
542    /// Initial cell capacity \[Ah\].
543    pub initial_capacity: f64,
544    /// Current cycle number.
545    pub cycle_count: usize,
546    /// SEI thickness \[nm\] (grows parabolically).
547    pub sei_thickness_nm: f64,
548    /// SEI growth rate constant \[nm²/cycle\].
549    pub sei_k: f64,
550    /// Lithium plating onset overpotential \[V\] (negative → plating occurs).
551    pub plating_onset_eta: f64,
552    /// Fraction of plated Li that is irreversible (dead Li).
553    pub dead_li_fraction: f64,
554    /// Capacity lost to dead Li \[Ah\].
555    pub dead_li_capacity: f64,
556    /// Calendar capacity fade rate \[Ah/day\].
557    pub calendar_fade_rate: f64,
558}
559
560impl BatteryDegradation {
561    /// Construct a new `BatteryDegradation` model.
562    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, // initial SEI ~ 2 nm
573            sei_k,
574            plating_onset_eta,
575            dead_li_fraction,
576            dead_li_capacity: 0.0,
577            calendar_fade_rate,
578        }
579    }
580
581    /// Advance by one cycle at the given anode overpotential `eta_anode` \[V\].
582    ///
583    /// Updates SEI thickness (parabolic growth) and dead Li accumulation
584    /// if lithium plating is active.
585    pub fn cycle(&mut self, eta_anode: f64, plated_capacity_ah: f64) {
586        self.cycle_count += 1;
587        // Parabolic SEI: d(δ²)/dt = k  →  δ(n) = sqrt(δ₀² + k·n)
588        let n = self.cycle_count as f64;
589        self.sei_thickness_nm = (2.0_f64.powi(2) + self.sei_k * n).sqrt();
590        // Lithium plating: if anode goes more negative than onset
591        if eta_anode < self.plating_onset_eta {
592            self.dead_li_capacity += plated_capacity_ah * self.dead_li_fraction;
593        }
594    }
595
596    /// Current capacity \[Ah\] accounting for SEI and dead Li losses.
597    ///
598    /// `Q = Q₀ − Q_deadLi − k_SEI × δ_SEI`
599    pub fn current_capacity(&self) -> f64 {
600        let sei_capacity_loss = self.sei_thickness_nm * 1e-9 * 1e4; // rough scaling
601        (self.initial_capacity - self.dead_li_capacity - sei_capacity_loss).max(0.0)
602    }
603
604    /// Capacity retention fraction: `Q_current / Q_initial`.
605    pub fn capacity_retention(&self) -> f64 {
606        self.current_capacity() / self.initial_capacity.max(1e-30)
607    }
608
609    /// SEI ionic resistance \[Ω·m²\].
610    ///
611    /// `R_SEI = δ / σ_SEI` where `σ_SEI ≈ 1e-7 S/m`.
612    pub fn sei_resistance(&self) -> f64 {
613        let sigma_sei = 1e-7_f64; // [S/m]
614        self.sei_thickness_nm * 1e-9 / sigma_sei
615    }
616
617    /// Check if lithium plating is active at overpotential `eta` \[V\].
618    pub fn is_plating(&self, eta: f64) -> bool {
619        eta < self.plating_onset_eta
620    }
621
622    /// Predict cycles to reach `retention_target` capacity retention.
623    ///
624    /// Uses the parabolic SEI model and linear dead Li accumulation.
625    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        // Invert parabolic SEI loss: δ² ≈ k·n, loss ≈ k_loss * sqrt(k·n)
632        // Approximation: loss ≈ sei_k * sqrt(n)
633        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// ─── Empirical cycle-life aging model (original) ─────────────────────────────
642
643/// Empirical cycle-life aging model for a battery cell.
644#[derive(Debug, Clone)]
645pub struct BatteryAgingModel {
646    /// Current cycle count.
647    pub cycle_count: usize,
648    /// Fractional capacity fade per cycle (e.g., 0.0002 = 0.02 % per cycle).
649    pub capacity_fade_rate: f64,
650    /// Fractional resistance growth per cycle.
651    pub resistance_growth_rate: f64,
652    /// Initial capacity \[Ah\].
653    pub initial_capacity: f64,
654}
655
656impl BatteryAgingModel {
657    /// Construct a new `BatteryAgingModel`.
658    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    /// Remaining capacity \[Ah\] at cycle number `n`.
672    ///
673    /// `Q(n) = Q₀ × (1 − rate × n)` clamped to zero.
674    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    /// Internal resistance multiplier at cycle number `n`.
680    ///
681    /// `R(n) = R₀ × (1 + growth_rate × n)`.
682    pub fn resistance_at_cycle(&self, n: usize) -> f64 {
683        1.0 + self.resistance_growth_rate * n as f64
684    }
685
686    /// Cycle number at which capacity falls below `threshold` fraction of initial.
687    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// ─── Thermal battery model ───────────────────────────────────────────────────
697
698/// Thermal model for a battery cell using the Bernardi heat generation model.
699///
700/// Tracks heat generation from electrochemical reactions, Joule heating,
701/// reversible entropy change, and Newton cooling.
702#[derive(Debug, Clone)]
703pub struct ThermalBattery {
704    /// Thermal mass of the cell \[J/K\] (mass × specific heat capacity).
705    pub thermal_mass: f64,
706    /// Newton cooling coefficient \[W/K\] (h × A where h = heat transfer coeff., A = surface area).
707    pub cooling_coefficient: f64,
708    /// Ambient temperature \[K\].
709    pub ambient_temperature: f64,
710    /// Current cell temperature \[K\].
711    pub temperature: f64,
712    /// Entropic heating coefficient dU/dT \[V/K\] (reversible heat source).
713    pub entropic_coefficient: f64,
714}
715
716impl ThermalBattery {
717    /// Construct a new `ThermalBattery`.
718    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    /// Bernardi heat generation rate \[W\].
734    ///
735    /// `Q_gen = I × (η_a + η_c) + I² × R_ohm − I × T × dU/dT`
736    ///
737    /// where:
738    /// - `current` \[A\] is the cell current (positive for charge)
739    /// - `eta_total` \[V\] is the total overpotential (|η_a| + |η_c|)
740    /// - `r_ohm` \[Ω\] is the ohmic resistance
741    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    /// Advance temperature by time step `dt` \[s\] with heat generation `q_gen` \[W\].
748    ///
749    /// `dT/dt = (Q_gen - h·A·(T - T_amb)) / C_th`
750    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    /// Steady-state temperature \[K\] at constant heat generation `q_gen` \[W\].
757    ///
758    /// `T_ss = T_amb + Q_gen / (h·A)`
759    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    /// Temperature rise \[K\] above ambient.
764    pub fn temperature_rise(&self) -> f64 {
765        self.temperature - self.ambient_temperature
766    }
767
768    /// Check thermal runaway risk: returns `true` if temperature exceeds `t_threshold` \[K\].
769    pub fn thermal_runaway_risk(&self, t_threshold: f64) -> bool {
770        self.temperature >= t_threshold
771    }
772
773    /// Estimate temperature non-uniformity \[K\] across cell of thickness `l` \[m\].
774    ///
775    /// For a 1-D planar cell: `ΔT = Q_gen × l² / (8 k_th × A)`
776    /// where `k_th` \[W/(m·K)\] is the thermal conductivity.
777    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    /// Time to reach a temperature `t_target` \[K\] from current temperature (Newton cooling).
782    ///
783    /// Analytical: `t = −C_th / (h·A) × ln((T_target − T_amb) / (T_init − T_amb))`
784    /// Returns `f64::INFINITY` if target equals ambient or is unreachable.
785    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// ─── Battery cycling ─────────────────────────────────────────────────────────
799
800/// Battery cycling model implementing CC/CV protocol.
801///
802/// Tracks state of charge (SOC), capacity delivered, and Coulombic efficiency
803/// over a charging or discharging cycle.
804#[derive(Debug, Clone)]
805pub struct BatteryCycling {
806    /// Nominal cell capacity \[Ah\].
807    pub nominal_capacity: f64,
808    /// Current state of charge \[0, 1\].
809    pub soc: f64,
810    /// Upper voltage cut-off \[V\] (CV phase begins here during charge).
811    pub v_max: f64,
812    /// Lower voltage cut-off \[V\] (discharge stops here).
813    pub v_min: f64,
814    /// CC charge current \[A\].
815    pub charge_current: f64,
816    /// CV hold current threshold \[A\] (cycle ends when current drops below this).
817    pub cv_cutoff_current: f64,
818    /// Charge delivered in current cycle \[Ah\].
819    pub charge_delivered: f64,
820    /// Discharge delivered in current cycle \[Ah\].
821    pub discharge_delivered: f64,
822    /// Coulombic efficiency of the last full cycle (Q_discharge / Q_charge).
823    pub coulombic_efficiency: f64,
824    /// Cycle count.
825    pub cycle_count: usize,
826    /// Internal resistance \[Ω\].
827    pub r_internal: f64,
828    /// Open-circuit voltage model parameters \[V_min, V_max\].
829    pub ocv_range: [f64; 2],
830}
831
832impl BatteryCycling {
833    /// Construct a new `BatteryCycling` model starting at SOC = 0.
834    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    /// Open-circuit voltage \[V\] as a function of SOC (linear interpolation).
859    ///
860    /// `OCV(SOC) = V_min + SOC × (V_max − V_min)`
861    pub fn ocv(&self, soc: f64) -> f64 {
862        self.ocv_range[0] + soc * (self.ocv_range[1] - self.ocv_range[0])
863    }
864
865    /// Terminal voltage \[V\] during CC charge.
866    ///
867    /// `V_term = OCV + I × R_int`
868    pub fn terminal_voltage_charge(&self) -> f64 {
869        self.ocv(self.soc) + self.charge_current * self.r_internal
870    }
871
872    /// Terminal voltage \[V\] during CC discharge at current `i_dis` \[A\].
873    ///
874    /// `V_term = OCV − I × R_int`
875    pub fn terminal_voltage_discharge(&self, i_dis: f64) -> f64 {
876        self.ocv(self.soc) - i_dis * self.r_internal
877    }
878
879    /// Apply one CC charge step of duration `dt` \[h\].
880    ///
881    /// Updates SOC and charge_delivered.  Returns `true` when CV phase is reached.
882    pub fn cc_charge_step(&mut self, dt: f64) -> bool {
883        let dq = self.charge_current * dt; // [Ah]
884        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    /// Apply one CC discharge step of duration `dt` \[h\] at current `i_dis` \[A\].
890    ///
891    /// Returns `true` when lower cut-off voltage is reached.
892    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    /// Complete a full charge–discharge cycle and record Coulombic efficiency.
900    ///
901    /// Simulates `n_steps` CC charge steps then `n_steps` CC discharge steps.
902    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        // CC charge phase
907        for _ in 0..n_steps {
908            if self.cc_charge_step(dt) {
909                break;
910            }
911        }
912        // CC discharge phase
913        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    /// Estimate C-rate as a multiple of nominal capacity.
928    ///
929    /// `C-rate = I / Q_nominal`
930    pub fn c_rate(&self) -> f64 {
931        self.charge_current / self.nominal_capacity.max(1e-30)
932    }
933
934    /// Charge time estimate \[h\] for CC charging from current SOC to `soc_target`.
935    ///
936    /// `t = (soc_target − SOC) × Q_nominal / I_charge`
937    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    /// Energy efficiency of the last cycle (round-trip).
943    ///
944    /// Accounts for voltage losses: `η_E = η_CE × V_dis / V_chg`
945    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    /// Reset for next cycle.
950    pub fn reset_cycle_counters(&mut self) {
951        self.charge_delivered = 0.0;
952        self.discharge_delivered = 0.0;
953    }
954}
955
956// ─── Butler-Volmer function ──────────────────────────────────────────────────
957
958/// Butler-Volmer electrode kinetics: current density \[A/m²\].
959///
960/// `j = i₀ × (exp(αa·F·η / (R·T)) − exp(−αc·F·η / (R·T)))`
961///
962/// # Arguments
963/// * `i0`      – exchange current density \[A/m²\]
964/// * `alpha_a` – anodic transfer coefficient (dimensionless)
965/// * `alpha_c` – cathodic transfer coefficient (dimensionless)
966/// * `eta`     – overpotential \[V\]
967/// * `t`       – temperature \[K\]
968pub 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
973// ─── Nernst equation ─────────────────────────────────────────────────────────
974
975/// Nernst equilibrium potential \[V\].
976///
977/// `E = E₀ − (R·T / (n·F)) × ln(Q)`
978///
979/// # Arguments
980/// * `e0` – standard electrode potential \[V\]
981/// * `r`  – universal gas constant \[J/(mol·K)\] (pass `GAS_CONSTANT` for SI)
982/// * `t`  – temperature \[K\]
983/// * `n`  – number of electrons transferred (integer ≥ 1)
984/// * `q`  – reaction quotient (dimensionless, must be > 0)
985pub 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// ─── Tests ───────────────────────────────────────────────────────────────────
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995
996    const EPS: f64 = 1e-9;
997
998    // 1. Butler-Volmer zero overpotential returns zero.
999    #[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    // 2. Butler-Volmer positive overpotential is positive.
1006    #[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    // 3. Butler-Volmer negative overpotential is negative.
1016    #[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    // 4. Scaling i0 scales current linearly.
1026    #[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    // 5. Nernst at Q=1 returns E0.
1037    #[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    // 6. Nernst Q>1 reduces potential.
1047    #[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    // 7. Nernst Q<1 increases potential.
1054    #[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    // 8. Nernst temperature dependence: higher T → larger correction for Q≠1.
1061    #[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    // 9. ElectrodeMaterial: graphite volumetric energy density > 0.
1072    #[test]
1073    fn test_graphite_volumetric_energy() {
1074        let g = ElectrodeMaterial::graphite();
1075        assert!(g.volumetric_energy_density() > 0.0);
1076    }
1077
1078    // 10. ElectrodeMaterial: silicon gravimetric energy density > NMC.
1079    #[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    // 11. ElectrodeMaterial: LFP average voltage ~3.4 V.
1090    #[test]
1091    fn test_lfp_voltage() {
1092        let lfp = ElectrodeMaterial::lfp();
1093        assert!((lfp.average_voltage - 3.4).abs() < 0.01);
1094    }
1095
1096    // 12. ElectrodeMaterial: LCO density > 5 g/cm³.
1097    #[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    // 13. Custom electrode type round-trips.
1104    #[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    // 14. Electrolyte: LiPF6 stability window is 4.5 V wide.
1120    #[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    // 15. Electrolyte: transference number in [0, 1].
1127    #[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    // 16. SEI growth rate is positive at moderate T and SOC.
1137    #[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    // 17. SEI growth rate is zero at SOC = 1.
1145    #[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    // 18. SEI growth rate decreases with increasing thickness.
1153    #[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    // 19. Diffusion concentration profile at r=0 equals c_bulk.
1163    #[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    // 20. Diffusion concentration profile at r=1 equals c_surface.
1174    #[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    // 21. Diffusion concentration profile is monotonic between r=0 and r=1.
1185    #[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    // 22. Diffusion length equals particle radius.
1198    #[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    // 23. Diffusion time scale = R² / D.
1209    #[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    // 24. Battery aging: capacity at cycle 0 equals initial capacity.
1222    #[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    // 25. Battery aging: capacity decreases with cycles.
1229    #[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    // 26. Battery aging: capacity never goes negative.
1238    #[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    // 27. Battery aging: resistance grows with cycles.
1246    #[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    // 28. Battery aging: cycle life threshold 0.8 (80 % capacity).
1255    #[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    // 29. Nernst with n=2 gives half the correction vs n=1.
1266    #[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    // 30. Butler-Volmer: very large positive overpotential → exponentially large current.
1277    #[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    // 31. ElectrodeMaterial: gravimetric energy density formula.
1287    #[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    // 32. ElectrodeMaterial: volumetric energy density formula.
1295    #[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    // 33. SEI: higher temperature → faster growth (Arrhenius).
1303    #[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    // 34. Electrolyte: zero-length window → width is 0.
1315    #[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    // 35. Diffusion profile clamps r outside [0, 1].
1325    #[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    // ─── ElectrodeKinetics tests ─────────────────────────────────────────────
1341
1342    // 36. ElectrodeKinetics: BV at zero overpotential is zero.
1343    #[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    // 37. ElectrodeKinetics: anodic Tafel slope > 0.
1350    #[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    // 38. ElectrodeKinetics: cathodic Tafel slope same as anodic for symmetric.
1357    #[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    // 39. ElectrodeKinetics: charge-transfer resistance > 0.
1364    #[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    // 40. ElectrodeKinetics: EIS real part > 0.
1371    #[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    // 41. ElectrodeKinetics: EIS imaginary part < 0 (capacitive).
1379    #[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    // 42. ElectrodeKinetics: EIS magnitude decreases with frequency.
1387    #[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    // 43. ElectrodeKinetics: Nernst correction at c_ratio=1 gives e_ref.
1399    #[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    // 44. ElectrodeKinetics: higher i0 → lower Rct.
1407    #[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    // ─── SolidElectrolyte tests ───────────────────────────────────────────────
1415
1416    // 45. SolidElectrolyte: LLZO conductivity > 0 at 300 K.
1417    #[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    // 46. SolidElectrolyte: Arrhenius – higher T → higher conductivity.
1424    #[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    // 47. SolidElectrolyte: LLZO predominantly ionic at 300 K.
1436    #[test]
1437    fn test_solid_electrolyte_predominantly_ionic() {
1438        let se = SolidElectrolyte::llzo();
1439        assert!(se.is_predominantly_ionic(300.0));
1440    }
1441
1442    // 48. SolidElectrolyte: transference number close to 1 for LLZO.
1443    #[test]
1444    fn test_solid_electrolyte_transference_number() {
1445        let se = SolidElectrolyte::llzo();
1446        assert!(se.li_transference_number() > 0.95);
1447    }
1448
1449    // 49. SolidElectrolyte: GEIS real impedance > 0.
1450    #[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    // 50. SolidElectrolyte: GEIS imaginary impedance < 0.
1458    #[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    // 51. SolidElectrolyte: relaxation time is finite and positive.
1466    #[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    // ─── BatteryDegradation tests ────────────────────────────────────────────
1474
1475    // 52. BatteryDegradation: initial capacity at cycle 0.
1476    #[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    // 53. BatteryDegradation: capacity retention ≤ 1.
1483    #[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    // 54. BatteryDegradation: SEI grows after cycling.
1490    #[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    // 55. BatteryDegradation: plating detected below onset.
1502    #[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    // 56. BatteryDegradation: dead Li accumulates during plating cycle.
1510    #[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); // plating occurs (eta < onset)
1515        assert!(model.dead_li_capacity > dl0, "Dead Li should accumulate");
1516    }
1517
1518    // 57. BatteryDegradation: SEI resistance > 0.
1519    #[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    // ─── ThermalBattery tests ─────────────────────────────────────────────────
1526
1527    // 58. ThermalBattery: initial temperature equals ambient.
1528    #[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    // 59. ThermalBattery: Bernardi heat generation positive for discharge.
1535    #[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    // 60. ThermalBattery: step increases temperature with heat generation.
1543    #[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    // 61. ThermalBattery: steady-state temperature formula.
1555    #[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); // 4 W / 2 W/K = 2 K above ambient
1559        assert!((t_ss - 300.15).abs() < EPS, "t_ss={t_ss}");
1560    }
1561
1562    // 62. ThermalBattery: temperature rise = temperature - ambient.
1563    #[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    // 63. ThermalBattery: thermal runaway at threshold.
1571    #[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    // 64. ThermalBattery: non-uniformity is positive.
1580    #[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    // ─── BatteryCycling tests ─────────────────────────────────────────────────
1588
1589    // 65. BatteryCycling: initial SOC is 0.
1590    #[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    // 66. BatteryCycling: OCV at SOC=0 equals V_min.
1597    #[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    // 67. BatteryCycling: OCV at SOC=1 equals V_max.
1604    #[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    // 68. BatteryCycling: CC charge increases SOC.
1611    #[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    // 69. BatteryCycling: CC discharge decreases SOC.
1620    #[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    // 70. BatteryCycling: C-rate calculation.
1630    #[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    // 71. BatteryCycling: CC charge time from SOC=0 to SOC=1 at 1C.
1637    #[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); // 1C
1640        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    // 72. BatteryCycling: Coulombic efficiency after full_cycle in [0, 1].
1648    #[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    // 73. BatteryCycling: cycle count increments after full_cycle.
1657    #[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    // 74. BatteryCycling: terminal voltage charge > OCV at same SOC.
1665    #[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); // ensure consistency
1669        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    // 75. DiffusionInElectrode: max C-rate positive.
1675    #[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    // 76. DiffusionInElectrode: larger D → higher max C-rate.
1682    #[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    // 77. DiffusionInElectrode: SOC from concentration in [0, 1].
1690    #[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    // 78. SolidElectrolyte: LGPS has higher conductivity than LLZO at 300 K.
1698    #[test]
1699    fn test_solid_electrolyte_lgps_vs_llzo() {
1700        let llzo = SolidElectrolyte::llzo();
1701        let lgps = SolidElectrolyte::lgps();
1702        // LGPS has lower activation energy, should be more conductive
1703        let s_llzo = llzo.ionic_conductivity(300.0);
1704        let s_lgps = lgps.ionic_conductivity(300.0);
1705        // Both positive; order depends on pre-factor vs Ea
1706        assert!(s_llzo > 0.0 && s_lgps > 0.0);
1707    }
1708
1709    // 79. BatteryDegradation: predict_cycle_life returns positive value.
1710    #[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    // 80. ElectrodeKinetics: Tafel overpotential zero when j <= i0.
1718    #[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); // j < i0
1722        assert!((eta).abs() < EPS);
1723    }
1724}