use serde::{Deserialize, Serialize};
const R_GAS: f64 = 8.314;
const K_SEI: f64 = 1e-4;
const DEFAULT_R0: f64 = 0.02;
const TEMP_HISTORY_SIZE: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DegradationChemistry {
NmcGraphite,
LfpGraphite,
NcaGraphite,
NmcLto,
SolidState,
SodiumIon,
}
impl DegradationChemistry {
pub fn ea_sei_j_mol(self) -> f64 {
match self {
Self::NmcGraphite => 60_000.0,
Self::LfpGraphite => 55_000.0,
Self::NcaGraphite => 65_000.0,
Self::NmcLto => 50_000.0,
Self::SolidState => 45_000.0,
Self::SodiumIon => 52_000.0,
}
}
pub fn alpha_cal(self) -> f64 {
match self {
Self::NmcGraphite => 0.020,
Self::LfpGraphite => 0.012,
Self::NcaGraphite => 0.025,
Self::NmcLto => 0.005,
Self::SolidState => 0.003,
Self::SodiumIon => 0.010,
}
}
pub fn alpha_cyc(self) -> f64 {
match self {
Self::NmcGraphite => 0.015,
Self::LfpGraphite => 0.008,
Self::NcaGraphite => 0.020,
Self::NmcLto => 0.004,
Self::SolidState => 0.002,
Self::SodiumIon => 0.007,
}
}
pub fn beta_dod(self) -> f64 {
match self {
Self::NmcGraphite => 0.80,
Self::LfpGraphite => 0.65,
Self::NcaGraphite => 0.85,
Self::NmcLto => 0.50,
Self::SolidState => 0.40,
Self::SodiumIon => 0.60,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DegradationMechanism {
SeiGrowth,
LithiumPlating,
CathodeDissolution,
MechanicalStress,
ElectrolyteDecomposition,
CalendarAging,
CycleAging,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgingMode {
Calendar,
Cycle,
Combined,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgingConditions {
pub temperature_c: f64,
pub soc_pct: f64,
pub c_rate: f64,
pub depth_of_discharge_pct: f64,
pub cycle_count: f64,
pub calendar_days: f64,
}
impl Default for AgingConditions {
fn default() -> Self {
Self {
temperature_c: 25.0,
soc_pct: 50.0,
c_rate: 0.0,
depth_of_discharge_pct: 80.0,
cycle_count: 0.0,
calendar_days: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapacityFade {
pub relative_capacity: f64,
pub calendar_loss_pct: f64,
pub cycle_loss_pct: f64,
pub sei_loss_pct: f64,
pub plating_loss_pct: f64,
pub mechanical_loss_pct: f64,
pub total_loss_pct: f64,
pub rul_cycles: f64,
pub rul_days: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResistanceGrowth {
pub relative_resistance: f64,
pub sei_resistance_ohm: f64,
pub contact_resistance_ohm: f64,
pub total_resistance_ohm: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CycleCounterDoD {
pub half_cycles: Vec<f64>,
pub completed_cycles: Vec<(f64, f64)>,
soc_at_half_start: Vec<f64>,
}
impl CycleCounterDoD {
pub fn new() -> Self {
Self {
half_cycles: Vec::new(),
completed_cycles: Vec::new(),
soc_at_half_start: Vec::new(),
}
}
pub fn add_soc_point(&mut self, soc: f64) {
let soc = soc.clamp(0.0, 100.0);
self.half_cycles.push(soc);
self.soc_at_half_start.push(soc);
loop {
let n = self.half_cycles.len();
if n < 3 {
break;
}
let x0 = self.half_cycles[n - 3];
let x1 = self.half_cycles[n - 2];
let x2 = self.half_cycles[n - 1];
let range1 = (x1 - x0).abs();
let range2 = (x2 - x1).abs();
if range1 <= range2 {
let dod = range1;
let mean_soc = (x0 + x1) / 2.0;
self.completed_cycles.push((dod, mean_soc));
self.half_cycles.remove(n - 3);
self.half_cycles.remove(n - 3); if self.soc_at_half_start.len() >= 2 {
self.soc_at_half_start.remove(n - 3);
let idx = self.soc_at_half_start.len().saturating_sub(1);
if idx < self.soc_at_half_start.len() {
self.soc_at_half_start.remove(idx);
}
}
} else {
break;
}
}
}
pub fn count_cycles(&self) -> f64 {
self.completed_cycles.len() as f64
}
}
impl Default for CycleCounterDoD {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DegradationModel {
pub chemistry: DegradationChemistry,
pub nominal_capacity_ah: f64,
pub nominal_voltage_v: f64,
pub initial_resistance_ohm: f64,
pub current_capacity_ah: f64,
pub current_resistance_ohm: f64,
pub total_cycles: f64,
pub calendar_age_days: f64,
pub cumulative_sei_loss: f64,
pub cumulative_plating_loss: f64,
pub cumulative_mechanical_loss: f64,
pub temperature_history: Vec<f64>,
pub cumulative_calendar_loss: f64,
pub cumulative_cycle_loss: f64,
}
impl DegradationModel {
pub fn new(chemistry: DegradationChemistry, capacity_ah: f64, voltage_v: f64) -> Self {
Self {
chemistry,
nominal_capacity_ah: capacity_ah,
nominal_voltage_v: voltage_v,
initial_resistance_ohm: DEFAULT_R0,
current_capacity_ah: capacity_ah,
current_resistance_ohm: DEFAULT_R0,
total_cycles: 0.0,
calendar_age_days: 0.0,
cumulative_sei_loss: 0.0,
cumulative_plating_loss: 0.0,
cumulative_mechanical_loss: 0.0,
temperature_history: Vec::new(),
cumulative_calendar_loss: 0.0,
cumulative_cycle_loss: 0.0,
}
}
fn soc_stress_factor(soc_pct: f64) -> f64 {
let s = (soc_pct.clamp(0.0, 100.0) - 50.0) / 50.0;
1.0 + 0.5 * s * s
}
fn arrhenius(ea_j_mol: f64, temp_c: f64) -> f64 {
const T_REF: f64 = 298.15; let t_k = temp_c + 273.15;
((-ea_j_mol / R_GAS) * (1.0 / t_k - 1.0 / T_REF)).exp()
}
fn temperature_cycle_factor(temp_c: f64) -> f64 {
(0.02 * (temp_c - 25.0)).exp()
}
pub fn compute_sei_growth_rate(&self, temp_c: f64, soc_pct: f64) -> f64 {
let ea = self.chemistry.ea_sei_j_mol();
let arr = Self::arrhenius(ea, temp_c);
let soc_f = Self::soc_stress_factor(soc_pct);
let t_ref = (self.calendar_age_days + 1.0).sqrt();
K_SEI * arr * soc_f / t_ref * self.nominal_capacity_ah
}
pub fn compute_calendar_loss(&self, dt_days: f64, temp_c: f64, soc_pct: f64) -> f64 {
let ea = self.chemistry.ea_sei_j_mol();
let alpha = self.chemistry.alpha_cal() / 100.0;
let arr = Self::arrhenius(ea, temp_c);
let soc_f = Self::soc_stress_factor(soc_pct);
let t0 = self.calendar_age_days;
let t1 = t0 + dt_days.max(0.0);
let delta_sqrt = t1.sqrt() - t0.sqrt();
let loss_frac = alpha * arr * soc_f * delta_sqrt;
(loss_frac * self.nominal_capacity_ah).max(0.0)
}
pub fn compute_cycle_loss(&self, dod_pct: f64, c_rate: f64, temp_c: f64) -> f64 {
let alpha = self.chemistry.alpha_cyc() / 100.0;
let beta = self.chemistry.beta_dod();
let dod_norm = dod_pct.clamp(0.0, 100.0) / 100.0;
let c_eff = c_rate.clamp(0.0, 3.0);
let c_factor = 1.0 + 0.15 * (c_eff - 1.0);
let t_factor = Self::temperature_cycle_factor(temp_c);
let loss_frac = alpha * dod_norm.powf(beta) * c_factor * t_factor;
(loss_frac * self.nominal_capacity_ah).max(0.0)
}
pub fn step_calendar(&mut self, dt_days: f64, conditions: &AgingConditions) {
if dt_days <= 0.0 {
return;
}
let loss_ah =
self.compute_calendar_loss(dt_days, conditions.temperature_c, conditions.soc_pct);
let actual_loss = loss_ah.min(self.current_capacity_ah);
let sei_portion = actual_loss * 0.7;
self.cumulative_sei_loss += sei_portion;
self.cumulative_calendar_loss += actual_loss;
self.current_capacity_ah = (self.current_capacity_ah - actual_loss).max(0.0);
self.calendar_age_days += dt_days;
self.current_resistance_ohm = self.compute_resistance_ohm_internal();
self.temperature_history.push(conditions.temperature_c);
if self.temperature_history.len() > TEMP_HISTORY_SIZE {
self.temperature_history.remove(0);
}
}
pub fn step_cycle(&mut self, dod_pct: f64, conditions: &AgingConditions) {
let loss_ah = self.compute_cycle_loss(dod_pct, conditions.c_rate, conditions.temperature_c);
let actual_loss = loss_ah.min(self.current_capacity_ah);
let mech_portion = actual_loss * 0.2;
self.cumulative_mechanical_loss += mech_portion;
self.cumulative_cycle_loss += actual_loss;
self.current_capacity_ah = (self.current_capacity_ah - actual_loss).max(0.0);
self.total_cycles += 1.0;
self.current_resistance_ohm = self.compute_resistance_ohm_internal();
}
pub fn compute_soh(&self) -> f64 {
if self.nominal_capacity_ah <= 0.0 {
return 0.0;
}
(self.current_capacity_ah / self.nominal_capacity_ah).clamp(0.0, 1.0)
}
pub fn compute_rul_cycles(&self) -> f64 {
let soh = self.compute_soh();
if soh <= 0.80 {
return 0.0;
}
let fade_per_cycle = self.compute_cycle_loss(50.0, 1.0, 25.0);
if fade_per_cycle <= 0.0 || self.nominal_capacity_ah <= 0.0 {
return f64::INFINITY;
}
let fade_per_cycle_frac = fade_per_cycle / self.nominal_capacity_ah;
((soh - 0.80) / fade_per_cycle_frac).max(0.0)
}
pub fn estimate_remaining_life_days(&self, daily_cycles: f64) -> f64 {
let soh = self.compute_soh();
if soh <= 0.80 {
return 0.0;
}
let rul_cyc = self.compute_rul_cycles();
let daily = daily_cycles.max(1e-9);
rul_cyc / daily
}
pub fn compute_capacity_fade(&self) -> CapacityFade {
let nom = self.nominal_capacity_ah.max(1e-12);
let total_loss_pct = 100.0 * (1.0 - self.current_capacity_ah / nom).max(0.0);
let calendar_loss_pct = 100.0 * self.cumulative_calendar_loss / nom;
let cycle_loss_pct = 100.0 * self.cumulative_cycle_loss / nom;
let sei_loss_pct = 100.0 * self.cumulative_sei_loss / nom;
let plating_loss_pct = 100.0 * self.cumulative_plating_loss / nom;
let mechanical_loss_pct = 100.0 * self.cumulative_mechanical_loss / nom;
let rul_cycles = self.compute_rul_cycles();
let daily_ref = 0.5_f64; let rul_days = self.estimate_remaining_life_days(daily_ref);
CapacityFade {
relative_capacity: self.compute_soh(),
calendar_loss_pct,
cycle_loss_pct,
sei_loss_pct,
plating_loss_pct,
mechanical_loss_pct,
total_loss_pct,
rul_cycles,
rul_days,
}
}
pub fn compute_resistance_growth(&self) -> ResistanceGrowth {
let r0 = self.initial_resistance_ohm;
let r_total = self.compute_resistance_ohm_internal();
let relative = if r0 > 0.0 { r_total / r0 } else { 1.0 };
let r_sei = r0 * 0.001 * self.total_cycles.sqrt();
let r_contact = r0 * 0.0005 * self.calendar_age_days.sqrt();
ResistanceGrowth {
relative_resistance: relative,
sei_resistance_ohm: r_sei,
contact_resistance_ohm: r_contact,
total_resistance_ohm: r_total,
}
}
pub fn reset(&mut self) {
self.current_capacity_ah = self.nominal_capacity_ah;
self.current_resistance_ohm = self.initial_resistance_ohm;
self.total_cycles = 0.0;
self.calendar_age_days = 0.0;
self.cumulative_sei_loss = 0.0;
self.cumulative_plating_loss = 0.0;
self.cumulative_mechanical_loss = 0.0;
self.cumulative_calendar_loss = 0.0;
self.cumulative_cycle_loss = 0.0;
self.temperature_history.clear();
}
fn compute_resistance_ohm_internal(&self) -> f64 {
let r0 = self.initial_resistance_ohm;
r0 * (1.0 + 0.002 * self.total_cycles + 0.001 * self.calendar_age_days.sqrt())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_conditions() -> AgingConditions {
AgingConditions {
temperature_c: 25.0,
soc_pct: 50.0,
c_rate: 1.0,
depth_of_discharge_pct: 80.0,
cycle_count: 0.0,
calendar_days: 0.0,
}
}
#[test]
fn test_new_battery_soh_unity() {
let model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let soh = model.compute_soh();
assert!(
(soh - 1.0).abs() < 1e-9,
"New battery SoH should be 1.0, got {soh}"
);
}
#[test]
fn test_calendar_aging_positive() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let initial_cap = model.current_capacity_ah;
let cond = default_conditions();
model.step_calendar(365.0, &cond);
assert!(
model.current_capacity_ah < initial_cap,
"Capacity should decrease after 1 year of calendar aging"
);
}
#[test]
fn test_calendar_aging_monotone() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let cond = default_conditions();
model.step_calendar(365.0, &cond);
let cap_1yr = model.current_capacity_ah;
model.step_calendar(365.0, &cond);
let cap_2yr = model.current_capacity_ah;
assert!(
cap_2yr < cap_1yr,
"More days should give more capacity loss: {cap_1yr} vs {cap_2yr}"
);
}
#[test]
fn test_temperature_effect_calendar() {
let mut hot = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let mut cold = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let mut hot_cond = default_conditions();
hot_cond.temperature_c = 45.0;
let mut cold_cond = default_conditions();
cold_cond.temperature_c = 10.0;
hot.step_calendar(365.0, &hot_cond);
cold.step_calendar(365.0, &cold_cond);
assert!(
hot.current_capacity_ah < cold.current_capacity_ah,
"Higher temperature should cause more capacity loss"
);
}
#[test]
fn test_soc_effect_calendar() {
let mut high_soc = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let mut low_soc = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let mut high_cond = default_conditions();
high_cond.soc_pct = 95.0;
let mut low_cond = default_conditions();
low_cond.soc_pct = 20.0;
high_soc.step_calendar(365.0, &high_cond);
low_soc.step_calendar(365.0, &low_cond);
assert!(
high_soc.current_capacity_ah < low_soc.current_capacity_ah,
"High SoC should cause faster calendar aging"
);
}
#[test]
fn test_cycle_aging_positive() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let initial_cap = model.current_capacity_ah;
let cond = default_conditions();
model.step_cycle(80.0, &cond);
assert!(
model.current_capacity_ah < initial_cap,
"Capacity should decrease after cycling"
);
}
#[test]
fn test_cycle_aging_dod_effect() {
let mut deep = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let mut shallow = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let cond = default_conditions();
for _ in 0..100 {
deep.step_cycle(90.0, &cond);
shallow.step_cycle(20.0, &cond);
}
assert!(
deep.current_capacity_ah < shallow.current_capacity_ah,
"Deeper DoD should cause more degradation"
);
}
#[test]
fn test_cycle_aging_crate_effect() {
let mut fast = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let mut slow = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let mut fast_cond = default_conditions();
fast_cond.c_rate = 3.0;
let mut slow_cond = default_conditions();
slow_cond.c_rate = 0.2;
for _ in 0..100 {
fast.step_cycle(80.0, &fast_cond);
slow.step_cycle(80.0, &slow_cond);
}
assert!(
fast.current_capacity_ah < slow.current_capacity_ah,
"Higher C-rate should cause more degradation"
);
}
#[test]
fn test_sei_growth_rate_arrhenius() {
let hot = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let cold = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let rate_hot = hot.compute_sei_growth_rate(45.0, 50.0);
let rate_cold = cold.compute_sei_growth_rate(10.0, 50.0);
assert!(
rate_hot > rate_cold,
"Higher temperature should yield higher SEI growth rate: hot={rate_hot:.4e}, cold={rate_cold:.4e}"
);
}
#[test]
fn test_resistance_growth_with_cycles() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let initial_r = model.current_resistance_ohm;
let cond = default_conditions();
for _ in 0..500 {
model.step_cycle(80.0, &cond);
}
assert!(
model.current_resistance_ohm > initial_r,
"Resistance should grow with cycling: initial={initial_r:.4}, current={:.4}",
model.current_resistance_ohm
);
}
#[test]
fn test_resistance_growth_with_calendar() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let initial_r = model.current_resistance_ohm;
let cond = default_conditions();
model.step_calendar(365.0 * 3.0, &cond); assert!(
model.current_resistance_ohm > initial_r,
"Resistance should grow with calendar age"
);
}
#[test]
fn test_soh_after_1000_cycles() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let cond = default_conditions();
for _ in 0..1000 {
model.step_cycle(80.0, &cond);
}
let soh = model.compute_soh();
assert!(
soh > 0.5 && soh <= 1.0,
"SoH after 1000 cycles at 80% DoD should be in (0.5, 1.0], got {soh:.4}"
);
}
#[test]
fn test_soh_after_calendar_5years() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let cond = AgingConditions {
temperature_c: 25.0,
soc_pct: 50.0,
..Default::default()
};
model.step_calendar(365.0 * 5.0, &cond);
let soh = model.compute_soh();
assert!(
soh > 0.5 && soh <= 1.0,
"SoH after 5 years calendar should be in (0.5, 1.0], got {soh:.4}"
);
}
#[test]
fn test_rul_decreases_with_aging() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let rul_initial = model.compute_rul_cycles();
let cond = default_conditions();
for _ in 0..200 {
model.step_cycle(80.0, &cond);
}
let rul_after = model.compute_rul_cycles();
assert!(
rul_after < rul_initial,
"RUL should decrease with aging: initial={rul_initial:.1}, after={rul_after:.1}"
);
}
#[test]
fn test_rul_positive_for_new_battery() {
let model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let rul = model.compute_rul_cycles();
assert!(rul > 0.0, "New battery should have positive RUL, got {rul}");
}
#[test]
fn test_capacity_fade_breakdown_sums() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let cond = default_conditions();
model.step_calendar(180.0, &cond);
for _ in 0..200 {
model.step_cycle(80.0, &cond);
}
let fade = model.compute_capacity_fade();
let sum = fade.calendar_loss_pct + fade.cycle_loss_pct;
let diff = (sum - fade.total_loss_pct).abs();
assert!(
diff < fade.total_loss_pct * 0.15 + 0.01,
"calendar({:.3}%) + cycle({:.3}%) ≈ total({:.3}%), diff={diff:.3}",
fade.calendar_loss_pct,
fade.cycle_loss_pct,
fade.total_loss_pct
);
}
#[test]
fn test_remaining_life_days_reasonable() {
let model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let days = model.estimate_remaining_life_days(1.0);
assert!(
days > 0.0,
"Remaining life should be positive for new battery, got {days}"
);
}
#[test]
fn test_lto_less_degradation_than_nmc() {
let mut nmc = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let mut lto = DegradationModel::new(DegradationChemistry::NmcLto, 50.0, 2.3);
let cond = default_conditions();
for _ in 0..500 {
nmc.step_cycle(80.0, &cond);
lto.step_cycle(80.0, &cond);
}
nmc.step_calendar(365.0, &cond);
lto.step_calendar(365.0, &cond);
assert!(
lto.compute_soh() > nmc.compute_soh(),
"LTO should have higher SoH than NMC after same cycling: LTO={:.4}, NMC={:.4}",
lto.compute_soh(),
nmc.compute_soh()
);
}
#[test]
fn test_solid_state_minimal_degradation() {
let mut ss = DegradationModel::new(DegradationChemistry::SolidState, 50.0, 3.8);
let mut nmc = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let cond = default_conditions();
for _ in 0..1000 {
ss.step_cycle(80.0, &cond);
nmc.step_cycle(80.0, &cond);
}
ss.step_calendar(365.0, &cond);
nmc.step_calendar(365.0, &cond);
assert!(
ss.compute_soh() > nmc.compute_soh(),
"SolidState should age slower than NMC: SS={:.4}, NMC={:.4}",
ss.compute_soh(),
nmc.compute_soh()
);
}
#[test]
fn test_reset_restores_new_condition() {
let mut model = DegradationModel::new(DegradationChemistry::NmcGraphite, 50.0, 3.7);
let cond = default_conditions();
for _ in 0..300 {
model.step_cycle(80.0, &cond);
}
model.step_calendar(200.0, &cond);
assert!(
model.compute_soh() < 1.0,
"Model should be degraded before reset"
);
model.reset();
let soh = model.compute_soh();
assert!(
(soh - 1.0).abs() < 1e-9,
"SoH should be 1.0 after reset, got {soh}"
);
assert_eq!(model.total_cycles, 0.0);
assert_eq!(model.calendar_age_days, 0.0);
assert!(model.temperature_history.is_empty());
}
#[test]
fn test_cycle_counter_empty_initially() {
let counter = CycleCounterDoD::new();
assert_eq!(counter.count_cycles(), 0.0);
}
#[test]
fn test_cycle_counter_detects_full_cycle() {
let mut counter = CycleCounterDoD::new();
counter.add_soc_point(100.0);
counter.add_soc_point(50.0);
counter.add_soc_point(0.0);
counter.add_soc_point(50.0);
counter.add_soc_point(100.0);
assert!(
counter.count_cycles() >= 1.0,
"Should detect at least one cycle, got {}",
counter.count_cycles()
);
}
#[test]
fn test_resistance_growth_struct_relative() {
let mut model = DegradationModel::new(DegradationChemistry::LfpGraphite, 75.0, 3.2);
let cond = AgingConditions {
temperature_c: 25.0,
c_rate: 0.5,
..Default::default()
};
for _ in 0..200 {
model.step_cycle(60.0, &cond);
}
let rg = model.compute_resistance_growth();
assert!(
rg.relative_resistance > 1.0,
"Relative resistance should exceed 1.0 after cycling, got {:.4}",
rg.relative_resistance
);
assert!(rg.total_resistance_ohm > model.initial_resistance_ohm);
}
}