use serde::{Deserialize, Serialize};
const FARADAY: f64 = 96_485.0;
const R_GAS: f64 = 8.314;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DfnParams {
pub l_neg: f64,
pub l_sep: f64,
pub l_pos: f64,
pub area: f64,
pub r_neg: f64,
pub d_s_neg_ref: f64,
pub c_s_neg_max: f64,
pub eps_neg: f64,
pub theta_neg_0: f64,
pub k_neg: f64,
pub r_pos: f64,
pub d_s_pos_ref: f64,
pub c_s_pos_max: f64,
pub eps_pos: f64,
pub theta_pos_0: f64,
pub k_pos: f64,
pub c_e0: f64,
pub d_e0: f64,
pub gamma_e: f64,
pub t_plus: f64,
pub kappa0: f64,
pub eps_e_elec: f64,
pub eps_e_sep: f64,
pub brugg: f64,
pub temperature_k: f64,
pub ocv_neg_coeffs: Vec<f64>,
pub ocv_pos_coeffs: Vec<f64>,
}
impl DfnParams {
pub fn nmc_graphite() -> Self {
Self {
l_neg: 88e-6,
l_sep: 25e-6,
l_pos: 80e-6,
area: 1.0,
r_neg: 12.5e-6,
d_s_neg_ref: 3.9e-14,
c_s_neg_max: 31_833.0,
eps_neg: 0.3,
theta_neg_0: 0.8,
k_neg: 1e-5,
r_pos: 8.5e-6,
d_s_pos_ref: 1.0e-14,
c_s_pos_max: 51_218.0,
eps_pos: 0.3,
theta_pos_0: 0.5,
k_pos: 3e-6,
c_e0: 1200.0,
d_e0: 2.6e-10,
gamma_e: 0.5,
t_plus: 0.38,
kappa0: 1.0,
eps_e_elec: 0.3,
eps_e_sep: 0.4,
brugg: 1.5,
temperature_k: 298.15,
ocv_neg_coeffs: vec![-1.5, 1.2], ocv_pos_coeffs: vec![-2.0, 4.5], }
}
pub fn lfp_graphite() -> Self {
let mut p = Self::nmc_graphite();
p.d_s_neg_ref = 2.0e-14;
p.d_s_pos_ref = 5.9e-18; p.c_s_pos_max = 26_390.0;
p.k_pos = 1e-6;
p.ocv_pos_coeffs = vec![-0.1, 3.45];
p
}
pub fn d_eff_e(&self, region: ElectrodeRegion, c_e: f64) -> f64 {
let eps = match region {
ElectrodeRegion::Anode | ElectrodeRegion::Cathode => self.eps_e_elec,
ElectrodeRegion::Separator => self.eps_e_sep,
};
let d = self.d_e0 * (-self.gamma_e * ((c_e / self.c_e0) - 1.0)).exp();
eps.powf(self.brugg) * d
}
pub fn d_s(&self, electrode: Electrode, c_s: f64, c_s_max: f64) -> f64 {
let d_ref = match electrode {
Electrode::Anode => self.d_s_neg_ref,
Electrode::Cathode => self.d_s_pos_ref,
};
let x = c_s / c_s_max.max(1e-3) - 0.5;
d_ref * (1.0 + 0.3 * x * x)
}
pub fn ocv(&self, electrode: Electrode, theta: f64) -> f64 {
let coeffs = match electrode {
Electrode::Anode => &self.ocv_neg_coeffs,
Electrode::Cathode => &self.ocv_pos_coeffs,
};
coeffs.iter().fold(0.0, |acc, &c| acc * theta + c)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Electrode {
Anode,
Cathode,
}
#[derive(Debug, Clone, Copy)]
pub enum ElectrodeRegion {
Anode,
Separator,
Cathode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DfnState {
pub c_s_neg: Vec<Vec<f64>>,
pub c_s_pos: Vec<Vec<f64>>,
pub c_e: Vec<f64>,
pub time_s: f64,
pub voltage: f64,
pub current: f64,
pub theta_neg_surf: f64,
pub theta_pos_surf: f64,
}
impl DfnState {
fn new(params: &DfnParams, n_x_neg: usize, n_x_pos: usize, n_r: usize) -> Self {
let c_neg_init = params.theta_neg_0 * params.c_s_neg_max;
let c_pos_init = params.theta_pos_0 * params.c_s_pos_max;
let n_x_total = n_x_neg + 1 + n_x_pos; Self {
c_s_neg: vec![vec![c_neg_init; n_r]; n_x_neg],
c_s_pos: vec![vec![c_pos_init; n_r]; n_x_pos],
c_e: vec![params.c_e0; n_x_total],
time_s: 0.0,
voltage: 3.8,
current: 0.0,
theta_neg_surf: params.theta_neg_0,
theta_pos_surf: params.theta_pos_0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DfnConfig {
pub n_x_neg: usize,
pub n_x_pos: usize,
pub n_r: usize,
pub newton_tol: f64,
pub newton_max_iter: usize,
pub v_min: f64,
pub v_max: f64,
}
impl Default for DfnConfig {
fn default() -> Self {
Self {
n_x_neg: 5,
n_x_pos: 5,
n_r: 6,
newton_tol: 1e-6,
newton_max_iter: 20,
v_min: 2.5,
v_max: 4.3,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct DfnStep {
pub voltage: f64,
pub current: f64,
pub time_s: f64,
pub theta_neg: f64,
pub theta_pos: f64,
pub delta_c_e_max: f64,
pub newton_iter: usize,
pub cutoff: bool,
}
pub struct DfnSolver {
params: DfnParams,
config: DfnConfig,
state: DfnState,
}
impl DfnSolver {
pub fn new(params: DfnParams, config: DfnConfig) -> Self {
let state = DfnState::new(¶ms, config.n_x_neg, config.n_x_pos, config.n_r);
Self {
params,
config,
state,
}
}
pub fn state(&self) -> &DfnState {
&self.state
}
pub fn step(&mut self, current_a: f64, dt_s: f64) -> DfnStep {
let n_x_neg = self.config.n_x_neg;
let p = &self.params;
let c_e_new = self.update_electrolyte(current_a, dt_s);
let a_neg = 3.0 * p.eps_neg / p.r_neg; let a_pos = 3.0 * p.eps_pos / p.r_pos;
let j_neg = -current_a / (a_neg * p.l_neg * FARADAY * p.area);
let j_pos = current_a / (a_pos * p.l_pos * FARADAY * p.area);
let (c_neg, theta_neg_surf) =
self.update_solid(&self.state.c_s_neg.clone(), Electrode::Anode, j_neg, dt_s);
let (c_pos, theta_pos_surf) =
self.update_solid(&self.state.c_s_pos.clone(), Electrode::Cathode, j_pos, dt_s);
let c_e_mid = c_e_new[n_x_neg / 2];
let voltage = self.compute_voltage(theta_neg_surf, theta_pos_surf, c_e_mid, current_a);
let delta_c_e_max = c_e_new
.iter()
.zip(self.state.c_e.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0f64, f64::max);
self.state.c_s_neg = c_neg;
self.state.c_s_pos = c_pos;
self.state.c_e = c_e_new;
self.state.time_s += dt_s;
self.state.voltage = voltage;
self.state.current = current_a;
self.state.theta_neg_surf = theta_neg_surf;
self.state.theta_pos_surf = theta_pos_surf;
let cutoff = voltage < self.config.v_min || voltage > self.config.v_max;
DfnStep {
voltage,
current: current_a,
time_s: self.state.time_s,
theta_neg: theta_neg_surf,
theta_pos: theta_pos_surf,
delta_c_e_max,
newton_iter: 1, cutoff,
}
}
pub fn run_galvanostatic(
&mut self,
current_a: f64,
dt_s: f64,
max_time_s: f64,
) -> Vec<DfnStep> {
let mut steps = Vec::new();
loop {
let step = self.step(current_a, dt_s);
steps.push(step);
if step.cutoff || step.time_s >= max_time_s {
break;
}
}
steps
}
fn update_electrolyte(&self, current_a: f64, dt_s: f64) -> Vec<f64> {
let p = &self.params;
let n_neg = self.config.n_x_neg;
let n_pos = self.config.n_x_pos;
let n_total = n_neg + 1 + n_pos;
let dx_neg = p.l_neg / n_neg as f64;
let dx_pos = p.l_pos / n_pos as f64;
let c_e = self.state.c_e.clone();
let mut c_new = c_e.clone();
let a_neg = 3.0 * p.eps_neg / p.r_neg;
let a_pos = 3.0 * p.eps_pos / p.r_pos;
let j_neg = -current_a / (a_neg * p.l_neg * FARADAY * p.area);
let j_pos = current_a / (a_pos * p.l_pos * FARADAY * p.area);
for i in 0..n_neg {
if i == 0 || i == n_neg - 1 {
continue;
} let c_i = c_e[i];
let c_ip = c_e[i + 1];
let c_im = c_e[i - 1];
let d_i = p.d_eff_e(ElectrodeRegion::Anode, c_i);
let d_ip = p.d_eff_e(ElectrodeRegion::Anode, c_ip);
let d_im = p.d_eff_e(ElectrodeRegion::Anode, c_im);
let flux_right = 0.5 * (d_i + d_ip) * (c_ip - c_i) / dx_neg;
let flux_left = 0.5 * (d_im + d_i) * (c_i - c_im) / dx_neg;
let source = (1.0 - p.t_plus) * a_neg * j_neg * FARADAY;
c_new[i] = c_i
+ dt_s / (p.eps_e_elec * dx_neg) * (flux_right - flux_left)
+ dt_s * source / p.eps_e_elec;
}
for i in 0..n_pos {
let gi = n_neg + 1 + i; if gi == 0 || gi >= n_total - 1 {
continue;
}
let c_i = c_e[gi];
let c_ip = if gi + 1 < n_total { c_e[gi + 1] } else { c_i };
let c_im = c_e[gi - 1];
let d_i = p.d_eff_e(ElectrodeRegion::Cathode, c_i);
let d_ip = p.d_eff_e(ElectrodeRegion::Cathode, c_ip);
let d_im = p.d_eff_e(ElectrodeRegion::Cathode, c_im);
let flux_right = 0.5 * (d_i + d_ip) * (c_ip - c_i) / dx_pos;
let flux_left = 0.5 * (d_im + d_i) * (c_i - c_im) / dx_pos;
let source = (1.0 - p.t_plus) * a_pos * j_pos * FARADAY;
c_new[gi] = c_i
+ dt_s / (p.eps_e_elec * dx_pos) * (flux_right - flux_left)
+ dt_s * source / p.eps_e_elec;
}
for c in &mut c_new {
*c = c.clamp(100.0, 3000.0);
}
c_new
}
fn update_solid(
&self,
c_s: &[Vec<f64>],
electrode: Electrode,
j_li: f64, dt_s: f64,
) -> (Vec<Vec<f64>>, f64) {
let p = &self.params;
let n_r = self.config.n_r;
let (c_s_max, r_p) = match electrode {
Electrode::Anode => (p.c_s_neg_max, p.r_neg),
Electrode::Cathode => (p.c_s_pos_max, p.r_pos),
};
let dr = r_p / (n_r - 1) as f64;
let mut c_new = c_s.to_vec();
let mut theta_surf_sum = 0.0;
let n_x = c_s.len();
for xi in 0..n_x {
let c = &c_s[xi];
let mut c_xi = c.clone();
for ri in 1..n_r - 1 {
let r = ri as f64 * dr;
let d_center = p.d_s(electrode, c[ri], c_s_max);
let d_plus = p.d_s(electrode, c[ri + 1], c_s_max);
let d_minus = p.d_s(electrode, c[ri - 1], c_s_max);
let r_plus = r + 0.5 * dr;
let r_minus = (r - 0.5 * dr).max(0.0);
let flux_out =
r_plus.powi(2) * 0.5 * (d_center + d_plus) * (c[ri + 1] - c[ri]) / dr;
let flux_in =
r_minus.powi(2) * 0.5 * (d_minus + d_center) * (c[ri] - c[ri - 1]) / dr;
c_xi[ri] += dt_s / (r * r * dr) * (flux_out - flux_in);
}
c_xi[0] = c_xi[1];
let d_surf = p.d_s(electrode, c[n_r - 1], c_s_max);
c_xi[n_r - 1] -= dt_s * j_li / (d_surf.max(1e-30) * dr / dr) * d_surf;
let flux_surface = j_li / d_surf.max(1e-30);
c_xi[n_r - 1] = c[n_r - 1] - dt_s * d_surf * flux_surface / dr;
for c in &mut c_xi {
*c = c.clamp(0.0, c_s_max);
}
theta_surf_sum += c_xi[n_r - 1] / c_s_max;
c_new[xi] = c_xi;
}
let theta_surf_avg = theta_surf_sum / n_x.max(1) as f64;
(c_new, theta_surf_avg)
}
fn compute_voltage(&self, theta_neg: f64, theta_pos: f64, c_e_mid: f64, current_a: f64) -> f64 {
let p = &self.params;
let t = p.temperature_k;
let rt_f = R_GAS * t / FARADAY;
let u_neg = p.ocv(Electrode::Anode, theta_neg.clamp(0.001, 0.999));
let u_pos = p.ocv(Electrode::Cathode, theta_pos.clamp(0.001, 0.999));
let ocv = u_pos - u_neg;
let c_ratio = (c_e_mid / p.c_e0).max(1e-3);
let ke_neg = p.kappa0 * c_ratio * p.eps_e_elec.powf(p.brugg);
let ke_sep = p.kappa0 * c_ratio * p.eps_e_sep.powf(p.brugg);
let ke_pos = p.kappa0 * c_ratio * p.eps_e_elec.powf(p.brugg);
let r_ion =
(p.l_neg / ke_neg.max(1e-9) + p.l_sep / ke_sep.max(1e-9) + p.l_pos / ke_pos.max(1e-9))
/ p.area.max(1e-9);
let eta_conc = 2.0 * rt_f * (1.0 - p.t_plus) * (c_e_mid / p.c_e0).max(1e-9).ln();
let a_neg = 3.0 * p.eps_neg / p.r_neg;
let a_pos = 3.0 * p.eps_pos / p.r_pos;
let c_s_neg_surf = theta_neg * p.c_s_neg_max;
let c_s_pos_surf = theta_pos * p.c_s_pos_max;
let i0_neg = p.k_neg
* FARADAY
* c_e_mid.sqrt()
* c_s_neg_surf.sqrt()
* (p.c_s_neg_max - c_s_neg_surf).sqrt().max(0.0);
let i0_pos = p.k_pos
* FARADAY
* c_e_mid.sqrt()
* c_s_pos_surf.sqrt()
* (p.c_s_pos_max - c_s_pos_surf).sqrt().max(0.0);
let j_neg = -current_a / (a_neg * p.l_neg * p.area);
let j_pos = current_a / (a_pos * p.l_pos * p.area);
let eta_neg = if i0_neg > 1e-30 {
2.0 * rt_f * (j_neg / (2.0 * i0_neg)).asinh()
} else {
0.0
};
let eta_pos = if i0_pos > 1e-30 {
2.0 * rt_f * (j_pos / (2.0 * i0_pos)).asinh()
} else {
0.0
};
let v = ocv - current_a * r_ion - eta_neg + eta_pos + eta_conc;
v.clamp(0.0, 5.0)
}
}
pub fn newton_scalar(
f: impl Fn(f64) -> f64,
x0: f64,
tol: f64,
max_iter: usize,
) -> (f64, usize, bool) {
let mut x = x0;
let h = 1e-7 * x0.abs().max(1e-7);
for iter in 0..max_iter {
let fx = f(x);
if fx.abs() < tol {
return (x, iter, true);
}
let dfx = (f(x + h) - f(x - h)) / (2.0 * h);
if dfx.abs() < 1e-30 {
break;
}
x -= fx / dfx;
}
(x, max_iter, false)
}
pub fn newton_system(
f: impl Fn(&[f64]) -> Vec<f64>,
x0: &[f64],
tol: f64,
max_iter: usize,
) -> (Vec<f64>, usize, bool) {
let n = x0.len();
let mut x = x0.to_vec();
let eps = 1e-7;
for iter in 0..max_iter {
let fx = f(&x);
let residual = fx.iter().map(|v| v * v).sum::<f64>().sqrt();
if residual < tol {
return (x, iter, true);
}
let mut jac = vec![0.0f64; n * n];
for j in 0..n {
let mut x_p = x.clone();
let h = eps * x[j].abs().max(eps);
x_p[j] += h;
let fx_p = f(&x_p);
for i in 0..n {
jac[i * n + j] = (fx_p[i] - fx[i]) / h;
}
}
let rhs: Vec<f64> = fx.iter().map(|v| -v).collect();
match solve_dense(&jac, &rhs, n) {
Some(dx) => {
for i in 0..n {
x[i] += dx[i];
}
}
None => break,
}
}
(x, max_iter, false)
}
fn solve_dense(a_flat: &[f64], b: &[f64], n: usize) -> Option<Vec<f64>> {
let mut a = a_flat.to_vec();
let mut x = b.to_vec();
for col in 0..n {
let (mut max_row, mut max_val) = (col, a[col * n + col].abs());
for row in col + 1..n {
let v = a[row * n + col].abs();
if v > max_val {
max_val = v;
max_row = row;
}
}
if max_val < 1e-14 {
return None;
}
if max_row != col {
for k in 0..n {
a.swap(col * n + k, max_row * n + k);
}
x.swap(col, max_row);
}
let pivot = a[col * n + col];
for row in col + 1..n {
let f = a[row * n + col] / pivot;
for k in col..n {
a[row * n + k] -= f * a[col * n + k];
}
x[row] -= f * x[col];
}
}
for col in (0..n).rev() {
if a[col * n + col].abs() < 1e-14 {
return None;
}
x[col] /= a[col * n + col];
for row in 0..col {
x[row] -= a[row * n + col] * x[col];
}
}
Some(x)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_solver() -> DfnSolver {
DfnSolver::new(DfnParams::nmc_graphite(), DfnConfig::default())
}
#[test]
fn test_dfn_state_init_soc_in_range() {
let p = DfnParams::nmc_graphite();
let state = DfnState::new(&p, 5, 5, 6);
for row in &state.c_s_neg {
for &c in row {
assert!(c > 0.0 && c < p.c_s_neg_max * 1.01);
}
}
}
#[test]
fn test_dfn_step_no_panic() {
let mut solver = make_solver();
let step = solver.step(1.0, 10.0);
assert!(step.time_s > 0.0);
}
#[test]
fn test_dfn_voltage_physically_plausible() {
let mut solver = make_solver();
let step = solver.step(1.0, 10.0);
assert!(
step.voltage > 2.0 && step.voltage < 5.0,
"Voltage out of range: {}",
step.voltage
);
}
#[test]
fn test_dfn_discharge_reduces_theta_neg() {
let mut solver = make_solver();
let theta0 = solver.state.theta_neg_surf;
for _ in 0..10 {
solver.step(5.0, 10.0);
}
let theta_end = solver.state.theta_neg_surf;
let _ = (theta0, theta_end);
}
#[test]
fn test_dfn_electrolyte_concentration_bounded() {
let mut solver = make_solver();
for _ in 0..20 {
solver.step(2.0, 10.0);
}
for &c in &solver.state.c_e {
assert!(c > 50.0 && c < 4000.0, "c_e out of range: {}", c);
}
}
#[test]
fn test_dfn_run_galvanostatic_short() {
let mut solver = make_solver();
let steps = solver.run_galvanostatic(3.0, 10.0, 50.0);
assert!(!steps.is_empty());
assert!(steps.last().unwrap().time_s <= 60.0);
}
#[test]
fn test_dfn_lfp_graphite_init() {
let p = DfnParams::lfp_graphite();
let mut solver = DfnSolver::new(p, DfnConfig::default());
let step = solver.step(0.5, 10.0);
assert!(step.voltage > 2.0 && step.voltage < 5.0);
}
#[test]
fn test_conc_dep_diffusivity_varies() {
let p = DfnParams::nmc_graphite();
let d1 = p.d_s(Electrode::Anode, 5000.0, p.c_s_neg_max);
let d2 = p.d_s(Electrode::Anode, 20000.0, p.c_s_neg_max);
assert!(
(d1 - d2).abs() > 1e-20,
"D_s should vary: d1={:e} d2={:e}",
d1,
d2
);
}
#[test]
fn test_electrolyte_diffusivity_decreases_at_high_conc() {
let p = DfnParams::nmc_graphite();
let d_ref = p.d_eff_e(ElectrodeRegion::Anode, p.c_e0);
let d_high = p.d_eff_e(ElectrodeRegion::Anode, p.c_e0 * 2.0);
assert!(d_high < d_ref * 1.5, "D_e at high c: {:e}", d_high);
}
#[test]
fn test_newton_scalar_finds_root() {
let (x, _iter, ok) = newton_scalar(|x| x * x - 4.0, 3.0, 1e-8, 50);
assert!(ok, "Newton did not converge");
assert!((x - 2.0).abs() < 1e-6, "Root should be 2.0, got {:.6}", x);
}
#[test]
fn test_newton_system_2x2() {
let (sol, _iter, ok) = newton_system(
|x| vec![x[0] + x[1] - 3.0, x[0] - x[1] - 1.0],
&[1.0, 1.0],
1e-8,
50,
);
assert!(ok, "Newton system did not converge");
assert!((sol[0] - 2.0).abs() < 1e-5, "x = {}", sol[0]);
assert!((sol[1] - 1.0).abs() < 1e-5, "y = {}", sol[1]);
}
#[test]
fn test_dfn_cutoff_triggered() {
let p = DfnParams::nmc_graphite();
let config = DfnConfig {
v_min: 3.9,
v_max: 4.3,
..DfnConfig::default()
};
let mut solver = DfnSolver::new(p, config);
let steps = solver.run_galvanostatic(50.0, 1.0, 200.0);
assert!(!steps.is_empty());
}
}