#[cfg(test)]
use std::f64::consts::PI;
const FARADAY: f64 = 96485.332;
const R_GAS: f64 = 8.314462;
const EPSILON_0: f64 = 8.854187817e-12;
#[derive(Debug, Clone)]
pub struct IonSpecies {
pub name: String,
pub charge: i32,
pub diffusivity: f64,
pub bulk_concentration: f64,
}
impl IonSpecies {
pub fn new(name: &str, charge: i32, diffusivity: f64, bulk_concentration: f64) -> Self {
Self {
name: name.to_owned(),
charge,
diffusivity,
bulk_concentration,
}
}
}
#[derive(Debug, Clone)]
pub struct NernstPlanckFem {
pub temperature: f64,
pub species: Vec<IonSpecies>,
pub n_nodes: usize,
pub length: f64,
pub concentration: Vec<Vec<f64>>,
pub potential: Vec<f64>,
pub velocity: Vec<f64>,
pub time: f64,
pub dt: f64,
}
impl NernstPlanckFem {
pub fn new(
temperature: f64,
species: Vec<IonSpecies>,
n_nodes: usize,
length: f64,
dt: f64,
) -> Self {
let dx = length / (n_nodes as f64 - 1.0);
let concentration = species
.iter()
.map(|sp| vec![sp.bulk_concentration; n_nodes])
.collect();
let potential = (0..n_nodes)
.map(|i| i as f64 * dx * 0.0)
.collect::<Vec<_>>();
let velocity = vec![0.0; n_nodes];
Self {
temperature,
species,
n_nodes,
length,
concentration,
potential,
velocity,
time: 0.0,
dt,
}
}
pub fn thermal_voltage(&self) -> f64 {
R_GAS * self.temperature / FARADAY
}
pub fn element_diffusion_matrix(&self, diffusivity: f64, h_elem: f64) -> [[f64; 2]; 2] {
let c = diffusivity / h_elem;
[[c, -c], [-c, c]]
}
pub fn element_migration_matrix(
&self,
species_idx: usize,
h_elem: f64,
dphi_dx: f64,
) -> [[f64; 2]; 2] {
let sp = &self.species[species_idx];
let vt = self.thermal_voltage();
let mu = sp.diffusivity * sp.charge as f64 / vt; let a = mu * dphi_dx; let peclet = a * h_elem / (2.0 * sp.diffusivity + 1e-30);
let alpha = peclet.tanh() / peclet.abs().max(1e-30) * peclet.signum();
let kappa = a * h_elem / 2.0 * alpha;
let base = a / 2.0;
[
[-base + kappa / h_elem, base - kappa / h_elem],
[-base - kappa / h_elem, base + kappa / h_elem],
]
}
pub fn assemble_species_matrix(&self, species_idx: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = self.n_nodes;
let h = self.length / (n as f64 - 1.0);
let sp = &self.species[species_idx];
let mut lower = vec![0.0; n];
let mut diag = vec![0.0; n];
let mut upper = vec![0.0; n];
let mass_c = h / 6.0 / self.dt;
for i in 0..n {
diag[i] += 2.0 * mass_c;
if i + 1 < n {
upper[i] += mass_c;
lower[i + 1] += mass_c;
}
}
for i in 0..n - 1 {
let phi_l = self.potential[i];
let phi_r = self.potential[i + 1];
let dphi_dx = (phi_r - phi_l) / h;
let dm = self.element_diffusion_matrix(sp.diffusivity, h);
let mm = self.element_migration_matrix(species_idx, h, dphi_dx);
diag[i] += dm[0][0] + mm[0][0];
upper[i] += dm[0][1] + mm[0][1];
lower[i + 1] += dm[1][0] + mm[1][0];
diag[i + 1] += dm[1][1] + mm[1][1];
}
(lower, diag, upper)
}
pub fn assemble_species_rhs(&self, species_idx: usize) -> Vec<f64> {
let n = self.n_nodes;
let h = self.length / (n as f64 - 1.0);
let c = &self.concentration[species_idx];
let mass_c = h / 6.0 / self.dt;
let mut rhs = vec![0.0; n];
for i in 0..n - 1 {
rhs[i] += mass_c * (2.0 * c[i] + c[i + 1]);
rhs[i + 1] += mass_c * (c[i] + 2.0 * c[i + 1]);
}
rhs
}
pub fn solve_tridiagonal(lower: &[f64], diag: &[f64], upper: &[f64], rhs: &[f64]) -> Vec<f64> {
let n = diag.len();
let mut c_prime = vec![0.0; n];
let mut d_prime = vec![0.0; n];
let mut x = vec![0.0; n];
c_prime[0] = upper[0] / diag[0];
d_prime[0] = rhs[0] / diag[0];
for i in 1..n {
let m = diag[i] - lower[i] * c_prime[i - 1];
c_prime[i] = if i + 1 < n { upper[i] / m } else { 0.0 };
d_prime[i] = (rhs[i] - lower[i] * d_prime[i - 1]) / m;
}
x[n - 1] = d_prime[n - 1];
for i in (0..n - 1).rev() {
x[i] = d_prime[i] - c_prime[i] * x[i + 1];
}
x
}
pub fn step(&mut self) {
let ns = self.species.len();
for s in 0..ns {
let (lower, diag, upper) = self.assemble_species_matrix(s);
let rhs = self.assemble_species_rhs(s);
let c_new = Self::solve_tridiagonal(&lower, &diag, &upper, &rhs);
self.concentration[s] = c_new;
}
self.time += self.dt;
}
pub fn current_density(&self, species_idx: usize, node: usize) -> f64 {
let n = self.n_nodes;
let h = self.length / (n as f64 - 1.0);
let sp = &self.species[species_idx];
let vt = self.thermal_voltage();
let c = &self.concentration[species_idx];
let phi = &self.potential;
let (dc_dx, dphi_dx, c_mid, u_mid) = if node == 0 {
(
(c[1] - c[0]) / h,
(phi[1] - phi[0]) / h,
(c[0] + c[1]) * 0.5,
(self.velocity[0] + self.velocity[1]) * 0.5,
)
} else if node == n - 1 {
(
(c[n - 1] - c[n - 2]) / h,
(phi[n - 1] - phi[n - 2]) / h,
(c[n - 2] + c[n - 1]) * 0.5,
(self.velocity[n - 2] + self.velocity[n - 1]) * 0.5,
)
} else {
(
(c[node + 1] - c[node - 1]) / (2.0 * h),
(phi[node + 1] - phi[node - 1]) / (2.0 * h),
c[node],
self.velocity[node],
)
};
let j = -sp.diffusivity * dc_dx - sp.diffusivity * sp.charge as f64 / vt * c_mid * dphi_dx
+ c_mid * u_mid;
sp.charge as f64 * FARADAY * j
}
pub fn total_current(&self, species_idx: usize) -> f64 {
let n = self.n_nodes;
(0..n)
.map(|i| self.current_density(species_idx, i))
.sum::<f64>()
/ n as f64
}
}
#[derive(Debug, Clone)]
pub struct PoissonBoltzmannFem {
pub epsilon_r: f64,
pub temperature: f64,
pub species: Vec<IonSpecies>,
pub n_nodes: usize,
pub length: f64,
pub potential: Vec<f64>,
pub phi_surface: f64,
}
impl PoissonBoltzmannFem {
pub fn new(
epsilon_r: f64,
temperature: f64,
species: Vec<IonSpecies>,
n_nodes: usize,
length: f64,
phi_surface: f64,
) -> Self {
Self {
epsilon_r,
temperature,
species,
n_nodes,
length,
potential: vec![0.0; n_nodes],
phi_surface,
}
}
pub fn ionic_strength(&self) -> f64 {
0.5 * self
.species
.iter()
.map(|sp| (sp.charge as f64).powi(2) * sp.bulk_concentration)
.sum::<f64>()
}
pub fn debye_length(&self) -> f64 {
let i_str = self.ionic_strength().max(1e-10);
let eps = self.epsilon_r * EPSILON_0;
(eps * R_GAS * self.temperature / (2.0 * FARADAY.powi(2) * i_str)).sqrt()
}
pub fn inverse_debye_length(&self) -> f64 {
1.0 / self.debye_length()
}
pub fn solve(&mut self) {
let n = self.n_nodes;
let h = self.length / (n as f64 - 1.0);
let kappa = self.inverse_debye_length();
let kappa2 = kappa * kappa;
let mut lower = vec![0.0_f64; n];
let mut diag = vec![0.0_f64; n];
let mut upper = vec![0.0_f64; n];
let mut rhs = vec![0.0_f64; n];
for i in 0..n - 1 {
let k_diff = 1.0 / h;
let m_mass = h / 6.0;
diag[i] += k_diff + 2.0 * m_mass * kappa2;
upper[i] += -k_diff + m_mass * kappa2;
lower[i + 1] += -k_diff + m_mass * kappa2;
diag[i + 1] += k_diff + 2.0 * m_mass * kappa2;
}
diag[0] = 1.0;
upper[0] = 0.0;
rhs[0] = self.phi_surface;
diag[n - 1] = 1.0;
lower[n - 1] = 0.0;
rhs[n - 1] = 0.0;
self.potential = NernstPlanckFem::solve_tridiagonal(&lower, &diag, &upper, &rhs);
}
pub fn surface_charge_density(&self) -> f64 {
if self.n_nodes < 2 {
return 0.0;
}
let h = self.length / (self.n_nodes as f64 - 1.0);
let dphi = (self.potential[1] - self.potential[0]) / h;
-self.epsilon_r * EPSILON_0 * dphi
}
pub fn differential_capacitance(&self) -> f64 {
self.epsilon_r * EPSILON_0 * self.inverse_debye_length()
}
pub fn analytical_solution(&self, x: f64) -> f64 {
self.phi_surface * (-self.inverse_debye_length() * x).exp()
}
pub fn zeta_potential(&self) -> f64 {
if self.potential.is_empty() {
0.0
} else {
self.potential[0]
}
}
}
#[derive(Debug, Clone)]
pub struct ButlerVolmerFem {
pub temperature: f64,
pub exchange_current: f64,
pub alpha_anodic: f64,
pub alpha_cathodic: f64,
pub equilibrium_potential: f64,
pub phi_solid: f64,
pub phi_liquid: f64,
}
impl ButlerVolmerFem {
pub fn new(
temperature: f64,
exchange_current: f64,
alpha_anodic: f64,
alpha_cathodic: f64,
equilibrium_potential: f64,
) -> Self {
Self {
temperature,
exchange_current,
alpha_anodic,
alpha_cathodic,
equilibrium_potential,
phi_solid: 0.0,
phi_liquid: 0.0,
}
}
pub fn set_potentials(&mut self, phi_solid: f64, phi_liquid: f64) {
self.phi_solid = phi_solid;
self.phi_liquid = phi_liquid;
}
pub fn overpotential(&self) -> f64 {
self.phi_solid - self.phi_liquid - self.equilibrium_potential
}
pub fn current_density(&self) -> f64 {
let eta = self.overpotential();
let f_rt = FARADAY / (R_GAS * self.temperature);
self.exchange_current
* ((self.alpha_anodic * f_rt * eta).exp() - (-self.alpha_cathodic * f_rt * eta).exp())
}
pub fn linearized_conductance(&self) -> f64 {
let f_rt = FARADAY / (R_GAS * self.temperature);
self.exchange_current * (self.alpha_anodic + self.alpha_cathodic) * f_rt
}
pub fn tafel_slope_anodic(&self) -> f64 {
R_GAS * self.temperature / (self.alpha_anodic * FARADAY) * 10.0_f64.ln()
}
pub fn tafel_slope_cathodic(&self) -> f64 {
R_GAS * self.temperature / (self.alpha_cathodic * FARADAY) * 10.0_f64.ln()
}
pub fn tangent_contribution(&self) -> f64 {
let eta = self.overpotential();
let f_rt = FARADAY / (R_GAS * self.temperature);
self.exchange_current
* f_rt
* (self.alpha_anodic * (self.alpha_anodic * f_rt * eta).exp()
+ self.alpha_cathodic * (-self.alpha_cathodic * f_rt * eta).exp())
}
pub fn ocp_graphite(soc: f64) -> f64 {
let u = soc.clamp(0.001, 0.999);
0.7222 + 0.1387 * u + 0.029 * u.powf(-0.5) - 0.0172 / u + 1.54e-4 / u.powi(2)
- 0.1231 * (-33.0 * u).exp()
- 0.2 * (5.0 * u - 3.5).exp()
}
pub fn ocp_licoo2(soc: f64) -> f64 {
let u = soc.clamp(0.001, 0.999);
3.5 + 0.4 * u - 0.1 * u.powi(2)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DfnDomain {
NegativeElectrode,
Separator,
PositiveElectrode,
}
#[derive(Debug, Clone)]
pub struct ElectrodeDomain {
pub domain: DfnDomain,
pub thickness: f64,
pub n_macro: usize,
pub n_micro: usize,
pub diffusivity_solid: f64,
pub c_solid_max: f64,
pub particle_radius: f64,
pub porosity: f64,
pub bruggeman: f64,
pub sigma_solid: f64,
pub j0_ref: f64,
}
impl ElectrodeDomain {
pub fn default_negative() -> Self {
Self {
domain: DfnDomain::NegativeElectrode,
thickness: 100e-6,
n_macro: 10,
n_micro: 5,
diffusivity_solid: 3.9e-14,
c_solid_max: 31370.0,
particle_radius: 2.0e-6,
porosity: 0.3,
bruggeman: 1.5,
sigma_solid: 100.0,
j0_ref: 1.0,
}
}
pub fn default_positive() -> Self {
Self {
domain: DfnDomain::PositiveElectrode,
thickness: 80e-6,
n_macro: 10,
n_micro: 5,
diffusivity_solid: 1.0e-14,
c_solid_max: 51218.0,
particle_radius: 3.0e-6,
porosity: 0.3,
bruggeman: 1.5,
sigma_solid: 3.8,
j0_ref: 1.0,
}
}
pub fn effective_electrolyte_diffusivity(&self, d_electrolyte: f64) -> f64 {
d_electrolyte * self.porosity.powf(self.bruggeman)
}
pub fn effective_conductivity(&self, kappa: f64) -> f64 {
kappa * self.porosity.powf(self.bruggeman)
}
}
#[derive(Debug, Clone)]
pub struct BatteryFem {
pub neg: ElectrodeDomain,
pub pos: ElectrodeDomain,
pub separator_thickness: f64,
pub n_sep: usize,
pub d_electrolyte: f64,
pub kappa_electrolyte: f64,
pub transference: f64,
pub temperature: f64,
pub applied_current: f64,
pub c_solid: Vec<Vec<Vec<f64>>>,
pub c_electrolyte: Vec<f64>,
pub phi_solid: Vec<Vec<f64>>,
pub phi_electrolyte: Vec<f64>,
pub internal_resistance: f64,
pub time: f64,
pub dt: f64,
}
impl BatteryFem {
pub fn new(applied_current: f64, temperature: f64, dt: f64) -> Self {
let neg = ElectrodeDomain::default_negative();
let pos = ElectrodeDomain::default_positive();
let n_sep = 5_usize;
let n_total = neg.n_macro + n_sep + pos.n_macro;
let c_solid = vec![
vec![vec![neg.c_solid_max * 0.5; neg.n_micro]; neg.n_macro],
vec![vec![pos.c_solid_max * 0.8; pos.n_micro]; pos.n_macro],
];
Self {
neg,
pos,
separator_thickness: 25e-6,
n_sep,
d_electrolyte: 2.7e-10,
kappa_electrolyte: 1.0,
transference: 0.363,
temperature,
applied_current,
c_solid,
c_electrolyte: vec![1000.0; n_total],
phi_solid: vec![vec![0.0; 10], vec![4.2; 10]],
phi_electrolyte: vec![0.0; n_total],
internal_resistance: 0.0,
time: 0.0,
dt,
}
}
pub fn n_total_nodes(&self) -> usize {
self.neg.n_macro + self.n_sep + self.pos.n_macro
}
pub fn soc_negative(&self) -> f64 {
let c_avg: f64 = self.c_solid[0]
.iter()
.map(|particle| particle.iter().sum::<f64>() / particle.len() as f64)
.sum::<f64>()
/ self.neg.n_macro as f64;
c_avg / self.neg.c_solid_max
}
pub fn soc_positive(&self) -> f64 {
let c_avg: f64 = self.c_solid[1]
.iter()
.map(|particle| particle.iter().sum::<f64>() / particle.len() as f64)
.sum::<f64>()
/ self.pos.n_macro as f64;
c_avg / self.pos.c_solid_max
}
pub fn terminal_voltage(&self) -> f64 {
let ocp_pos = ButlerVolmerFem::ocp_licoo2(self.soc_positive());
let ocp_neg = ButlerVolmerFem::ocp_graphite(self.soc_negative());
let ir_drop = self.applied_current * self.internal_resistance;
ocp_pos - ocp_neg - ir_drop
}
pub fn step_solid_diffusion(&mut self, electrode_idx: usize) {
let dom = if electrode_idx == 0 {
&self.neg
} else {
&self.pos
};
let n_r = dom.n_micro;
let r_p = dom.particle_radius;
let ds = dom.diffusivity_solid;
let h_r = r_p / (n_r as f64 - 1.0);
let dt = self.dt;
let n_x = dom.n_macro;
for c_solid_xi in self.c_solid[electrode_idx].iter_mut().take(n_x) {
let c = c_solid_xi.clone();
let mut lower = vec![0.0_f64; n_r];
let mut diag = vec![0.0_f64; n_r];
let mut upper = vec![0.0_f64; n_r];
let mut rhs = c.clone();
for ri in 1..n_r - 1 {
let r = ri as f64 * h_r;
let coef = ds * dt / (h_r * h_r);
let r_p_half = r + 0.5 * h_r;
let r_m_half = r - 0.5 * h_r;
let a_plus = coef * (r_p_half / r).powi(2);
let a_minus = coef * (r_m_half / r).powi(2);
lower[ri] = -a_minus;
diag[ri] = 1.0 + a_plus + a_minus;
upper[ri] = -a_plus;
}
diag[0] = 1.0 + 2.0 * ds * dt / (h_r * h_r);
upper[0] = -2.0 * ds * dt / (h_r * h_r);
let j_li = self.applied_current
/ (3.0 / dom.particle_radius * (1.0 - dom.porosity) * dom.thickness);
diag[n_r - 1] = 1.0;
lower[n_r - 1] = 0.0;
rhs[n_r - 1] = c[n_r - 1] - j_li * dt * h_r / (FARADAY * ds);
let new_c = NernstPlanckFem::solve_tridiagonal(&lower, &diag, &upper, &rhs);
*c_solid_xi = new_c;
}
}
pub fn step(&mut self) {
self.step_solid_diffusion(0);
self.step_solid_diffusion(1);
self.time += self.dt;
}
pub fn c_rate(&self, capacity_ah: f64) -> f64 {
self.applied_current / (capacity_ah * 3600.0)
}
pub fn specific_energy(&self, mass_density: f64, volume: f64) -> f64 {
let energy = self.terminal_voltage() * self.applied_current * self.time;
let mass = mass_density * volume;
energy / mass / 3600.0
}
}
#[derive(Debug, Clone)]
pub struct ElectrochemicalCorrosion {
pub temperature: f64,
pub e_corrosion: f64,
pub beta_anodic: f64,
pub beta_cathodic: f64,
pub i_corrosion: f64,
pub e_pit: f64,
pub e_rep: f64,
pub pitting_active: bool,
pub galvanic_couples: Vec<(f64, f64, f64)>,
pub n_nodes: usize,
pub length: f64,
pub potential: Vec<f64>,
pub corrosion_rate: Vec<f64>,
}
impl ElectrochemicalCorrosion {
pub fn new(
temperature: f64,
e_corrosion: f64,
beta_anodic: f64,
beta_cathodic: f64,
i_corrosion: f64,
e_pit: f64,
e_rep: f64,
n_nodes: usize,
length: f64,
) -> Self {
Self {
temperature,
e_corrosion,
beta_anodic,
beta_cathodic,
i_corrosion,
e_pit,
e_rep,
pitting_active: false,
galvanic_couples: Vec::new(),
n_nodes,
length,
potential: vec![e_corrosion; n_nodes],
corrosion_rate: vec![0.0; n_nodes],
}
}
pub fn anodic_current(&self, e: f64) -> f64 {
self.i_corrosion * 10.0_f64.powf((e - self.e_corrosion) / self.beta_anodic)
}
pub fn cathodic_current(&self, e: f64) -> f64 {
self.i_corrosion * 10.0_f64.powf(-(e - self.e_corrosion) / self.beta_cathodic)
}
pub fn net_current(&self, e: f64) -> f64 {
self.anodic_current(e) - self.cathodic_current(e)
}
pub fn check_pitting(&mut self, e: f64) {
self.pitting_active = e >= self.e_pit;
}
pub fn current_to_corrosion_rate(current_density: f64) -> f64 {
let m_fe = 55.85e-3; let n_eq = 2.0;
let rho_fe = 7874.0; let rate_ms = current_density * m_fe / (n_eq * FARADAY * rho_fe);
rate_ms * 1000.0 * 365.25 * 24.0 * 3600.0
}
pub fn add_galvanic_couple(&mut self, anode_area: f64, cathode_area: f64, delta_e: f64) {
self.galvanic_couples
.push((anode_area, cathode_area, delta_e));
}
pub fn galvanic_current(&self) -> Vec<f64> {
self.galvanic_couples
.iter()
.map(|(a_a, _a_c, de)| {
let i_gal = self.i_corrosion * 10.0_f64.powf(de / self.beta_anodic);
i_gal * a_a
})
.collect()
}
pub fn update_corrosion_rates(&mut self) {
self.corrosion_rate = self
.potential
.iter()
.map(|&e| {
let j = self.net_current(e).max(0.0);
Self::current_to_corrosion_rate(j)
})
.collect();
}
pub fn solve_laplace(&mut self, e_anode: f64, e_cathode: f64, conductivity: f64) {
let n = self.n_nodes;
let h = self.length / (n as f64 - 1.0);
let mut lower = vec![0.0_f64; n];
let mut diag = vec![0.0_f64; n];
let mut upper = vec![0.0_f64; n];
let mut rhs = vec![0.0_f64; n];
for i in 0..n - 1 {
let k = conductivity / h;
diag[i] += k;
upper[i] -= k;
lower[i + 1] -= k;
diag[i + 1] += k;
}
diag[0] = 1.0;
upper[0] = 0.0;
rhs[0] = e_anode;
diag[n - 1] = 1.0;
lower[n - 1] = 0.0;
rhs[n - 1] = e_cathode;
self.potential = NernstPlanckFem::solve_tridiagonal(&lower, &diag, &upper, &rhs);
self.update_corrosion_rates();
}
pub fn evans_diagram(&self, n_points: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let e_range = 0.5;
let potentials: Vec<f64> = (0..n_points)
.map(|i| self.e_corrosion - e_range + 2.0 * e_range * i as f64 / (n_points - 1) as f64)
.collect();
let anodic: Vec<f64> = potentials
.iter()
.map(|&e| self.anodic_current(e).max(1e-12).log10())
.collect();
let cathodic: Vec<f64> = potentials
.iter()
.map(|&e| self.cathodic_current(e).max(1e-12).log10())
.collect();
(potentials, anodic, cathodic)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FuelCellRegion {
AnodeGdl,
AnodeCatalyst,
Membrane,
CathodeCatalyst,
CathodeGdl,
}
#[derive(Debug, Clone)]
pub struct FuelCellFem {
pub temperature: f64,
pub sigma_membrane: f64,
pub thickness_membrane: f64,
pub porosity_gdl: f64,
pub thickness_gdl: f64,
pub thickness_cl: f64,
pub d_oxygen: f64,
pub c_oxygen_ref: f64,
pub j0_orr: f64,
pub v_cell: f64,
pub v_oc: f64,
pub n_nodes_per_region: usize,
pub phi_proton: Vec<f64>,
pub c_oxygen: Vec<f64>,
pub saturation: Vec<f64>,
pub current_density: Vec<f64>,
pub time: f64,
pub dt: f64,
}
impl FuelCellFem {
pub fn new(temperature: f64, v_cell: f64, dt: f64) -> Self {
let n = 20_usize;
Self {
temperature,
sigma_membrane: 10.0,
thickness_membrane: 50e-6,
porosity_gdl: 0.6,
thickness_gdl: 200e-6,
thickness_cl: 10e-6,
d_oxygen: 2.6e-5,
c_oxygen_ref: 40.97, j0_orr: 1e-4,
v_cell,
v_oc: 1.23,
n_nodes_per_region: n,
phi_proton: vec![0.0; n],
c_oxygen: vec![40.97; n],
saturation: vec![0.1; n],
current_density: vec![0.0; n],
time: 0.0,
dt,
}
}
pub fn total_overpotential(&self) -> f64 {
self.v_oc - self.v_cell
}
pub fn proton_conductivity(&self, lambda_water: f64) -> f64 {
let sigma_0 = 0.5139 * lambda_water - 0.326;
let exp_factor = (1268.0 * (1.0 / 303.0 - 1.0 / self.temperature)).exp();
sigma_0.max(0.0) * exp_factor
}
pub fn effective_oxygen_diffusivity(&self, porosity: f64, saturation: f64) -> f64 {
self.d_oxygen * porosity.powf(1.5) * (1.0 - saturation).powf(1.5)
}
pub fn orr_rate(&self, c_o2: f64, eta_cathode: f64) -> f64 {
let alpha_c = 0.5;
let f_rt = FARADAY / (R_GAS * self.temperature);
self.j0_orr * (c_o2 / self.c_oxygen_ref) * (-alpha_c * f_rt * eta_cathode).exp() / FARADAY }
pub fn solve_proton_transport(&mut self) {
let n = self.n_nodes_per_region;
let h = self.thickness_membrane / (n as f64 - 1.0);
let sigma = self.sigma_membrane;
let mut lower = vec![0.0_f64; n];
let mut diag = vec![0.0_f64; n];
let mut upper = vec![0.0_f64; n];
let mut rhs = vec![0.0_f64; n];
let k = sigma / h;
for i in 0..n - 1 {
diag[i] += k;
upper[i] -= k;
lower[i + 1] -= k;
diag[i + 1] += k;
}
diag[0] = 1.0;
upper[0] = 0.0;
rhs[0] = 0.0;
diag[n - 1] = 1.0;
lower[n - 1] = 0.0;
rhs[n - 1] = self.v_cell;
self.phi_proton = NernstPlanckFem::solve_tridiagonal(&lower, &diag, &upper, &rhs);
}
pub fn solve_oxygen_transport(&mut self) {
let n = self.n_nodes_per_region;
let h = self.thickness_gdl / (n as f64 - 1.0);
let eta_c = self.total_overpotential();
let d_eff = self.effective_oxygen_diffusivity(self.porosity_gdl, 0.1);
let mut lower = vec![0.0_f64; n];
let mut diag = vec![0.0_f64; n];
let mut upper = vec![0.0_f64; n];
let mut rhs = vec![0.0_f64; n];
let c_ref = self.c_oxygen_ref;
for i in 0..n - 1 {
let k = d_eff / h;
diag[i] += k;
upper[i] -= k;
lower[i + 1] -= k;
diag[i + 1] += k;
}
let n_cl = (self.thickness_cl / h).ceil() as usize;
for (i, diag_i) in diag
.iter_mut()
.enumerate()
.take(n)
.skip(n.saturating_sub(n_cl))
{
let r_o2 = self.orr_rate(self.c_oxygen[i].max(0.0), eta_c.abs()) * FARADAY;
*diag_i += r_o2 / self.c_oxygen[i].max(1e-10) * h;
}
diag[0] = 1.0;
upper[0] = 0.0;
rhs[0] = c_ref;
lower[n - 1] = 0.0;
self.c_oxygen = NernstPlanckFem::solve_tridiagonal(&lower, &diag, &upper, &rhs);
}
pub fn compute_current_density(&mut self) {
let eta_c = self.total_overpotential().abs();
self.current_density = self
.c_oxygen
.iter()
.map(|&c| {
let rate = self.orr_rate(c.max(0.0), eta_c);
rate * FARADAY * self.thickness_cl
})
.collect();
}
pub fn average_current_density(&self) -> f64 {
self.current_density.iter().sum::<f64>() / self.current_density.len() as f64
}
pub fn power_density(&self) -> f64 {
self.v_cell * self.average_current_density()
}
pub fn step(&mut self) {
self.solve_proton_transport();
self.solve_oxygen_transport();
self.compute_current_density();
self.time += self.dt;
}
pub fn nernst_potential(temperature: f64, p_h2: f64, p_o2: f64, p_h2o: f64) -> f64 {
let v0 = 1.229;
let correction = (temperature - 298.15) * (-8.5e-4);
let nernst =
R_GAS * temperature / (2.0 * FARADAY) * (p_h2 * p_o2.sqrt() / p_h2o.max(1e-10)).ln();
v0 + correction + nernst
}
pub fn activation_overpotential(&self, current_density: f64) -> f64 {
if current_density <= 0.0 {
return 0.0;
}
R_GAS * self.temperature / (0.5 * FARADAY) * (current_density / self.j0_orr).ln()
}
pub fn ohmic_overpotential(&self, current_density: f64, r_ohm: f64) -> f64 {
current_density * r_ohm
}
pub fn concentration_overpotential(&self, current_density: f64, j_limiting: f64) -> f64 {
let ratio = (current_density / j_limiting).min(0.999);
-R_GAS * self.temperature / (2.0 * FARADAY) * (1.0 - ratio).ln()
}
pub fn electro_osmotic_drag(&self, lambda_water: f64) -> f64 {
2.5 * lambda_water / 22.0
}
pub fn water_diffusivity(&self, lambda_water: f64) -> f64 {
let d0 = 2.1e-7 * (-2436.0 / self.temperature).exp();
let f_lambda = if lambda_water < 2.0 {
1.0
} else if lambda_water < 3.0 {
1.0 + 2.0 * (lambda_water - 2.0)
} else {
3.0
};
d0 * f_lambda
}
}
pub fn thermal_voltage(temperature: f64) -> f64 {
R_GAS * temperature / FARADAY
}
pub fn debye_length_11(epsilon_r: f64, temperature: f64, c_bulk: f64) -> f64 {
let eps = epsilon_r * EPSILON_0;
(eps * R_GAS * temperature / (2.0 * FARADAY.powi(2) * c_bulk)).sqrt()
}
pub fn limiting_current_density(n_elec: f64, diffusivity: f64, c_bulk: f64, delta: f64) -> f64 {
n_elec * FARADAY * diffusivity * c_bulk / delta
}
pub fn polarization_resistance(temperature: f64, n_elec: f64, exchange_current: f64) -> f64 {
R_GAS * temperature / (n_elec * FARADAY * exchange_current)
}
pub fn warburg_coefficient(
temperature: f64,
n_elec: f64,
diffusivity: f64,
c_bulk: f64,
area: f64,
) -> f64 {
R_GAS * temperature
/ (n_elec.powi(2) * FARADAY.powi(2) * area * 2.0_f64.sqrt() * diffusivity.sqrt() * c_bulk)
}
pub fn levich_diffusion_layer(diffusivity: f64, viscosity_kinematic: f64, omega: f64) -> f64 {
1.61 * diffusivity.powf(1.0 / 3.0) * viscosity_kinematic.powf(1.0 / 6.0) / omega.sqrt()
}
pub fn nernst_potential(
e_standard: f64,
temperature: f64,
n_elec: f64,
c_ox: f64,
c_red: f64,
) -> f64 {
e_standard + R_GAS * temperature / (n_elec * FARADAY) * (c_ox / c_red.max(1e-30)).ln()
}
pub fn helmholtz_capacitance(epsilon_r: f64, d_layer: f64) -> f64 {
epsilon_r * EPSILON_0 / d_layer
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ion_species_creation() {
let sp = IonSpecies::new("Li+", 1, 1.0e-9, 1000.0);
assert_eq!(sp.charge, 1);
assert!((sp.diffusivity - 1e-9).abs() < 1e-20);
assert_eq!(sp.bulk_concentration, 1000.0);
}
#[test]
fn test_thermal_voltage() {
let np = NernstPlanckFem::new(
298.15,
vec![IonSpecies::new("Li+", 1, 1e-9, 1000.0)],
10,
1e-3,
1e-3,
);
let vt = np.thermal_voltage();
assert!((vt - 0.025693).abs() < 1e-4, "thermal voltage = {vt}");
}
#[test]
fn test_element_diffusion_matrix_symmetry() {
let np = NernstPlanckFem::new(
298.15,
vec![IonSpecies::new("Na+", 1, 1.3e-9, 100.0)],
5,
1e-3,
1e-4,
);
let dm = np.element_diffusion_matrix(1.3e-9, 2.5e-4);
assert!((dm[0][0] + dm[0][1]).abs() < 1e-20);
assert!((dm[1][0] + dm[1][1]).abs() < 1e-20);
assert!((dm[0][1] - dm[1][0]).abs() < 1e-30);
}
#[test]
fn test_nernst_planck_step_conserves_mass_approx() {
let species = vec![IonSpecies::new("Cl-", -1, 2.0e-9, 500.0)];
let mut np = NernstPlanckFem::new(298.15, species, 20, 1e-3, 1e-4);
let c_init: f64 = np.concentration[0].iter().sum();
np.step();
let c_final: f64 = np.concentration[0].iter().sum();
assert!(c_final.is_finite());
assert!(c_final > 0.0);
assert!((c_final - c_init).abs() / c_init < 0.5);
}
#[test]
fn test_nernst_planck_tridiagonal() {
let lower = vec![0.0, -1.0, -1.0];
let diag = vec![2.0, 3.0, 2.0];
let upper = vec![-1.0, -1.0, 0.0];
let rhs = vec![1.0, 2.0, 1.0];
let x = NernstPlanckFem::solve_tridiagonal(&lower, &diag, &upper, &rhs);
let r0 = diag[0] * x[0] + upper[0] * x[1] - rhs[0];
let r1 = lower[1] * x[0] + diag[1] * x[1] + upper[1] * x[2] - rhs[1];
let r2 = lower[2] * x[1] + diag[2] * x[2] - rhs[2];
assert!(r0.abs() < 1e-10);
assert!(r1.abs() < 1e-10);
assert!(r2.abs() < 1e-10);
}
#[test]
fn test_current_density_finite() {
let species = vec![IonSpecies::new("K+", 1, 1.96e-9, 100.0)];
let np = NernstPlanckFem::new(298.15, species, 10, 1e-3, 1e-4);
let j = np.current_density(0, 5);
assert!(j.is_finite());
}
#[test]
fn test_total_current_finite() {
let species = vec![IonSpecies::new("K+", 1, 1.96e-9, 100.0)];
let np = NernstPlanckFem::new(298.15, species, 10, 1e-3, 1e-4);
assert!(np.total_current(0).is_finite());
}
#[test]
fn test_debye_length_nacl_100mm() {
let sp = vec![
IonSpecies::new("Na+", 1, 1.33e-9, 100.0),
IonSpecies::new("Cl-", -1, 2.03e-9, 100.0),
];
let pb = PoissonBoltzmannFem::new(78.5, 298.15, sp, 50, 10e-9, 0.1);
let ld = pb.debye_length();
assert!(ld > 0.5e-9 && ld < 2.0e-9, "Debye length = {ld:.3e} m");
}
#[test]
fn test_pb_ionic_strength() {
let sp = vec![
IonSpecies::new("Ca2+", 2, 7.9e-10, 10.0),
IonSpecies::new("Cl-", -1, 2.03e-9, 20.0),
];
let pb = PoissonBoltzmannFem::new(78.5, 298.15, sp, 10, 1e-8, 0.05);
let i = pb.ionic_strength();
assert!((i - 30.0).abs() < 1e-10);
}
#[test]
fn test_pb_solve_monotonic_decay() {
let sp = vec![IonSpecies::new("Na+", 1, 1.33e-9, 100.0)];
let mut pb = PoissonBoltzmannFem::new(78.5, 298.15, sp, 30, 20e-9, 0.1);
pb.solve();
let phi = &pb.potential;
assert!(phi[0] > phi[phi.len() - 1]);
assert!(phi.last().copied().unwrap_or(1.0).abs() < 0.01);
}
#[test]
fn test_surface_charge_density_sign() {
let sp = vec![IonSpecies::new("Na+", 1, 1.33e-9, 100.0)];
let mut pb = PoissonBoltzmannFem::new(78.5, 298.15, sp, 30, 20e-9, 0.1);
pb.solve();
let sigma = pb.surface_charge_density();
assert!(sigma.abs() > 0.0, "sigma should be non-zero, got = {sigma}");
assert!(sigma.is_finite(), "sigma should be finite");
}
#[test]
fn test_differential_capacitance_positive() {
let sp = vec![IonSpecies::new("Na+", 1, 1.33e-9, 100.0)];
let pb = PoissonBoltzmannFem::new(78.5, 298.15, sp, 30, 20e-9, 0.1);
assert!(pb.differential_capacitance() > 0.0);
}
#[test]
fn test_analytical_solution_at_zero() {
let sp = vec![IonSpecies::new("Na+", 1, 1.33e-9, 100.0)];
let pb = PoissonBoltzmannFem::new(78.5, 298.15, sp, 30, 20e-9, 0.05);
assert!((pb.analytical_solution(0.0) - 0.05).abs() < 1e-10);
}
#[test]
fn test_bv_overpotential_zero_at_equilibrium() {
let mut bv = ButlerVolmerFem::new(298.15, 1e-3, 0.5, 0.5, 0.5);
bv.set_potentials(0.5, 0.0);
assert!((bv.overpotential()).abs() < 1e-10);
}
#[test]
fn test_bv_current_zero_at_equilibrium() {
let mut bv = ButlerVolmerFem::new(298.15, 1e-3, 0.5, 0.5, 0.3);
bv.set_potentials(0.3, 0.0);
let j = bv.current_density();
assert!(j.abs() < 1e-12, "j at equilibrium = {j}");
}
#[test]
fn test_bv_anodic_current_positive_for_positive_overpotential() {
let mut bv = ButlerVolmerFem::new(298.15, 1e-3, 0.5, 0.5, 0.0);
bv.set_potentials(0.1, 0.0); assert!(bv.current_density() > 0.0);
}
#[test]
fn test_bv_tafel_slopes_positive() {
let bv = ButlerVolmerFem::new(298.15, 1e-3, 0.5, 0.5, 0.0);
assert!(bv.tafel_slope_anodic() > 0.0);
assert!(bv.tafel_slope_cathodic() > 0.0);
}
#[test]
fn test_bv_linearized_conductance() {
let bv = ButlerVolmerFem::new(298.15, 1e-3, 0.5, 0.5, 0.0);
let g = bv.linearized_conductance();
let expected = 1e-3 * 1.0 * FARADAY / (R_GAS * 298.15);
assert!((g - expected).abs() / expected < 1e-8);
}
#[test]
fn test_ocp_graphite_range() {
for soc in [0.01, 0.1, 0.3, 0.5, 0.7, 0.9, 0.99] {
let v = ButlerVolmerFem::ocp_graphite(soc);
assert!(v > -0.5 && v < 2.0, "OCP graphite at soc={soc} = {v}");
}
}
#[test]
fn test_ocp_licoo2_range() {
for soc in [0.1, 0.3, 0.5, 0.7, 0.9] {
let v = ButlerVolmerFem::ocp_licoo2(soc);
assert!(v > 3.0 && v < 5.0, "OCP LiCoO2 at soc={soc} = {v}");
}
}
#[test]
fn test_battery_fem_creation() {
let bat = BatteryFem::new(1.0, 298.15, 1.0);
assert_eq!(bat.neg.domain, DfnDomain::NegativeElectrode);
assert_eq!(bat.pos.domain, DfnDomain::PositiveElectrode);
}
#[test]
fn test_battery_n_total_nodes() {
let bat = BatteryFem::new(1.0, 298.15, 1.0);
let expected = bat.neg.n_macro + bat.n_sep + bat.pos.n_macro;
assert_eq!(bat.n_total_nodes(), expected);
}
#[test]
fn test_battery_soc_initial() {
let bat = BatteryFem::new(1.0, 298.15, 1.0);
let soc_n = bat.soc_negative();
let soc_p = bat.soc_positive();
assert!((soc_n - 0.5).abs() < 1e-10);
assert!((soc_p - 0.8).abs() < 1e-10);
}
#[test]
fn test_battery_terminal_voltage_range() {
let bat = BatteryFem::new(1.0, 298.15, 1.0);
let v = bat.terminal_voltage();
assert!(v > 2.0 && v < 5.0, "V_cell = {v}");
}
#[test]
fn test_battery_ir_drop_terminal_voltage() {
let mut bat = BatteryFem::new(2.0, 298.15, 1.0);
bat.internal_resistance = 0.05;
let v_no_r = {
let mut b2 = bat.clone();
b2.internal_resistance = 0.0;
b2.terminal_voltage()
};
let v_with_r = bat.terminal_voltage();
assert!(
v_with_r < v_no_r,
"IR drop not applied: v_with_r={v_with_r}, v_no_r={v_no_r}"
);
assert!(
(v_no_r - v_with_r - 0.1).abs() < 1e-10,
"expected IR drop of 0.1 V, got {:.6}",
v_no_r - v_with_r
);
}
#[test]
fn test_battery_step() {
let mut bat = BatteryFem::new(1.0, 298.15, 10.0);
bat.step();
assert!((bat.time - 10.0).abs() < 1e-10);
}
#[test]
fn test_electrode_effective_conductivity() {
let neg = ElectrodeDomain::default_negative();
let kappa_eff = neg.effective_conductivity(1.0);
assert!(kappa_eff < 1.0 && kappa_eff > 0.0);
}
#[test]
fn test_electrode_effective_diffusivity() {
let pos = ElectrodeDomain::default_positive();
let d_eff = pos.effective_electrolyte_diffusivity(2.7e-10);
assert!(d_eff < 2.7e-10 && d_eff > 0.0);
}
#[test]
fn test_corrosion_creation() {
let corr =
ElectrochemicalCorrosion::new(298.15, -0.44, 0.12, 0.12, 1e-6, -0.25, -0.35, 20, 1e-2);
assert!((corr.e_corrosion - (-0.44)).abs() < 1e-10);
}
#[test]
fn test_corrosion_net_current_zero_at_ecorr() {
let corr =
ElectrochemicalCorrosion::new(298.15, -0.44, 0.12, 0.12, 1e-6, -0.25, -0.35, 10, 1e-2);
let j = corr.net_current(-0.44);
assert!(j.abs() < 1e-15, "net current at E_corr = {j}");
}
#[test]
fn test_anodic_current_increases_with_e() {
let corr =
ElectrochemicalCorrosion::new(298.15, -0.44, 0.12, 0.12, 1e-6, -0.25, -0.35, 10, 1e-2);
let j1 = corr.anodic_current(-0.44);
let j2 = corr.anodic_current(-0.30);
assert!(j2 > j1);
}
#[test]
fn test_pitting_check() {
let mut corr =
ElectrochemicalCorrosion::new(298.15, -0.44, 0.12, 0.12, 1e-6, -0.25, -0.35, 10, 1e-2);
corr.check_pitting(-0.10);
assert!(corr.pitting_active);
corr.check_pitting(-0.40);
assert!(!corr.pitting_active);
}
#[test]
fn test_corrosion_rate_from_current() {
let rate = ElectrochemicalCorrosion::current_to_corrosion_rate(1e-3);
assert!(rate > 0.0 && rate < 10.0, "rate = {rate} mm/year");
}
#[test]
fn test_galvanic_couple() {
let mut corr =
ElectrochemicalCorrosion::new(298.15, -0.44, 0.12, 0.12, 1e-6, -0.25, -0.35, 10, 1e-2);
corr.add_galvanic_couple(1.0, 10.0, 0.3);
let gc = corr.galvanic_current();
assert_eq!(gc.len(), 1);
assert!(gc[0] > 0.0);
}
#[test]
fn test_evans_diagram_lengths() {
let corr =
ElectrochemicalCorrosion::new(298.15, -0.44, 0.12, 0.12, 1e-6, -0.25, -0.35, 10, 1e-2);
let (e, ja, jc) = corr.evans_diagram(50);
assert_eq!(e.len(), 50);
assert_eq!(ja.len(), 50);
assert_eq!(jc.len(), 50);
}
#[test]
fn test_solve_laplace_bc() {
let mut corr =
ElectrochemicalCorrosion::new(298.15, -0.44, 0.12, 0.12, 1e-6, -0.25, -0.35, 10, 1e-2);
corr.solve_laplace(-0.44, -0.60, 5.0);
assert!((corr.potential[0] - (-0.44)).abs() < 1e-10);
assert!((corr.potential[9] - (-0.60)).abs() < 1e-10);
}
#[test]
fn test_fuel_cell_creation() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
assert!((fc.v_cell - 0.7).abs() < 1e-10);
assert!((fc.v_oc - 1.23).abs() < 1e-10);
}
#[test]
fn test_total_overpotential() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
assert!((fc.total_overpotential() - 0.53).abs() < 1e-10);
}
#[test]
fn test_proton_conductivity_springer() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
let sigma = fc.proton_conductivity(14.0);
assert!(sigma > 0.0 && sigma < 100.0, "sigma = {sigma}");
}
#[test]
fn test_effective_oxygen_diffusivity() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
let d = fc.effective_oxygen_diffusivity(0.6, 0.1);
assert!(d > 0.0 && d < fc.d_oxygen);
}
#[test]
fn test_orr_rate_positive() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
let rate = fc.orr_rate(40.0, 0.4);
assert!(rate > 0.0);
}
#[test]
fn test_solve_proton_transport_bc() {
let mut fc = FuelCellFem::new(353.15, 0.7, 1.0);
fc.solve_proton_transport();
assert!((fc.phi_proton[0]).abs() < 1e-10);
assert!((fc.phi_proton[fc.n_nodes_per_region - 1] - 0.7).abs() < 1e-10);
}
#[test]
fn test_solve_oxygen_transport_positive() {
let mut fc = FuelCellFem::new(353.15, 0.7, 1.0);
fc.solve_oxygen_transport();
assert!(fc.c_oxygen.iter().all(|&c| c >= -1e-6));
}
#[test]
fn test_fuel_cell_step_advances_time() {
let mut fc = FuelCellFem::new(353.15, 0.7, 0.1);
fc.step();
assert!((fc.time - 0.1).abs() < 1e-10);
}
#[test]
fn test_power_density_positive() {
let mut fc = FuelCellFem::new(353.15, 0.7, 1.0);
fc.step();
assert!(fc.power_density() >= 0.0);
}
#[test]
fn test_nernst_potential_standard() {
let v = FuelCellFem::nernst_potential(298.15, 101325.0, 101325.0, 3540.0);
assert!(v > 1.0 && v < 1.5, "V_Nernst = {v}");
}
#[test]
fn test_activation_overpotential_positive() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
let eta = fc.activation_overpotential(0.1);
assert!(eta >= 0.0);
}
#[test]
fn test_ohmic_overpotential() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
let eta = fc.ohmic_overpotential(1.0, 0.05);
assert!((eta - 0.05).abs() < 1e-10);
}
#[test]
fn test_concentration_overpotential_grows() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
let eta1 = fc.concentration_overpotential(0.5, 2.0);
let eta2 = fc.concentration_overpotential(0.9, 2.0);
assert!(eta2 > eta1);
}
#[test]
fn test_electro_osmotic_drag() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
let nd = fc.electro_osmotic_drag(22.0);
assert!((nd - 2.5).abs() < 1e-10);
}
#[test]
fn test_water_diffusivity_positive() {
let fc = FuelCellFem::new(353.15, 0.7, 1.0);
assert!(fc.water_diffusivity(5.0) > 0.0);
assert!(fc.water_diffusivity(14.0) > 0.0);
}
#[test]
fn test_thermal_voltage_room_temp() {
let vt = thermal_voltage(298.15);
assert!((vt - 0.025693).abs() < 1e-4);
}
#[test]
fn test_debye_length_11_order_of_magnitude() {
let ld = debye_length_11(78.5, 298.15, 10.0);
assert!(ld > 1e-9 && ld < 10e-9, "λ_D = {ld:.3e}");
}
#[test]
fn test_limiting_current_density() {
let j_lim = limiting_current_density(1.0, 1e-9, 1000.0, 100e-6);
assert!(j_lim > 0.0);
}
#[test]
fn test_polarization_resistance_positive() {
let rp = polarization_resistance(298.15, 2.0, 1e-3);
assert!(rp > 0.0);
}
#[test]
fn test_warburg_coefficient_positive() {
let sigma = warburg_coefficient(298.15, 1.0, 1e-9, 1000.0, 1e-4);
assert!(sigma > 0.0);
}
#[test]
fn test_levich_diffusion_layer() {
let delta = levich_diffusion_layer(1e-9, 1e-6, 2.0 * PI * 10.0);
assert!(delta > 0.0 && delta < 1e-3, "δ = {delta:.3e}");
}
#[test]
fn test_nernst_potential_fn() {
let e = nernst_potential(0.34, 298.15, 2.0, 1.0, 1.0);
assert!((e - 0.34).abs() < 1e-10);
}
#[test]
fn test_helmholtz_capacitance() {
let c = helmholtz_capacitance(78.5, 3e-10);
assert!(c > 1.0 && c < 5.0, "C_H = {c}");
}
}