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}