use serde::{Deserialize, Serialize};
const GAS_CONSTANT: f64 = 8.314;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgingParams {
pub k_cal: f64,
pub e_a: f64,
pub k_cyc: f64,
pub dod_exponent: f64,
pub dod_ref: f64,
pub q_nom: f64,
pub r0_nom: f64,
pub r_growth_factor: f64,
}
impl AgingParams {
pub fn lfp_default() -> Self {
Self {
k_cal: 0.0003, e_a: 24_500.0, k_cyc: 0.0025, dod_exponent: 0.8,
dod_ref: 1.0,
q_nom: 75.0,
r0_nom: 0.0015,
r_growth_factor: 2.0, }
}
pub fn nmc_default() -> Self {
Self {
k_cal: 0.00045,
e_a: 27_000.0,
k_cyc: 0.005,
dod_exponent: 1.0,
dod_ref: 1.0,
q_nom: 3.0,
r0_nom: 0.020,
r_growth_factor: 2.5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgingState {
pub time_s: f64,
pub equiv_full_cycles: f64,
pub q_loss_cal_pct: f64,
pub q_loss_cyc_pct: f64,
pub q_remaining: f64,
pub r0_current: f64,
pub soh: f64,
}
impl AgingState {
pub fn new(params: &AgingParams) -> Self {
Self {
time_s: 0.0,
equiv_full_cycles: 0.0,
q_loss_cal_pct: 0.0,
q_loss_cyc_pct: 0.0,
q_remaining: params.q_nom,
r0_current: params.r0_nom,
soh: 1.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgingModel {
pub params: AgingParams,
pub state: AgingState,
charge_throughput: f64,
prev_soc: f64,
ah_total: f64,
}
impl AgingModel {
pub fn new(params: AgingParams) -> Self {
let state = AgingState::new(¶ms);
Self {
state,
charge_throughput: 0.0,
prev_soc: 1.0,
ah_total: 0.0,
params,
}
}
pub fn step_calendar(&mut self, dt_s: f64, temp_k: f64) {
let t0 = self.state.time_s;
let t1 = t0 + dt_s;
let k_eff = self.params.k_cal * (-self.params.e_a / (GAS_CONSTANT * temp_k)).exp();
let delta_q = k_eff * (t1.sqrt() - t0.max(1.0).sqrt());
self.state.q_loss_cal_pct += delta_q * 100.0;
self.state.time_s = t1;
self.update_capacity();
}
pub fn register_cycle(&mut self, dod: f64) {
let dod_clamped = dod.clamp(0.0, 1.0);
if dod_clamped < 1e-4 {
return;
}
let delta_q =
self.params.k_cyc * (dod_clamped / self.params.dod_ref).powf(self.params.dod_exponent);
self.state.q_loss_cyc_pct += delta_q * 100.0;
self.state.equiv_full_cycles += dod_clamped;
self.update_capacity();
}
pub fn step_current(&mut self, current_a: f64, dt_s: f64, soc: f64) {
let dah = current_a.abs() * dt_s / 3600.0;
self.ah_total += dah;
let soc_change = soc - self.prev_soc;
let prev_dir = (self.prev_soc - soc).signum();
let cur_dir = soc_change.signum();
if cur_dir.abs() > 0.5 && prev_dir * cur_dir < 0.0 {
let dod = self.charge_throughput / self.params.q_nom;
self.register_cycle(dod);
self.charge_throughput = 0.0;
}
self.charge_throughput += dah;
self.prev_soc = soc;
}
fn update_capacity(&mut self) {
let total_loss_pct = (self.state.q_loss_cal_pct + self.state.q_loss_cyc_pct).min(100.0);
self.state.q_remaining = self.params.q_nom * (1.0 - total_loss_pct / 100.0);
self.state.soh = (self.state.q_remaining / self.params.q_nom).clamp(0.0, 1.0);
let capacity_loss_frac = total_loss_pct / 100.0;
self.state.r0_current =
self.params.r0_nom * (1.0 + self.params.r_growth_factor * capacity_loss_frac);
}
pub fn time_to_80pct_soh(&self, temp_k: f64) -> f64 {
let k_eff = self.params.k_cal * (-self.params.e_a / (GAS_CONSTANT * temp_k)).exp();
if k_eff < 1e-20 {
return f64::INFINITY;
}
(0.20 / k_eff).powi(2)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LithiumPlatingModel {
pub q_nom: f64,
pub c_rate_threshold: f64,
pub k_plating: f64,
pub beta: f64,
pub irreversible_fraction: f64,
pub plated_ah: f64,
pub dead_li_ah: f64,
pub t_threshold_k: f64,
pub cold_amplification: f64,
}
impl LithiumPlatingModel {
pub fn nmc_default(q_nom: f64) -> Self {
Self {
q_nom,
c_rate_threshold: 0.5, k_plating: 2e-7, beta: 1.5,
irreversible_fraction: 0.20,
plated_ah: 0.0,
dead_li_ah: 0.0,
t_threshold_k: 278.15, cold_amplification: 3.0,
}
}
pub fn lfp_default(q_nom: f64) -> Self {
Self {
q_nom,
c_rate_threshold: 1.0,
k_plating: 5e-8,
beta: 1.2,
irreversible_fraction: 0.15,
plated_ah: 0.0,
dead_li_ah: 0.0,
t_threshold_k: 268.15, cold_amplification: 4.0,
}
}
pub fn step(&mut self, i_charge_a: f64, dt_s: f64, temp_k: f64) -> f64 {
if i_charge_a <= 0.0 {
let strip = self.plated_ah * 0.05 * (dt_s / 3600.0); let strip = strip.min(self.plated_ah - self.dead_li_ah);
if strip > 0.0 {
self.plated_ah -= strip;
}
return 0.0;
}
let i_threshold = self.c_rate_threshold * self.q_nom;
let excess = (i_charge_a - i_threshold).max(0.0);
if excess < 1e-9 {
return 0.0;
}
let temp_factor = if temp_k < self.t_threshold_k {
self.cold_amplification
} else {
1.0
};
let dq_plated = self.k_plating * excess.powf(self.beta) * dt_s * temp_factor;
self.plated_ah += dq_plated;
let dead = dq_plated * self.irreversible_fraction;
self.dead_li_ah += dead;
dead
}
pub fn capacity_loss_fraction(&self) -> f64 {
(self.dead_li_ah / self.q_nom).clamp(0.0, 1.0)
}
pub fn remaining_capacity_ah(&self) -> f64 {
(self.q_nom - self.dead_li_ah).max(0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_cell_full_capacity() {
let model = AgingModel::new(AgingParams::lfp_default());
assert!((model.state.soh - 1.0).abs() < 1e-9);
assert!((model.state.q_remaining - 75.0).abs() < 1e-9);
}
#[test]
fn test_calendar_aging_reduces_capacity() {
let mut model = AgingModel::new(AgingParams::lfp_default());
let one_year = 31_536_000.0;
model.step_calendar(one_year, 298.15);
assert!(
model.state.soh < 1.0,
"SoH should decrease: {}",
model.state.soh
);
assert!(
model.state.soh > 0.5,
"SoH should not collapse in 1 year: {}",
model.state.soh
);
}
#[test]
fn test_high_temp_accelerates_aging() {
let p = AgingParams::lfp_default();
let mut cool = AgingModel::new(p.clone());
let mut hot = AgingModel::new(p);
let one_year = 31_536_000.0;
cool.step_calendar(one_year, 298.15); hot.step_calendar(one_year, 333.15); assert!(
hot.state.q_loss_cal_pct > cool.state.q_loss_cal_pct,
"hot={:.4} cool={:.4}",
hot.state.q_loss_cal_pct,
cool.state.q_loss_cal_pct
);
}
#[test]
fn test_cycle_aging_reduces_capacity() {
let mut model = AgingModel::new(AgingParams::lfp_default());
for _ in 0..100 {
model.register_cycle(1.0); }
assert!(model.state.q_loss_cyc_pct > 0.0);
assert!(model.state.soh < 1.0);
}
#[test]
fn test_resistance_grows_with_aging() {
let mut model = AgingModel::new(AgingParams::lfp_default());
let r_initial = model.state.r0_current;
for _ in 0..500 {
model.register_cycle(1.0);
}
assert!(
model.state.r0_current > r_initial,
"R should grow: {} > {}",
model.state.r0_current,
r_initial
);
}
#[test]
fn test_soh_bounded_between_0_and_1() {
let mut model = AgingModel::new(AgingParams::lfp_default());
model.step_calendar(1e12, 333.15);
model.register_cycle(1.0);
assert!(
model.state.soh >= 0.0 && model.state.soh <= 1.0,
"SoH={}",
model.state.soh
);
}
#[test]
fn test_time_to_80pct_positive() {
let p = AgingParams::lfp_default();
let model = AgingModel::new(p);
let t80 = model.time_to_80pct_soh(298.15);
assert!(t80 > 0.0 && t80 < 1e15, "t80={:.2e}", t80);
}
#[test]
fn test_plating_no_damage_below_threshold() {
let mut m = LithiumPlatingModel::nmc_default(3.0);
let dead = m.step(0.4 * 3.0, 3600.0, 298.15);
assert_eq!(dead, 0.0);
assert_eq!(m.plated_ah, 0.0);
}
#[test]
fn test_plating_occurs_above_threshold() {
let mut m = LithiumPlatingModel::nmc_default(3.0);
let dead = m.step(2.0 * 3.0, 3600.0, 298.15);
assert!(dead > 0.0, "Dead Li should accumulate at 2C");
assert!(m.plated_ah > 0.0);
assert!(m.dead_li_ah > 0.0);
}
#[test]
fn test_plating_amplified_at_low_temperature() {
let mut warm = LithiumPlatingModel::nmc_default(3.0);
let mut cold = LithiumPlatingModel::nmc_default(3.0);
let i = 2.0 * 3.0;
let warm_dead = warm.step(i, 3600.0, 298.15); let cold_dead = cold.step(i, 3600.0, 268.15); assert!(
cold_dead > warm_dead,
"Cold plating {cold_dead:.4e} should exceed warm {warm_dead:.4e}"
);
}
#[test]
fn test_plating_capacity_loss_fraction_bounded() {
let mut m = LithiumPlatingModel::nmc_default(3.0);
for _ in 0..36000 {
m.step(10.0 * 3.0, 1.0, 253.15);
}
let loss = m.capacity_loss_fraction();
assert!(
(0.0..=1.0).contains(&loss),
"Loss fraction {loss:.4} out of [0,1]"
);
}
#[test]
fn test_remaining_capacity_non_negative() {
let mut m = LithiumPlatingModel::lfp_default(75.0);
for _ in 0..100_000 {
m.step(150.0, 1.0, 258.15); }
assert!(m.remaining_capacity_ah() >= 0.0);
}
#[test]
fn test_no_plating_during_discharge() {
let mut m = LithiumPlatingModel::nmc_default(3.0);
let dead = m.step(-3.0, 3600.0, 298.15); assert_eq!(dead, 0.0);
assert_eq!(m.plated_ah, 0.0);
}
#[test]
fn test_plating_irreversible_fraction_correct() {
let mut m = LithiumPlatingModel::nmc_default(3.0);
let dead = m.step(6.0, 1.0, 298.15); assert!((dead - m.dead_li_ah).abs() < 1e-12);
assert!((m.dead_li_ah - m.plated_ah * m.irreversible_fraction).abs() < 1e-12);
}
}