use serde::{Deserialize, Serialize};
use std::f64::consts::PI;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum ElectrodeType {
Graphite,
LFP,
NMC,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElectrodeParams {
pub electrode_type: ElectrodeType,
pub r_particle: f64,
pub d_s_ref: f64,
pub c_s_max: f64,
pub theta_init: f64,
pub thickness: f64,
pub epsilon_s: f64,
pub area: f64,
pub e_a_ds: f64,
pub k_bv: f64,
}
impl ElectrodeParams {
pub fn graphite_anode() -> Self {
Self {
electrode_type: ElectrodeType::Graphite,
r_particle: 12.5e-6, d_s_ref: 3.9e-14, c_s_max: 31_833.0, theta_init: 0.80, thickness: 100e-6, epsilon_s: 0.60,
area: 0.0626, e_a_ds: 35_000.0, k_bv: 2.0e-11,
}
}
pub fn lfp_cathode() -> Self {
Self {
electrode_type: ElectrodeType::LFP,
r_particle: 2.5e-6, d_s_ref: 1.0e-14, c_s_max: 22_806.0, theta_init: 0.20, thickness: 80e-6,
epsilon_s: 0.50,
area: 0.0626,
e_a_ds: 20_000.0,
k_bv: 2.0e-11,
}
}
pub fn nmc_cathode() -> Self {
Self {
electrode_type: ElectrodeType::NMC,
r_particle: 5.0e-6,
d_s_ref: 1.5e-14,
c_s_max: 49_000.0,
theta_init: 0.15,
thickness: 90e-6,
epsilon_s: 0.55,
area: 0.0626,
e_a_ds: 25_000.0,
k_bv: 2.0e-11,
}
}
pub fn d_s(&self, temp_k: f64) -> f64 {
const R_GAS: f64 = 8.314;
const T_REF: f64 = 298.15;
self.d_s_ref * (self.e_a_ds / R_GAS * (1.0 / T_REF - 1.0 / temp_k)).exp()
}
pub fn specific_area(&self) -> f64 {
3.0 * self.epsilon_s / self.r_particle
}
pub fn ocp(&self, theta: f64) -> f64 {
let x = theta.clamp(0.01, 0.99);
match self.electrode_type {
ElectrodeType::Graphite => ocp_graphite(x),
ElectrodeType::LFP => ocp_lfp(x),
ElectrodeType::NMC => ocp_nmc(x),
}
}
pub fn docp_dtheta(&self, theta: f64) -> f64 {
let eps = 1e-4;
let t = theta.clamp(eps, 1.0 - eps);
(self.ocp(t + eps) - self.ocp(t - eps)) / (2.0 * eps)
}
}
fn ocp_graphite(x: f64) -> f64 {
0.7222 + 0.1387 * x + 0.029 * x.powf(0.5) - 0.0172 / x
+ 0.0019 / x.powf(1.5)
+ 0.2808 * (-0.9 / x).exp()
- 0.7984 * (0.4465 * x - 0.4108).exp()
}
fn ocp_lfp(x: f64) -> f64 {
let x = x.clamp(0.01, 0.99);
let backbone = 3.414 + 0.12 * x.powi(2) - 0.17 * x.powi(3) + 0.05 * x.powi(4);
let low_soc = 0.20 / (1.0 + (80.0 * (x - 0.08)).exp());
let high_soc = 0.15 / (1.0 + (80.0 * (0.92 - x)).exp());
backbone - low_soc + high_soc
}
fn ocp_nmc(x: f64) -> f64 {
let x = x.clamp(0.1, 0.9);
4.20 - 1.50 * x + 1.20 * x.powi(2) - 0.60 * x.powi(3) + 0.08 / (1.0 + (30.0 * (x - 0.85)).exp())
}
pub struct ParticleDiffusion {
pub params: ElectrodeParams,
pub c_s: Vec<f64>,
pub n_nodes: usize,
dr: f64,
}
impl ParticleDiffusion {
pub fn new(params: ElectrodeParams, n_nodes: usize) -> Self {
let c_init = params.theta_init * params.c_s_max;
let dr = params.r_particle / (n_nodes - 1) as f64;
Self {
c_s: vec![c_init; n_nodes],
n_nodes,
dr,
params,
}
}
pub fn theta_surface(&self) -> f64 {
self.c_s[self.n_nodes - 1] / self.params.c_s_max
}
pub fn theta_avg(&self) -> f64 {
self.c_avg() / self.params.c_s_max
}
pub fn c_avg(&self) -> f64 {
let n = self.n_nodes;
let r_max = self.params.r_particle;
let mut sum = 0.0_f64;
let mut sum_r2 = 0.0_f64;
for i in 0..n {
let r = i as f64 * self.dr;
let w = if i == 0 || i == n - 1 {
1.0
} else if i % 2 == 0 {
2.0
} else {
4.0
};
sum += w * r * r * self.c_s[i];
sum_r2 += w * r * r;
}
if sum_r2 < 1e-30 {
return self.c_s[0];
}
3.0 * sum / (r_max * r_max * r_max) * self.dr / 3.0
}
pub fn step(&mut self, j_n: f64, dt: f64, temp_k: f64) {
let n = self.n_nodes;
let dr = self.dr;
let d_s = self.params.d_s(temp_k);
let mut dc = vec![0.0_f64; n];
dc[0] = 6.0 * d_s * (self.c_s[1] - self.c_s[0]) / (dr * dr);
#[allow(clippy::needless_range_loop)]
for i in 1..n - 1 {
let r = i as f64 * dr;
let d2c = (self.c_s[i + 1] - 2.0 * self.c_s[i] + self.c_s[i - 1]) / (dr * dr);
let dc_dr = (self.c_s[i + 1] - self.c_s[i - 1]) / (2.0 * dr);
dc[i] = d_s * (d2c + 2.0 / r * dc_dr);
}
let ghost = self.c_s[n - 2] + 2.0 * dr * j_n / d_s;
let r_surf = (n - 1) as f64 * dr;
let d2c = (ghost - 2.0 * self.c_s[n - 1] + self.c_s[n - 2]) / (dr * dr);
let dc_dr_surf = (ghost - self.c_s[n - 2]) / (2.0 * dr);
dc[n - 1] = d_s * (d2c + 2.0 / r_surf * dc_dr_surf);
let c_min = 0.0;
let c_max = self.params.c_s_max;
for (cs, dc_val) in self.c_s.iter_mut().zip(dc.iter()) {
*cs = (*cs + dt * dc_val).clamp(c_min, c_max);
}
}
pub fn dt_stable(&self, temp_k: f64) -> f64 {
let d_s = self.params.d_s(temp_k);
0.5 * self.dr * self.dr / d_s
}
pub fn volume(&self) -> f64 {
self.params.area * self.params.thickness
}
pub fn capacity_coulombs(&self) -> f64 {
const F: f64 = 96_485.0;
F * self.params.c_s_max * self.params.epsilon_s * self.volume()
}
pub fn ocp_surface(&self) -> f64 {
self.params.ocp(self.theta_surface())
}
pub fn reset(&mut self) {
let c_init = self.params.theta_init * self.params.c_s_max;
self.c_s.fill(c_init);
}
}
pub fn sphere_volume(r: f64) -> f64 {
4.0 / 3.0 * PI * r * r * r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_electrode_params_graphite() {
let params = ElectrodeParams::graphite_anode();
assert!(params.r_particle > 0.0);
assert!(params.d_s_ref > 0.0);
assert!(params.c_s_max > 0.0);
let d_s_300 = params.d_s(300.0);
let d_s_298 = params.d_s(298.15);
assert!(d_s_300 > d_s_298, "Diffusivity increases with temperature");
}
#[test]
fn test_ocp_graphite_in_range() {
let params = ElectrodeParams::graphite_anode();
for theta in [0.1, 0.3, 0.5, 0.7, 0.9] {
let v = params.ocp(theta);
assert!(
v > 0.0 && v < 1.5,
"Graphite OCP out of range: {:.3} V at θ={}",
v,
theta
);
}
}
#[test]
fn test_ocp_lfp_plateau() {
let params = ElectrodeParams::lfp_cathode();
let v_mid = params.ocp(0.5);
assert!(
v_mid > 3.0 && v_mid < 3.8,
"LFP OCP plateau: {:.3} V",
v_mid
);
}
#[test]
fn test_particle_diffusion_init() {
let params = ElectrodeParams::graphite_anode();
let theta0 = params.theta_init;
let particle = ParticleDiffusion::new(params, 5);
assert!(
(particle.theta_avg() - theta0).abs() < 0.01,
"Initial avg θ should be theta_init"
);
}
#[test]
fn test_particle_step_reduces_conc_on_discharge() {
let params = ElectrodeParams::graphite_anode();
let mut particle = ParticleDiffusion::new(params, 10);
let c_before = particle.theta_surface();
let j_n = -1e-5; particle.step(j_n, particle.dt_stable(298.15) * 0.4, 298.15);
let c_after = particle.theta_surface();
assert!(
c_after < c_before,
"Surface θ should decrease on anode discharge"
);
}
#[test]
fn test_capacity_positive() {
let params = ElectrodeParams::lfp_cathode();
let particle = ParticleDiffusion::new(params, 5);
assert!(
particle.capacity_coulombs() > 1000.0,
"Capacity should be > 1000 C"
);
}
#[test]
fn test_specific_area() {
let params = ElectrodeParams::graphite_anode();
let a_s = params.specific_area();
assert!(
a_s > 1e4,
"Specific area should be > 10,000 m⁻¹: {:.0}",
a_s
);
}
}