use serde::{Deserialize, Serialize};
use super::electrode::{ElectrodeParams, ParticleDiffusion};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum SpmMode {
GalvanostaticDischarge,
GalvanostaticCharge,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpmConfig {
pub n_nodes: usize,
pub v_min: f64,
pub v_max: f64,
pub faraday: f64,
pub r_gas: f64,
pub r_electrolyte_ohm_m2: f64,
}
impl Default for SpmConfig {
fn default() -> Self {
Self {
n_nodes: 11, v_min: 2.5,
v_max: 4.2,
faraday: 96_485.0,
r_gas: 8.314,
r_electrolyte_ohm_m2: 2e-4, }
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct SpmState {
pub voltage: f64,
pub current: f64,
pub theta_neg: f64,
pub theta_pos: f64,
pub theta_neg_avg: f64,
pub theta_pos_avg: f64,
pub time_s: f64,
pub cutoff: bool,
}
pub struct SpmSolver {
pub config: SpmConfig,
pub anode: ParticleDiffusion,
pub cathode: ParticleDiffusion,
pub electrode_area: f64,
pub time_s: f64,
}
impl SpmSolver {
pub fn graphite_lfp(config: SpmConfig) -> Self {
let n = config.n_nodes;
let anode = ParticleDiffusion::new(ElectrodeParams::graphite_anode(), n);
let cathode = ParticleDiffusion::new(ElectrodeParams::lfp_cathode(), n);
let area = anode.params.area;
Self {
config,
anode,
cathode,
electrode_area: area,
time_s: 0.0,
}
}
pub fn graphite_nmc(config: SpmConfig) -> Self {
let n = config.n_nodes;
let anode = ParticleDiffusion::new(ElectrodeParams::graphite_anode(), n);
let cathode = ParticleDiffusion::new(ElectrodeParams::nmc_cathode(), n);
let area = anode.params.area;
Self {
config,
anode,
cathode,
electrode_area: area,
time_s: 0.0,
}
}
fn bv_overpotential(&self, j_n: f64, particle: &ParticleDiffusion, temp_k: f64) -> f64 {
let f = self.config.faraday;
let r = self.config.r_gas;
let theta_s = particle.theta_surface().clamp(0.01, 0.99);
let c_ss = theta_s * particle.params.c_s_max;
let c_smax = particle.params.c_s_max;
let i_0_mol = particle.params.k_bv * (c_ss * (c_smax - c_ss) * 1000.0).sqrt();
if i_0_mol < 1e-30 {
return 0.0;
}
let arg = j_n / (2.0 * i_0_mol);
(2.0 * r * temp_k / f) * arg.asinh()
}
pub fn step(&mut self, current_a: f64, dt: f64, temp_k: f64) -> SpmState {
let f = self.config.faraday;
let area = self.electrode_area;
let i_app = current_a / area;
let a_s_neg = self.anode.params.specific_area();
let a_s_pos = self.cathode.params.specific_area();
let l_neg = self.anode.params.thickness;
let l_pos = self.cathode.params.thickness;
let j_n_neg = -i_app / (a_s_neg * l_neg * f); let j_n_pos = i_app / (a_s_pos * l_pos * f);
let dt_neg = self.anode.dt_stable(temp_k) * 0.45;
let dt_pos = self.cathode.dt_stable(temp_k) * 0.45;
let dt_use = dt.min(dt_neg).min(dt_pos);
let n_sub = (dt / dt_use).ceil() as usize;
let dt_sub = dt / n_sub as f64;
for _ in 0..n_sub {
self.anode.step(j_n_neg, dt_sub, temp_k);
self.cathode.step(j_n_pos, dt_sub, temp_k);
}
let u_neg = self.anode.ocp_surface();
let u_pos = self.cathode.ocp_surface();
let eta_neg = -self.bv_overpotential(j_n_neg, &self.anode, temp_k);
let eta_pos = -self.bv_overpotential(j_n_pos, &self.cathode, temp_k);
let v_ohm = i_app * self.config.r_electrolyte_ohm_m2;
let voltage = (u_pos + eta_pos) - (u_neg + eta_neg) - v_ohm * current_a.signum();
self.time_s += dt;
let cutoff = voltage < self.config.v_min || voltage > self.config.v_max;
SpmState {
voltage,
current: current_a,
theta_neg: self.anode.theta_surface(),
theta_pos: self.cathode.theta_surface(),
theta_neg_avg: self.anode.theta_avg(),
theta_pos_avg: self.cathode.theta_avg(),
time_s: self.time_s,
cutoff,
}
}
pub fn simulate_discharge(
&mut self,
current_a: f64,
dt: f64,
temp_k: f64,
max_time_s: f64,
) -> Vec<SpmState> {
let mut states = Vec::new();
let mut t = 0.0;
while t < max_time_s {
let state = self.step(current_a, dt, temp_k);
states.push(state);
t += dt;
if state.cutoff {
break;
}
}
states
}
pub fn ocv(&self) -> f64 {
self.cathode.ocp_surface() - self.anode.ocp_surface()
}
pub fn soc_estimate(&self) -> f64 {
let theta = self.anode.theta_avg();
let theta_100 = 0.80; let theta_0 = 0.20; ((theta - theta_0) / (theta_100 - theta_0)).clamp(0.0, 1.0)
}
pub fn reset(&mut self) {
self.anode.reset();
self.cathode.reset();
self.time_s = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ocv_in_range() {
let solver = SpmSolver::graphite_lfp(SpmConfig::default());
let ocv = solver.ocv();
assert!(ocv > 2.5 && ocv < 4.5, "OCV out of range: {} V", ocv);
}
#[test]
fn test_discharge_reduces_voltage() {
let mut solver = SpmSolver::graphite_lfp(SpmConfig::default());
let v0 = solver.ocv();
let state = solver.step(3.0, 1.0, 298.15);
assert!(
state.voltage < v0 + 0.01,
"Voltage should drop on discharge"
);
}
#[test]
fn test_soc_initial() {
let solver = SpmSolver::graphite_lfp(SpmConfig::default());
let soc = solver.soc_estimate();
assert!(soc > 0.9, "Initial SoC should be high: {:.3}", soc);
}
#[test]
fn test_simulate_discharge_returns_states() {
let mut solver = SpmSolver::graphite_lfp(SpmConfig::default());
let states = solver.simulate_discharge(3.0, 10.0, 298.15, 60.0);
assert!(!states.is_empty(), "Should return at least one state");
if states.len() > 1 {
assert!(states.last().unwrap().voltage <= states.first().unwrap().voltage + 0.1);
}
}
#[test]
fn test_reset_restores_ocv() {
let mut solver = SpmSolver::graphite_lfp(SpmConfig::default());
let ocv_before = solver.ocv();
solver.simulate_discharge(3.0, 10.0, 298.15, 300.0);
solver.reset();
let ocv_after = solver.ocv();
assert!(
(ocv_after - ocv_before).abs() < 0.01,
"OCV should be restored after reset"
);
}
#[test]
fn test_graphite_nmc_solver() {
let mut solver = SpmSolver::graphite_nmc(SpmConfig::default());
let ocv = solver.ocv();
assert!(ocv > 3.0 && ocv < 5.0, "NMC OCV out of range: {} V", ocv);
let state = solver.step(3.0, 1.0, 298.15);
assert!(!state.cutoff || state.voltage <= solver.config.v_min + 0.01);
}
#[test]
fn test_bv_zero_current_zero_overpotential() {
let solver = SpmSolver::graphite_lfp(SpmConfig::default());
let eta = solver.bv_overpotential(0.0, &solver.anode, 298.15);
assert!(eta.abs() < 1e-12, "Zero flux → zero overpotential");
}
}