Skip to main content

oxiphysics_materials/electrochemistry/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#[allow(unused_imports)]
6use super::functions::*;
7use super::functions::{FARADAY, GAS_CONSTANT};
8
9/// Evans diagram for mixed potential / corrosion potential analysis.
10///
11/// In the Evans (mixed potential) model, corrosion occurs at the potential where
12/// the anodic current of the metal oxidation equals the cathodic current of the
13/// oxidant reduction. This potential is the corrosion (or mixed) potential E_corr.
14///
15/// The model uses two Butler-Volmer half-reactions:
16/// - Anodic (metal dissolution): i_a = i0_a * exp(alpha_a * F * (E - E0_a) / RT)
17/// - Cathodic (reduction): i_c = i0_c * exp(-alpha_c * F * (E - E0_c) / RT)
18#[derive(Debug, Clone)]
19pub struct EvansDiagram {
20    /// Exchange current density for anodic reaction \[A/m²\]
21    pub i0_anodic: f64,
22    /// Standard potential for anodic reaction \[V\]
23    pub e0_anodic: f64,
24    /// Anodic transfer coefficient αa
25    pub alpha_a: f64,
26    /// Exchange current density for cathodic reaction \[A/m²\]
27    pub i0_cathodic: f64,
28    /// Standard potential for cathodic reaction \[V\]
29    pub e0_cathodic: f64,
30    /// Cathodic transfer coefficient αc
31    pub alpha_c: f64,
32    /// Temperature \[K\]
33    pub temperature: f64,
34}
35impl EvansDiagram {
36    /// Create a new Evans diagram model.
37    #[allow(clippy::too_many_arguments)]
38    pub fn new(
39        i0_anodic: f64,
40        e0_anodic: f64,
41        alpha_a: f64,
42        i0_cathodic: f64,
43        e0_cathodic: f64,
44        alpha_c: f64,
45        temperature: f64,
46    ) -> Self {
47        Self {
48            i0_anodic,
49            e0_anodic,
50            alpha_a,
51            i0_cathodic,
52            e0_cathodic,
53            alpha_c,
54            temperature,
55        }
56    }
57    /// F/(RT) at current temperature \[1/V\].
58    pub fn f_over_rt(&self) -> f64 {
59        FARADAY / (GAS_CONSTANT * self.temperature)
60    }
61    /// Anodic (oxidation) current density \[A/m²\] at potential E \[V\].
62    ///
63    /// Tafel form: `i_a = i0_a * exp(α_a · F · (E - E0_a) / RT)`
64    pub fn anodic_current(&self, e: f64) -> f64 {
65        let q = self.f_over_rt();
66        self.i0_anodic * (self.alpha_a * q * (e - self.e0_anodic)).exp()
67    }
68    /// Cathodic (reduction) current density \[A/m²\] at potential E \[V\].
69    ///
70    /// Tafel form: `i_c = i0_c * exp(-α_c · F · (E - E0_c) / RT)`
71    pub fn cathodic_current(&self, e: f64) -> f64 {
72        let q = self.f_over_rt();
73        self.i0_cathodic * (-self.alpha_c * q * (e - self.e0_cathodic)).exp()
74    }
75    /// Net current density \[A/m²\] at potential E: `i_net = i_a - i_c`.
76    pub fn net_current(&self, e: f64) -> f64 {
77        self.anodic_current(e) - self.cathodic_current(e)
78    }
79    /// Mixed (corrosion) potential \[V\] found by bisection.
80    ///
81    /// The corrosion potential E_corr satisfies `i_a(E_corr) = i_c(E_corr)`.
82    /// Bisects the interval \[e_low, e_high\] to find the zero of `i_net`.
83    ///
84    /// Returns `None` if sign does not change on the interval.
85    pub fn corrosion_potential(&self, e_low: f64, e_high: f64, tol: f64) -> Option<f64> {
86        let f_low = self.net_current(e_low);
87        let f_high = self.net_current(e_high);
88        if f_low * f_high > 0.0 {
89            return None;
90        }
91        let mut lo = e_low;
92        let mut hi = e_high;
93        for _ in 0..100 {
94            let mid = 0.5 * (lo + hi);
95            if (hi - lo) < tol {
96                return Some(mid);
97            }
98            let f_mid = self.net_current(mid);
99            if f_mid * self.net_current(lo) <= 0.0 {
100                hi = mid;
101            } else {
102                lo = mid;
103            }
104        }
105        Some(0.5 * (lo + hi))
106    }
107    /// Corrosion current density \[A/m²\] at the corrosion potential.
108    ///
109    /// Returns `None` if corrosion potential cannot be found.
110    pub fn corrosion_current(&self, e_low: f64, e_high: f64, tol: f64) -> Option<f64> {
111        let e_corr = self.corrosion_potential(e_low, e_high, tol)?;
112        Some(self.anodic_current(e_corr))
113    }
114}
115/// Nernst diffusion layer model.
116///
117/// Models mass-transport limitations across a stagnant diffusion layer.
118pub struct DiffusionLayer {
119    /// Diffusion layer thickness δ (m)
120    pub thickness: f64,
121    /// Diffusivity D (m²/s)
122    pub diffusivity: f64,
123    /// Bulk concentration C_bulk (mol/m³)
124    pub bulk_concentration: f64,
125}
126impl DiffusionLayer {
127    /// Create a new `DiffusionLayer`.
128    pub fn new(thickness: f64, d: f64, c_bulk: f64) -> Self {
129        Self {
130            thickness,
131            diffusivity: d,
132            bulk_concentration: c_bulk,
133        }
134    }
135    /// Limiting current density.
136    ///
137    /// `j_L = n · F · D · C_bulk / δ`
138    ///
139    /// # Arguments
140    /// * `n_electrons` — number of electrons transferred per mole of reactant
141    /// * `f_const`     — Faraday constant (C/mol)
142    /// * `area`        — electrode area (m²)
143    pub fn limiting_current(&self, n_electrons: u32, f_const: f64, area: f64) -> f64 {
144        (n_electrons as f64) * f_const * self.diffusivity * self.bulk_concentration * area
145            / self.thickness
146    }
147    /// Concentration at the electrode surface.
148    ///
149    /// `C_s = C_bulk − j · δ / (n · F · D)`
150    ///
151    /// # Arguments
152    /// * `current_density` — current density (A/m²)
153    /// * `n_electrons`     — electrons per mole
154    /// * `f_const`         — Faraday constant (C/mol)
155    pub fn concentration_at_surface(
156        &self,
157        current_density: f64,
158        n_electrons: u32,
159        f_const: f64,
160    ) -> f64 {
161        self.bulk_concentration
162            - current_density * self.thickness / ((n_electrons as f64) * f_const * self.diffusivity)
163    }
164    /// Diffusion overpotential.
165    ///
166    /// `η_d = (RT/F) · ln(C_s / C_bulk)`
167    ///
168    /// # Arguments
169    /// * `c_surface`  — surface concentration (mol/m³)
170    /// * `temp`       — temperature (K) — kept for API symmetry; used implicitly via `f_over_rt`
171    /// * `f_over_rt`  — F / (R·T) (1/V)
172    pub fn diffusion_overpotential(&self, c_surface: f64, _temp: f64, f_over_rt: f64) -> f64 {
173        (c_surface / self.bulk_concentration).ln() / f_over_rt
174    }
175}
176/// Peukert's law model for battery discharge capacity.
177///
178/// Peukert's equation relates the capacity of a battery to the discharge rate:
179///
180/// `t = C_peukert / I^k`
181///
182/// where `t` is the discharge time \[h\], `I` is the discharge current \[A\],
183/// `k` is the Peukert exponent (1 = ideal, >1 for real batteries, typically 1.1-1.3),
184/// and `C_peukert` is the Peukert capacity constant \[Ah·A^(k-1)\].
185///
186/// The actual available capacity at discharge current I is:
187/// `Q_actual = C_nominal * (I_nominal / I)^(k-1)`
188#[allow(dead_code)]
189#[derive(Debug, Clone)]
190pub struct PeukertModel {
191    /// Nominal capacity \[Ah\] at the rated discharge rate
192    pub capacity_nominal_ah: f64,
193    /// Rated discharge current \[A\] at which nominal capacity is measured
194    pub i_nominal_a: f64,
195    /// Peukert exponent k (dimensionless, typically 1.05–1.4)
196    pub peukert_exponent: f64,
197}
198impl PeukertModel {
199    /// Create a new Peukert battery model.
200    pub fn new(capacity_nominal_ah: f64, i_nominal_a: f64, peukert_exponent: f64) -> Self {
201        Self {
202            capacity_nominal_ah,
203            i_nominal_a,
204            peukert_exponent,
205        }
206    }
207    /// Lead-acid battery typical values: C=100 Ah, I_nom=5 A (C/20 rate), k=1.2
208    pub fn lead_acid_100ah() -> Self {
209        Self::new(100.0, 5.0, 1.2)
210    }
211    /// Li-ion battery: close to ideal (k ≈ 1.05)
212    pub fn li_ion_10ah() -> Self {
213        Self::new(10.0, 0.5, 1.05)
214    }
215    /// Peukert capacity constant \[Ah · A^(k-1)\].
216    ///
217    /// `C_p = Q_nom * I_nom^(k-1)`
218    pub fn peukert_constant(&self) -> f64 {
219        self.capacity_nominal_ah * self.i_nominal_a.powf(self.peukert_exponent - 1.0)
220    }
221    /// Available capacity \[Ah\] at discharge current `i_a` \[A\].
222    ///
223    /// `Q = Q_nom * (I_nom / I)^(k-1)`
224    pub fn available_capacity_ah(&self, current_a: f64) -> f64 {
225        if current_a <= 0.0 {
226            return self.capacity_nominal_ah;
227        }
228        self.capacity_nominal_ah * (self.i_nominal_a / current_a).powf(self.peukert_exponent - 1.0)
229    }
230    /// Discharge time \[h\] at constant current `i_a` \[A\].
231    ///
232    /// `t = C_p / I^k`
233    pub fn discharge_time_h(&self, current_a: f64) -> f64 {
234        if current_a <= 0.0 {
235            return f64::INFINITY;
236        }
237        self.peukert_constant() / current_a.powf(self.peukert_exponent)
238    }
239    /// C-rate (multiple of nominal capacity drained per hour).
240    ///
241    /// `C-rate = I / Q_nom`
242    pub fn c_rate(&self, current_a: f64) -> f64 {
243        current_a / self.capacity_nominal_ah
244    }
245    /// Capacity fade factor at C-rate compared to rated: Q_actual / Q_nominal.
246    pub fn capacity_fade_factor(&self, current_a: f64) -> f64 {
247        self.available_capacity_ah(current_a) / self.capacity_nominal_ah
248    }
249}
250/// Butler-Volmer electrode kinetics.
251///
252/// Models the current–overpotential relationship at an electrode surface via
253/// the Butler-Volmer equation:
254/// `j = j0 * (exp(αa·F·η/RT) − exp(−αc·F·η/RT))`.
255pub struct ButlerVolmerKinetics {
256    /// Exchange current density j₀ \[A/m²\]
257    pub i0: f64,
258    /// Anodic transfer coefficient αa (dimensionless)
259    pub alpha_a: f64,
260    /// Cathodic transfer coefficient αc (dimensionless)
261    pub alpha_c: f64,
262    /// Temperature \[K\]
263    pub temperature: f64,
264}
265impl ButlerVolmerKinetics {
266    /// Create a new `ButlerVolmerKinetics`.
267    pub fn new(i0: f64, alpha_a: f64, alpha_c: f64, temp: f64) -> Self {
268        Self {
269            i0,
270            alpha_a,
271            alpha_c,
272            temperature: temp,
273        }
274    }
275    /// Compute F/(R·T) \[1/V\] at the stored temperature.
276    pub fn f_over_rt(&self) -> f64 {
277        FARADAY / (GAS_CONSTANT * self.temperature)
278    }
279    /// Current density \[A/m²\] at overpotential `eta` \[V\].
280    pub fn current_density(&self, eta: f64) -> f64 {
281        let q = self.f_over_rt();
282        self.i0 * ((self.alpha_a * q * eta).exp() - (-self.alpha_c * q * eta).exp())
283    }
284    /// Linearised charge-transfer resistance \[Ω·m²\] near equilibrium (η → 0).
285    ///
286    /// `R_ct = RT / (i0 · (αa + αc) · F)`
287    pub fn charge_transfer_resistance(&self) -> f64 {
288        GAS_CONSTANT * self.temperature / (self.i0 * (self.alpha_a + self.alpha_c) * FARADAY)
289    }
290    /// Anodic Tafel slope \[V/decade\].
291    ///
292    /// `b_a = ln(10) · RT / (αa · F)`
293    pub fn tafel_slope_anodic(&self) -> f64 {
294        10.0_f64.ln() / (self.alpha_a * self.f_over_rt())
295    }
296    /// Cathodic Tafel slope \[V/decade\].
297    ///
298    /// `b_c = ln(10) · RT / (αc · F)`
299    pub fn tafel_slope_cathodic(&self) -> f64 {
300        10.0_f64.ln() / (self.alpha_c * self.f_over_rt())
301    }
302}
303/// Electrochemical Impedance Spectroscopy (EIS) model.
304///
305/// Models a simple Randles circuit:
306/// Z = R_s + (R_ct ∥ C_dl) + Warburg diffusion element
307///
308/// where:
309/// - R_s = solution (ohmic) resistance
310/// - R_ct = charge transfer resistance
311/// - C_dl = double-layer capacitance
312/// - W = Warburg coefficient
313#[derive(Debug, Clone)]
314pub struct ImpedanceModel {
315    /// Solution resistance R_s \[Ω\]
316    pub r_solution: f64,
317    /// Charge transfer resistance R_ct \[Ω\]
318    pub r_ct: f64,
319    /// Double-layer capacitance C_dl \[F\]
320    pub c_dl: f64,
321    /// Warburg coefficient σ \[Ω·s^{-0.5}\]
322    pub warburg_sigma: f64,
323}
324impl ImpedanceModel {
325    /// Create a new Randles circuit impedance model.
326    pub fn new(r_solution: f64, r_ct: f64, c_dl: f64, warburg_sigma: f64) -> Self {
327        Self {
328            r_solution,
329            r_ct,
330            c_dl,
331            warburg_sigma,
332        }
333    }
334    /// Angular frequency \[rad/s\] from frequency f \[Hz\].
335    pub fn angular_frequency(f_hz: f64) -> f64 {
336        2.0 * std::f64::consts::PI * f_hz
337    }
338    /// Real part of impedance Z' at angular frequency ω \[Ω\].
339    ///
340    /// Z' = R_s + (R_ct + σ/√ω) / ((1 + C_dl·ω·σ·√ω)² + (C_dl·ω·R_ct)²)  \[simplified\]
341    ///
342    /// For the Warburg element: Z_W = σ·(1 - j) / √ω
343    pub fn z_real(&self, omega: f64) -> f64 {
344        if omega < 1e-15 {
345            return self.r_solution + self.r_ct;
346        }
347        let sqrt_w = omega.sqrt();
348        let sigma = self.warburg_sigma;
349        let zw_r = sigma / sqrt_w;
350        let rw = self.r_ct + zw_r;
351        let xc = 1.0 / (omega * self.c_dl);
352        let _denom = rw * rw + xc * xc;
353        let z_par_r = rw / (1.0 + (rw / xc).powi(2));
354        self.r_solution + z_par_r
355    }
356    /// Imaginary part of impedance Z'' at angular frequency ω \[Ω\].
357    ///
358    /// Negative for capacitive behaviour.
359    pub fn z_imag(&self, omega: f64) -> f64 {
360        if omega < 1e-15 {
361            return 0.0;
362        }
363        let sqrt_w = omega.sqrt();
364        let sigma = self.warburg_sigma;
365        let zw_r = sigma / sqrt_w;
366        let zw_i = -sigma / sqrt_w;
367        let rw = self.r_ct + zw_r;
368        let xw = zw_i;
369        let xc = 1.0 / (omega * self.c_dl);
370        let denom = 1.0 + (rw / xc).powi(2);
371
372        (xw - rw * rw / xc - xc) / denom
373    }
374    /// Magnitude |Z| at angular frequency ω \[Ω\].
375    pub fn z_magnitude(&self, omega: f64) -> f64 {
376        let zr = self.z_real(omega);
377        let zi = self.z_imag(omega);
378        (zr * zr + zi * zi).sqrt()
379    }
380    /// Phase angle θ = atan(Z''/Z') \[radians\] at angular frequency ω.
381    pub fn phase_angle(&self, omega: f64) -> f64 {
382        self.z_imag(omega).atan2(self.z_real(omega))
383    }
384    /// Nyquist plot point (Z', -Z'') at frequency f \[Hz\].
385    pub fn nyquist_point(&self, f_hz: f64) -> (f64, f64) {
386        let omega = Self::angular_frequency(f_hz);
387        (self.z_real(omega), -self.z_imag(omega))
388    }
389}
390/// Concentration polarisation overpotential model.
391///
392/// When current flows, the reactant concentration at the electrode surface
393/// differs from the bulk. This creates a concentration overpotential:
394///
395/// `η_conc = (RT/nF) * ln(C_bulk / C_surface)`
396///
397/// For a simple linear diffusion layer: `C_s = C_bulk * (1 - j/j_L)`.
398#[derive(Debug, Clone)]
399pub struct ConcentrationPolarisation {
400    /// Bulk concentration \[mol/m³\]
401    pub c_bulk: f64,
402    /// Limiting current density \[A/m²\]
403    pub j_limiting: f64,
404    /// Number of electrons per reaction
405    pub n_electrons: u32,
406    /// Temperature \[K\]
407    pub temperature: f64,
408}
409impl ConcentrationPolarisation {
410    /// Create a new concentration polarisation model.
411    pub fn new(c_bulk: f64, j_limiting: f64, n_electrons: u32, temperature: f64) -> Self {
412        Self {
413            c_bulk,
414            j_limiting,
415            n_electrons,
416            temperature,
417        }
418    }
419    /// Surface concentration at current density j \[A/m²\].
420    ///
421    /// `C_s = C_bulk * (1 - j/j_L)`
422    pub fn surface_concentration(&self, j: f64) -> f64 {
423        (self.c_bulk * (1.0 - j / self.j_limiting)).max(0.0)
424    }
425    /// Concentration overpotential \[V\] at current density j \[A/m²\].
426    ///
427    /// `η_conc = -(RT/nF) * ln(1 - j/j_L)`
428    pub fn overpotential(&self, j: f64) -> f64 {
429        let c_s = self.surface_concentration(j);
430        if c_s < 1e-15 {
431            return f64::INFINITY;
432        }
433        let rt_over_nf = GAS_CONSTANT * self.temperature / (self.n_electrons as f64 * FARADAY);
434        -rt_over_nf * (c_s / self.c_bulk).ln()
435    }
436    /// Fraction of limiting current (dimensionless): `j / j_L`.
437    pub fn current_fraction(&self, j: f64) -> f64 {
438        j / self.j_limiting
439    }
440}
441/// Simplified SEI layer resistance growth model.
442///
443/// The SEI layer forms during the first charge of a Li-ion cell.
444/// Resistance grows with time and temperature via a diffusion-limited mechanism:
445///
446/// `R_SEI(t) = R_SEI_0 + k_SEI * sqrt(t)`
447///
448/// where `k_SEI` is the growth rate constant \[Ω·s^{-0.5}\] and depends on temperature
449/// via an Arrhenius factor.
450#[derive(Debug, Clone)]
451pub struct SeiLayer {
452    /// Initial SEI resistance \[Ω\]
453    pub r_sei_0: f64,
454    /// Growth rate constant at T_ref \[Ω·s^{-0.5}\]
455    pub k_sei_ref: f64,
456    /// Reference temperature \[K\]
457    pub t_ref: f64,
458    /// Activation energy for SEI growth \[J/mol\]
459    pub activation_energy: f64,
460}
461impl SeiLayer {
462    /// Create a new SEI layer model.
463    pub fn new(r_sei_0: f64, k_sei_ref: f64, t_ref: f64, activation_energy: f64) -> Self {
464        Self {
465            r_sei_0,
466            k_sei_ref,
467            t_ref,
468            activation_energy,
469        }
470    }
471    /// SEI growth rate constant at temperature T \[K\].
472    pub fn k_sei(&self, temp_k: f64) -> f64 {
473        let exponent = -self.activation_energy / GAS_CONSTANT * (1.0 / temp_k - 1.0 / self.t_ref);
474        self.k_sei_ref * exponent.exp()
475    }
476    /// SEI resistance \[Ω\] after time t \[s\] at temperature T \[K\].
477    pub fn resistance(&self, t_s: f64, temp_k: f64) -> f64 {
478        self.r_sei_0 + self.k_sei(temp_k) * t_s.sqrt()
479    }
480    /// SEI growth rate dR/dt \[Ω/s\] at time t \[s\] and temperature T \[K\].
481    pub fn growth_rate(&self, t_s: f64, temp_k: f64) -> f64 {
482        if t_s < 1e-15 {
483            return f64::INFINITY;
484        }
485        0.5 * self.k_sei(temp_k) / t_s.sqrt()
486    }
487    /// Temperature at which SEI resistance doubles in time `t_s` \[s\].
488    ///
489    /// Solves: `R_SEI_0 + k_SEI(T) * sqrt(t) = 2 * R_SEI_0`
490    pub fn time_to_double_resistance(&self, temp_k: f64) -> f64 {
491        if self.r_sei_0 < f64::EPSILON {
492            return f64::INFINITY;
493        }
494        let k = self.k_sei(temp_k);
495        (self.r_sei_0 / k).powi(2)
496    }
497}
498/// Faraday's law of electrolysis model for electroplating.
499///
500/// Mass deposited: `m = (I · t · M) / (n · F)`
501/// where M is molar mass \[g/mol\], n is electrons per ion.
502#[derive(Debug, Clone)]
503pub struct Electroplating {
504    /// Molar mass of deposited metal \[g/mol\]
505    pub molar_mass: f64,
506    /// Number of electrons per ion (valence)
507    pub valence: u32,
508    /// Current efficiency (0–1, accounts for side reactions)
509    pub current_efficiency: f64,
510}
511impl Electroplating {
512    /// Create a new electroplating model.
513    pub fn new(molar_mass: f64, valence: u32, current_efficiency: f64) -> Self {
514        Self {
515            molar_mass,
516            valence,
517            current_efficiency,
518        }
519    }
520    /// Mass deposited \[g\] for current `i_a` \[A\] over time `t_s` \[s\].
521    ///
522    /// `m = η_curr · I · t · M / (n · F)`
523    pub fn mass_deposited_g(&self, current_a: f64, time_s: f64) -> f64 {
524        self.current_efficiency * current_a * time_s * self.molar_mass
525            / (self.valence as f64 * FARADAY)
526    }
527    /// Thickness deposited \[µm\] given anode area `area_m2` \[m²\] and density `rho` \[g/cm³\].
528    pub fn thickness_um(
529        &self,
530        current_a: f64,
531        time_s: f64,
532        area_m2: f64,
533        density_g_cm3: f64,
534    ) -> f64 {
535        let mass_g = self.mass_deposited_g(current_a, time_s);
536        let volume_cm3 = mass_g / density_g_cm3;
537        let area_cm2 = area_m2 * 1.0e4;
538        let thickness_cm = volume_cm3 / area_cm2;
539        thickness_cm * 1.0e4
540    }
541    /// Required time \[s\] to deposit target mass `m_g` \[g\] at current `i_a` \[A\].
542    pub fn time_for_mass(&self, m_g: f64, current_a: f64) -> f64 {
543        m_g * self.valence as f64 * FARADAY
544            / (self.current_efficiency * current_a * self.molar_mass)
545    }
546    /// Plating rate \[µm/min\] at current density `j_a_m2` \[A/m²\] and density `rho` \[g/cm³\].
547    pub fn plating_rate_um_per_min(&self, j_a_m2: f64, density_g_cm3: f64) -> f64 {
548        let j_a_cm2 = j_a_m2 * 1.0e-4;
549        let rate_g_cm2_s =
550            self.current_efficiency * j_a_cm2 * self.molar_mass / (self.valence as f64 * FARADAY);
551        let rate_cm_s = rate_g_cm2_s / density_g_cm3;
552        rate_cm_s * 1.0e4 * 60.0
553    }
554}
555/// Simple battery cell model with state-of-charge (SOC) tracking.
556pub struct BatteryCell {
557    /// Nominal capacity (Ah)
558    pub capacity_ah: f64,
559    /// Nominal voltage (V)
560    pub nominal_voltage: f64,
561    /// Internal resistance (Ω)
562    pub internal_resistance: f64,
563    /// State of charge (0 = empty, 1 = full)
564    pub soc: f64,
565}
566impl BatteryCell {
567    /// Create a new fully-charged `BatteryCell`.
568    ///
569    /// # Arguments
570    /// * `capacity`   — capacity (Ah)
571    /// * `voltage`    — nominal voltage (V)
572    /// * `resistance` — internal resistance (Ω)
573    pub fn new(capacity: f64, voltage: f64, resistance: f64) -> Self {
574        Self {
575            capacity_ah: capacity,
576            nominal_voltage: voltage,
577            internal_resistance: resistance,
578            soc: 1.0,
579        }
580    }
581    /// Open-circuit voltage (linear SOC approximation).
582    ///
583    /// `V_oc = V_nom · (0.8 + 0.2 · SOC)`
584    pub fn open_circuit_voltage(&self) -> f64 {
585        self.nominal_voltage * (0.8 + 0.2 * self.soc)
586    }
587    /// Terminal voltage under load.
588    ///
589    /// `V = V_oc − I · R_int`  (positive `current_a` = discharge)
590    pub fn terminal_voltage(&self, current_a: f64) -> f64 {
591        self.open_circuit_voltage() - current_a * self.internal_resistance
592    }
593    /// Advance the cell by `dt_s` seconds at `current_a` amperes.
594    ///
595    /// Updates SOC: `SOC -= I · Δt / (capacity · 3600)`
596    pub fn discharge(&mut self, current_a: f64, dt_s: f64) {
597        self.soc -= current_a * dt_s / (self.capacity_ah * 3600.0);
598        self.soc = self.soc.clamp(0.0, 1.0);
599    }
600    /// Returns `true` when the cell is considered depleted (SOC < 5 %).
601    pub fn is_depleted(&self) -> bool {
602        self.soc < 0.05
603    }
604}
605/// Galvanic series potential values for common metals in seawater \[V vs SHE\].
606///
607/// Approximate values; actual values depend on environment and surface condition.
608#[allow(dead_code)]
609pub struct GalvanicSeriesEntry {
610    /// Metal name
611    pub name: &'static str,
612    /// Approximate potential in seawater \[V vs SHE\]
613    pub potential_v: f64,
614}
615/// Butler-Volmer electrode kinetics model.
616///
617/// Models the current–overpotential relationship at an electrode surface.
618pub struct ElectrodeKinetics {
619    /// Exchange current density (A/m²)
620    pub exchange_current_density: f64,
621    /// Anodic transfer coefficient (dimensionless)
622    pub transfer_coefficient_a: f64,
623    /// Cathodic transfer coefficient (dimensionless)
624    pub transfer_coefficient_c: f64,
625    /// Temperature (K)
626    pub temperature: f64,
627}
628impl ElectrodeKinetics {
629    /// Create a new `ElectrodeKinetics`.
630    ///
631    /// # Arguments
632    /// * `j0`      — exchange current density (A/m²)
633    /// * `alpha_a` — anodic transfer coefficient
634    /// * `alpha_c` — cathodic transfer coefficient
635    /// * `temp`    — temperature (K)
636    pub fn new(j0: f64, alpha_a: f64, alpha_c: f64, temp: f64) -> Self {
637        Self {
638            exchange_current_density: j0,
639            transfer_coefficient_a: alpha_a,
640            transfer_coefficient_c: alpha_c,
641            temperature: temp,
642        }
643    }
644    /// Butler-Volmer current density.
645    ///
646    /// `j = j0 * (exp(αa · F · η / RT) − exp(−αc · F · η / RT))`
647    ///
648    /// # Arguments
649    /// * `eta`      — overpotential (V)
650    /// * `f_over_rt`— F / (R·T) (1/V)
651    pub fn current_density(&self, eta: f64, f_over_rt: f64) -> f64 {
652        let j0 = self.exchange_current_density;
653        let aa = self.transfer_coefficient_a;
654        let ac = self.transfer_coefficient_c;
655        j0 * ((aa * f_over_rt * eta).exp() - (-ac * f_over_rt * eta).exp())
656    }
657    /// Linearised charge-transfer resistance at η = 0.
658    ///
659    /// `R_ct = RT / (j0 · (αa + αc) · F)` in the symmetric limit.
660    /// For the requested simplified form `∂η/∂j|_{η=0} = 1 / (j0 · F/RT)`:
661    ///
662    /// Returns `1 / (j0 · f_over_rt)` (Ω·m²).
663    ///
664    /// # Arguments
665    /// * `f_over_rt` — F / (R·T) (1/V)
666    pub fn linearized_resistance(&self, f_over_rt: f64) -> f64 {
667        1.0 / (self.exchange_current_density * f_over_rt)
668    }
669    /// Anodic Tafel slope.
670    ///
671    /// `b_a = ln(10) · RT / (αa · F)`
672    ///
673    /// # Arguments
674    /// * `f_over_rt` — F / (R·T) (1/V)
675    pub fn tafel_slope_anodic(&self, f_over_rt: f64) -> f64 {
676        10.0_f64.ln() / (self.transfer_coefficient_a * f_over_rt)
677    }
678}
679/// Galvanic couple model: two dissimilar metals in electrical contact in an electrolyte.
680///
681/// The more anodic metal corrodes preferentially. The couple potential and
682/// current are determined by the intersection of the polarisation curves.
683#[derive(Debug, Clone)]
684pub struct GalvanicCouple {
685    /// Metal 1 (anode): corrosion potential \[V\]
686    pub e_corr_1: f64,
687    /// Metal 1 Tafel slope anodic \[V/decade\]
688    pub b_a1: f64,
689    /// Metal 1 corrosion current \[A\]
690    pub i_corr_1: f64,
691    /// Metal 2 (cathode): corrosion potential \[V\]
692    pub e_corr_2: f64,
693    /// Metal 2 Tafel slope cathodic \[V/decade\]
694    pub b_c2: f64,
695    /// Metal 2 corrosion current \[A\]
696    pub i_corr_2: f64,
697}
698impl GalvanicCouple {
699    /// Create a new galvanic couple model.
700    pub fn new(
701        e_corr_1: f64,
702        b_a1: f64,
703        i_corr_1: f64,
704        e_corr_2: f64,
705        b_c2: f64,
706        i_corr_2: f64,
707    ) -> Self {
708        Self {
709            e_corr_1,
710            b_a1,
711            i_corr_1,
712            e_corr_2,
713            b_c2,
714            i_corr_2,
715        }
716    }
717    /// Anodic current of metal 1 at potential E \[A\]: Tafel approximation.
718    pub fn anodic_current_1(&self, e: f64) -> f64 {
719        self.i_corr_1 * 10.0_f64.powf((e - self.e_corr_1) / self.b_a1)
720    }
721    /// Cathodic current of metal 2 at potential E \[A\]: Tafel approximation.
722    pub fn cathodic_current_2(&self, e: f64) -> f64 {
723        self.i_corr_2 * 10.0_f64.powf(-(e - self.e_corr_2) / self.b_c2)
724    }
725    /// Couple potential \[V\]: found by bisection where `i_a1 = i_c2`.
726    pub fn couple_potential(&self, tol: f64) -> Option<f64> {
727        let e_lo = self.e_corr_1.min(self.e_corr_2) - 0.1;
728        let e_hi = self.e_corr_1.max(self.e_corr_2) + 0.1;
729        let f = |e: f64| self.anodic_current_1(e) - self.cathodic_current_2(e);
730        let fl = f(e_lo);
731        let fh = f(e_hi);
732        if fl * fh > 0.0 {
733            return None;
734        }
735        let mut lo = e_lo;
736        let mut hi = e_hi;
737        for _ in 0..100 {
738            let mid = 0.5 * (lo + hi);
739            if (hi - lo) < tol {
740                return Some(mid);
741            }
742            if f(mid) * f(lo) <= 0.0 {
743                hi = mid;
744            } else {
745                lo = mid;
746            }
747        }
748        Some(0.5 * (lo + hi))
749    }
750    /// Galvanic current \[A\] at the couple potential.
751    pub fn galvanic_current(&self, tol: f64) -> Option<f64> {
752        let e_couple = self.couple_potential(tol)?;
753        Some(self.anodic_current_1(e_couple))
754    }
755}
756/// Li-ion battery degradation model.
757///
758/// Models capacity fade and resistance growth over cycling using
759/// empirical power-law and SEI-layer growth models.
760///
761/// Reference: Plett, "Battery Management Systems", 2015.
762#[derive(Debug, Clone)]
763pub struct LiIonDegradation {
764    /// Initial capacity \[Ah\].
765    pub capacity_init: f64,
766    /// Current capacity \[Ah\].
767    pub capacity: f64,
768    /// Initial internal resistance \[Ω\].
769    pub resistance_init: f64,
770    /// Current internal resistance \[Ω\].
771    pub resistance: f64,
772    /// Total charge throughput \[Ah\].
773    pub throughput_ah: f64,
774    /// Cycle count.
775    pub n_cycles: u64,
776    /// Calendar age \[days\].
777    pub calendar_days: f64,
778}
779impl LiIonDegradation {
780    /// Create a new, fresh battery cell.
781    pub fn new(capacity: f64, resistance: f64) -> Self {
782        Self {
783            capacity_init: capacity,
784            capacity,
785            resistance_init: resistance,
786            resistance,
787            throughput_ah: 0.0,
788            n_cycles: 0,
789            calendar_days: 0.0,
790        }
791    }
792    /// State of health based on capacity fade (SOH = Q/Q_init).
793    pub fn soh_capacity(&self) -> f64 {
794        self.capacity / self.capacity_init
795    }
796    /// State of health based on resistance growth (SOH_R = R_init/R).
797    pub fn soh_resistance(&self) -> f64 {
798        self.resistance_init / self.resistance
799    }
800    /// Update capacity fade after `delta_ah` Ah of throughput.
801    ///
802    /// Simple power-law model: Q(Ah) = Q0 * (1 - k_ah * Ah^0.5)
803    /// where k_ah is the fade coefficient per √Ah.
804    pub fn update_cycle_fade(&mut self, delta_ah: f64, k_fade: f64) {
805        self.throughput_ah += delta_ah;
806        self.n_cycles += 1;
807        let fade = k_fade * self.throughput_ah.sqrt();
808        self.capacity = self.capacity_init * (1.0 - fade).max(0.0);
809    }
810    /// Update SEI-layer resistance growth.
811    ///
812    /// R(t) = R0 * (1 + k_sei * sqrt(t))  \[calendar aging\]
813    pub fn update_calendar_aging(&mut self, delta_days: f64, k_sei: f64) {
814        self.calendar_days += delta_days;
815        self.resistance = self.resistance_init * (1.0 + k_sei * self.calendar_days.sqrt());
816    }
817    /// Check if the battery has reached end of life (SOH < 80%).
818    pub fn is_end_of_life(&self) -> bool {
819        self.soh_capacity() < 0.8
820    }
821    /// Remaining useful life estimate \[cycles\] using a linear fade model.
822    ///
823    /// Extrapolates from current fade rate to SOH = 0.8.
824    pub fn estimated_remaining_cycles(&self, k_fade: f64) -> Option<u64> {
825        if k_fade < 1e-30 {
826            return None;
827        }
828        let ah_eol = (0.2 / k_fade).powi(2);
829        if ah_eol <= self.throughput_ah {
830            return Some(0);
831        }
832        let remaining_ah = ah_eol - self.throughput_ah;
833        if self.n_cycles == 0 {
834            return None;
835        }
836        let ah_per_cycle = self.throughput_ah / self.n_cycles as f64;
837        Some((remaining_ah / ah_per_cycle) as u64)
838    }
839}
840/// Gouy-Chapman-Stern (GCS) model for the electrical double layer.
841///
842/// Models the differential capacitance of the diffuse layer as a function
843/// of potential difference across the Helmholtz layer.
844///
845/// The Debye length κ⁻¹ = sqrt(ε·RT / (2·n0·F²))
846/// where n0 is bulk ion concentration \[mol/m³\] and ε is permittivity \[F/m\].
847#[allow(dead_code)]
848#[derive(Debug, Clone)]
849pub struct DoubleLayerCapacitance {
850    /// Bulk ion concentration \[mol/m³\] (symmetric 1:1 electrolyte)
851    pub concentration_mol_m3: f64,
852    /// Permittivity of solvent \[F/m\] (water ≈ 78.5 × ε_0)
853    pub permittivity: f64,
854    /// Temperature \[K\]
855    pub temperature: f64,
856}
857impl DoubleLayerCapacitance {
858    /// Create a new double-layer model.
859    pub fn new(concentration_mol_m3: f64, permittivity: f64, temperature: f64) -> Self {
860        Self {
861            concentration_mol_m3,
862            permittivity,
863            temperature,
864        }
865    }
866    /// Aqueous KCl at 0.1 mol/L and 25°C.
867    pub fn kcl_01_mol_l() -> Self {
868        Self::new(100.0, 78.5 * 8.854e-12, 298.15)
869    }
870    /// Debye length \[m\]: κ⁻¹ = sqrt(ε·R·T / (2·c·F²))
871    pub fn debye_length(&self) -> f64 {
872        let numerator = self.permittivity * GAS_CONSTANT * self.temperature;
873        let denominator = 2.0 * self.concentration_mol_m3 * FARADAY * FARADAY;
874        (numerator / denominator).sqrt()
875    }
876    /// Diffuse-layer capacitance per unit area \[F/m²\] at the potential of zero charge.
877    ///
878    /// `C_d = ε / κ⁻¹`
879    pub fn capacitance_at_pzc(&self) -> f64 {
880        self.permittivity / self.debye_length()
881    }
882    /// Differential capacitance \[F/m²\] at potential ψ \[V\] (linearised GC model).
883    ///
884    /// `C(ψ) = ε * κ * cosh(F·ψ / (2·R·T))`
885    pub fn differential_capacitance(&self, psi: f64) -> f64 {
886        let kappa = 1.0 / self.debye_length();
887        let arg = FARADAY * psi / (2.0 * GAS_CONSTANT * self.temperature);
888        self.permittivity * kappa * arg.cosh()
889    }
890}
891/// Corrosion kinetics from Tafel polarisation data.
892///
893/// The corrosion rate is derived from the corrosion current density `i_corr`
894/// via: `CR [mm/year] = (i_corr · M_w) / (n · F · ρ) · K`
895/// where `K = 3.27 × 10⁻³` (unit conversion constant for SI inputs).
896#[derive(Debug, Clone)]
897pub struct CorrosionRate {
898    /// Corrosion current density i_corr \[A/m²\]
899    pub i_corr: f64,
900    /// Molar mass of the metal \[g/mol\]
901    pub molar_mass: f64,
902    /// Number of electrons in the oxidation reaction
903    pub n_electrons: u32,
904    /// Density of the metal \[g/cm³\]
905    pub density_g_cm3: f64,
906}
907impl CorrosionRate {
908    /// Create a new corrosion rate model.
909    pub fn new(i_corr: f64, molar_mass: f64, n_electrons: u32, density_g_cm3: f64) -> Self {
910        Self {
911            i_corr,
912            molar_mass,
913            n_electrons,
914            density_g_cm3,
915        }
916    }
917    /// Corrosion rate \[mm/year\].
918    ///
919    /// Uses the standard electrochemical formula with unit conversion constant
920    /// `K = 3.27 × 10⁻³` mm·g/(µA·cm·year) adapted to SI (A/m²).
921    pub fn mm_per_year(&self) -> f64 {
922        let i_ua_cm2 = self.i_corr * 0.1;
923        0.00327 * i_ua_cm2 * self.molar_mass / ((self.n_electrons as f64) * self.density_g_cm3)
924    }
925    /// Corrosion current density from Tafel slopes using the Stern-Geary equation.
926    ///
927    /// `i_corr = B / R_p`  where `B = (b_a · b_c) / (2.303 · (b_a + b_c))`
928    ///
929    /// # Arguments
930    /// * `rp`  — polarisation resistance \[Ω·m²\]
931    /// * `b_a` — anodic Tafel slope \[V/decade\]
932    /// * `b_c` — cathodic Tafel slope \[V/decade\]
933    pub fn from_polarisation_resistance(rp: f64, b_a: f64, b_c: f64) -> f64 {
934        let b = (b_a * b_c) / (2.303 * (b_a + b_c));
935        b / rp
936    }
937}
938/// Battery equivalent-circuit model with internal resistance and one RC pair.
939///
940/// Topology: `V_oc − R0 − (R1 ∥ C1) = V_terminal`.
941/// The RC pair captures diffusion / charge-transfer relaxation dynamics.
942#[derive(Debug, Clone)]
943pub struct BatteryModel {
944    /// Nominal capacity \[Ah\]
945    pub capacity_ah: f64,
946    /// State of charge (0 = empty, 1 = full)
947    pub soc: f64,
948    /// Series (ohmic) resistance R₀ \[Ω\]
949    pub r0: f64,
950    /// RC branch resistance R₁ \[Ω\]
951    pub r1: f64,
952    /// RC branch capacitance C₁ \[F\]
953    pub c1: f64,
954    /// Voltage across the RC branch (state variable) \[V\]
955    pub u_rc: f64,
956}
957impl BatteryModel {
958    /// Create a fully charged battery model.
959    ///
960    /// # Arguments
961    /// * `capacity_ah` — capacity \[Ah\]
962    /// * `r0`          — series resistance \[Ω\]
963    /// * `r1`          — RC branch resistance \[Ω\]
964    /// * `c1`          — RC branch capacitance \[F\]
965    pub fn new(capacity_ah: f64, r0: f64, r1: f64, c1: f64) -> Self {
966        Self {
967            capacity_ah,
968            soc: 1.0,
969            r0,
970            r1,
971            c1,
972            u_rc: 0.0,
973        }
974    }
975    /// Open-circuit voltage from a piecewise-linear SOC-OCV curve.
976    ///
977    /// Uses a simplified linear model: `V_oc = 3.0 + 1.2 · SOC` (LFP-like).
978    pub fn open_circuit_voltage(&self) -> f64 {
979        3.0 + 1.2 * self.soc
980    }
981    /// Terminal voltage under load \[V\].
982    ///
983    /// `V = V_oc − R0 · I − U_rc`
984    pub fn terminal_voltage(&self, current_a: f64) -> f64 {
985        self.open_circuit_voltage() - self.r0 * current_a - self.u_rc
986    }
987    /// Advance simulation by `dt_s` seconds at current `current_a` \[A\].
988    ///
989    /// Updates SOC and RC branch voltage via first-order Euler integration.
990    pub fn step(&mut self, current_a: f64, dt_s: f64) {
991        let tau = self.r1 * self.c1;
992        self.u_rc += (self.r1 * current_a - self.u_rc) / tau * dt_s;
993        self.soc -= current_a * dt_s / (self.capacity_ah * 3600.0);
994        self.soc = self.soc.clamp(0.0, 1.0);
995    }
996    /// Returns `true` when the cell is considered depleted (SOC < 5 %).
997    pub fn is_depleted(&self) -> bool {
998        self.soc < 0.05
999    }
1000    /// Instantaneous power \[W\] (positive = discharge).
1001    pub fn power(&self, current_a: f64) -> f64 {
1002        self.terminal_voltage(current_a) * current_a
1003    }
1004}
1005/// Hydrogen PEM fuel cell polarisation curve model.
1006///
1007/// Total overpotential = activation loss + ohmic loss + concentration loss.
1008#[derive(Debug, Clone)]
1009pub struct FuelCellStack {
1010    /// Reversible (Nernst) cell voltage \[V\]
1011    pub e_rev: f64,
1012    /// Exchange current density i₀ \[A/m²\]
1013    pub i0: f64,
1014    /// Tafel slope for activation loss \[V/decade\] (positive value)
1015    pub tafel_slope: f64,
1016    /// Ohmic area-specific resistance \[Ω·m²\]
1017    pub r_ohmic: f64,
1018    /// Limiting current density i_L \[A/m²\]
1019    pub i_lim: f64,
1020    /// Concentration loss empirical coefficient m \[V\]
1021    pub m_conc: f64,
1022    /// Concentration loss empirical coefficient n \[m²/A\]
1023    pub n_conc: f64,
1024}
1025impl FuelCellStack {
1026    /// Create a new fuel cell stack model.
1027    #[allow(clippy::too_many_arguments)]
1028    pub fn new(
1029        e_rev: f64,
1030        i0: f64,
1031        tafel_slope: f64,
1032        r_ohmic: f64,
1033        i_lim: f64,
1034        m_conc: f64,
1035        n_conc: f64,
1036    ) -> Self {
1037        Self {
1038            e_rev,
1039            i0,
1040            tafel_slope,
1041            r_ohmic,
1042            i_lim,
1043            m_conc,
1044            n_conc,
1045        }
1046    }
1047    /// Activation overpotential \[V\] at current density `j` \[A/m²\].
1048    ///
1049    /// `η_act = tafel_slope · log10(j / i0)`
1050    pub fn activation_loss(&self, j: f64) -> f64 {
1051        if j <= 0.0 || j < self.i0 {
1052            return 0.0;
1053        }
1054        self.tafel_slope * (j / self.i0).log10()
1055    }
1056    /// Ohmic overpotential \[V\] at current density `j` \[A/m²\].
1057    ///
1058    /// `η_ohm = R_ohmic · j`
1059    pub fn ohmic_loss(&self, j: f64) -> f64 {
1060        self.r_ohmic * j
1061    }
1062    /// Concentration overpotential \[V\] at current density `j` \[A/m²\].
1063    ///
1064    /// `η_conc = m · exp(n · j)`
1065    pub fn concentration_loss(&self, j: f64) -> f64 {
1066        self.m_conc * (self.n_conc * j).exp()
1067    }
1068    /// Cell terminal voltage \[V\] at current density `j` \[A/m²\].
1069    ///
1070    /// `V = E_rev − η_act − η_ohm − η_conc`
1071    ///
1072    /// Returns 0.0 if j ≥ i_lim (cell has stalled).
1073    pub fn cell_voltage(&self, j: f64) -> f64 {
1074        if j >= self.i_lim {
1075            return 0.0;
1076        }
1077        let v =
1078            self.e_rev - self.activation_loss(j) - self.ohmic_loss(j) - self.concentration_loss(j);
1079        v.max(0.0)
1080    }
1081    /// Power density \[W/m²\] at current density `j` \[A/m²\].
1082    pub fn power_density(&self, j: f64) -> f64 {
1083        j * self.cell_voltage(j)
1084    }
1085    /// Efficiency relative to reversible voltage.
1086    ///
1087    /// `η = V_cell / E_rev`
1088    pub fn efficiency(&self, j: f64) -> f64 {
1089        self.cell_voltage(j) / self.e_rev
1090    }
1091}
1092/// Electrolyte ionic conductivity model.
1093///
1094/// Provides the Kohlrausch law for strong electrolytes and the
1095/// Casteel-Amis empirical model for concentrated solutions.
1096#[derive(Debug, Clone)]
1097pub struct ElectrolyteConductivity {
1098    /// Limiting molar conductivity Λ₀ \[S·m²/mol\] (at infinite dilution).
1099    pub lambda_0: f64,
1100    /// Kohlrausch coefficient B \[S·m²·mol^{-3/2}\].
1101    pub kohlrausch_b: f64,
1102    /// Temperature \[K\].
1103    pub temperature: f64,
1104}
1105impl ElectrolyteConductivity {
1106    /// Create a new electrolyte conductivity model.
1107    pub fn new(lambda_0: f64, kohlrausch_b: f64, temperature: f64) -> Self {
1108        Self {
1109            lambda_0,
1110            kohlrausch_b,
1111            temperature,
1112        }
1113    }
1114    /// Kohlrausch law: Λ(c) = Λ₀ - B·√c \[S·m²/mol\].
1115    ///
1116    /// Valid for dilute solutions (c < ~0.01 mol/L).
1117    pub fn molar_conductivity(&self, conc: f64) -> f64 {
1118        (self.lambda_0 - self.kohlrausch_b * conc.sqrt()).max(0.0)
1119    }
1120    /// Specific conductivity κ = Λ·c \[S/m\].
1121    pub fn specific_conductivity(&self, conc: f64) -> f64 {
1122        self.molar_conductivity(conc) * conc
1123    }
1124    /// Resistivity ρ = 1/κ \[Ω·m\].
1125    pub fn resistivity(&self, conc: f64) -> f64 {
1126        let kappa = self.specific_conductivity(conc);
1127        if kappa < f64::EPSILON {
1128            return f64::INFINITY;
1129        }
1130        1.0 / kappa
1131    }
1132    /// Temperature correction using Arrhenius model.
1133    ///
1134    /// κ(T) = κ(T_ref) * exp(-E_a/R * (1/T - 1/T_ref))
1135    ///
1136    /// Returns the conductivity at temperature T given conductivity at T_ref.
1137    pub fn temperature_corrected_conductivity(
1138        &self,
1139        kappa_ref: f64,
1140        t_ref: f64,
1141        activation_energy: f64,
1142    ) -> f64 {
1143        const R: f64 = 8.314_462_618;
1144        let exponent = -activation_energy / R * (1.0 / self.temperature - 1.0 / t_ref);
1145        kappa_ref * exponent.exp()
1146    }
1147    /// Transference number for the cation (fraction of current carried by cation).
1148    ///
1149    /// Uses the ratio of limiting ionic conductivities.
1150    /// t+ = λ+ / (λ+ + λ-)
1151    pub fn transference_number_cation(lambda_plus: f64, lambda_minus: f64) -> f64 {
1152        lambda_plus / (lambda_plus + lambda_minus)
1153    }
1154}