use crate::battery::marketplace::BatteryChemistry;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub enum SohError {
InvalidConfig(String),
InvalidUsagePattern(String),
}
impl fmt::Display for SohError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(m) => write!(f, "invalid config: {m}"),
Self::InvalidUsagePattern(m) => write!(f, "invalid usage pattern: {m}"),
}
}
}
impl std::error::Error for SohError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChargeStrategy {
Cc,
CcCv,
FastCharge,
Trickle,
}
impl ChargeStrategy {
pub fn degradation_multiplier(self) -> f64 {
match self {
Self::Cc => 1.10,
Self::CcCv => 1.00,
Self::FastCharge => 1.35,
Self::Trickle => 0.80,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsagePattern {
pub avg_dod_pct: f64,
pub avg_c_rate: f64,
pub avg_temp_c: f64,
pub charge_strategy: ChargeStrategy,
pub cycles_per_day: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SohPredictionConfig {
pub chemistry: BatteryChemistry,
pub nominal_capacity_kwh: f64,
pub nominal_voltage_v: f64,
pub end_of_life_soh: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SohForecast {
pub current_soh_pct: f64,
pub soh_at_1year_pct: f64,
pub soh_at_5years_pct: f64,
pub soh_at_10years_pct: f64,
pub predicted_eol_cycles: f64,
pub predicted_eol_calendar_years: f64,
pub remaining_useful_life_years: f64,
pub confidence_interval_pct: f64,
pub degradation_rate_per_cycle: f64,
pub degradation_rate_per_year: f64,
}
struct ChemistryParams {
b_cycle: f64,
#[allow(dead_code)]
ea_over_r_cycle: f64,
z_crate: f64,
beta: f64,
a_cal: f64,
ea_over_r_cal: f64,
alpha_dod: f64,
}
impl ChemistryParams {
fn for_chemistry(chem: BatteryChemistry) -> Self {
match chem {
BatteryChemistry::LfpLithiumIron => Self {
b_cycle: 5.5e-5,
ea_over_r_cycle: 0.0,
z_crate: 0.55,
beta: 0.56,
a_cal: 5.16e-2,
ea_over_r_cal: 4000.0,
alpha_dod: 1.50,
},
BatteryChemistry::NmcLithiumIon => Self {
b_cycle: 9.5e-5,
ea_over_r_cycle: 0.0,
z_crate: 0.60,
beta: 0.58,
a_cal: 6.32e-2,
ea_over_r_cal: 4500.0,
alpha_dod: 1.50,
},
BatteryChemistry::NcaLithiumNickel => Self {
b_cycle: 1.15e-4,
ea_over_r_cycle: 0.0,
z_crate: 0.65,
beta: 0.60,
a_cal: 7.07e-2,
ea_over_r_cal: 4800.0,
alpha_dod: 1.60,
},
BatteryChemistry::LtoLithiumTitanate => Self {
b_cycle: 1.15e-5,
ea_over_r_cycle: 0.0,
z_crate: 0.40,
beta: 0.50,
a_cal: 4.00e-2,
ea_over_r_cal: 5000.0,
alpha_dod: 1.30,
},
BatteryChemistry::LeadAcid => Self {
b_cycle: 3.5e-4,
ea_over_r_cycle: 0.0,
z_crate: 0.70,
beta: 0.65,
a_cal: 8.94e-2,
ea_over_r_cal: 3000.0,
alpha_dod: 2.00,
},
BatteryChemistry::SodiumIon => Self {
b_cycle: 7.5e-5,
ea_over_r_cycle: 0.0,
z_crate: 0.58,
beta: 0.57,
a_cal: 5.77e-2,
ea_over_r_cal: 4200.0,
alpha_dod: 1.50,
},
}
}
}
pub struct SohPredictor {
config: SohPredictionConfig,
params: ChemistryParams,
}
impl SohPredictor {
pub fn new(config: SohPredictionConfig) -> Self {
let params = ChemistryParams::for_chemistry(config.chemistry);
Self { config, params }
}
pub fn predict(
&self,
current_cycles: f64,
current_calendar_years: f64,
usage: &UsagePattern,
) -> Result<SohForecast, SohError> {
if usage.avg_dod_pct < 0.0 || usage.avg_dod_pct > 100.0 {
return Err(SohError::InvalidUsagePattern(
"avg_dod_pct must be in [0, 100]".to_string(),
));
}
if usage.avg_c_rate <= 0.0 {
return Err(SohError::InvalidUsagePattern(
"avg_c_rate must be > 0".to_string(),
));
}
if usage.cycles_per_day < 0.0 {
return Err(SohError::InvalidUsagePattern(
"cycles_per_day must be ≥ 0".to_string(),
));
}
let current_soh = self.combined_soh(current_cycles, current_calendar_years, usage);
let cycles_per_year = usage.cycles_per_day * 365.25;
let soh_1yr = self.combined_soh(
current_cycles + cycles_per_year,
current_calendar_years + 1.0,
usage,
);
let soh_5yr = self.combined_soh(
current_cycles + 5.0 * cycles_per_year,
current_calendar_years + 5.0,
usage,
);
let soh_10yr = self.combined_soh(
current_cycles + 10.0 * cycles_per_year,
current_calendar_years + 10.0,
usage,
);
let eol_soh = self.config.end_of_life_soh;
let eol_cycles = self.find_eol_cycles(current_calendar_years, usage, eol_soh);
let eol_years = self.find_eol_years(current_cycles, usage, eol_soh);
let remaining_cycles = (eol_cycles - current_cycles).max(0.0);
let rul_years = remaining_cycles / cycles_per_year.max(1e-12);
let delta_cycles = 100.0_f64;
let soh_now_cycle = self.cycle_soh(current_cycles, usage);
let soh_fut_cycle = self.cycle_soh(current_cycles + delta_cycles, usage);
let degradation_per_cycle =
((soh_now_cycle - soh_fut_cycle) / delta_cycles * 100.0).max(0.0);
let soh_now_cal = self.calendar_soh(current_calendar_years, usage);
let soh_fut_cal = self.calendar_soh(current_calendar_years + 1.0, usage);
let degradation_per_year = ((soh_now_cal - soh_fut_cal) * 100.0).max(0.0);
let confidence = 10.0 + (usage.avg_dod_pct / 100.0) * 5.0;
Ok(SohForecast {
current_soh_pct: current_soh * 100.0,
soh_at_1year_pct: soh_1yr * 100.0,
soh_at_5years_pct: soh_5yr * 100.0,
soh_at_10years_pct: soh_10yr * 100.0,
predicted_eol_cycles: eol_cycles,
predicted_eol_calendar_years: eol_years,
remaining_useful_life_years: rul_years,
confidence_interval_pct: confidence,
degradation_rate_per_cycle: degradation_per_cycle,
degradation_rate_per_year: degradation_per_year,
})
}
fn combined_soh(&self, cycles: f64, years: f64, usage: &UsagePattern) -> f64 {
let soh_c = self.cycle_soh(cycles, usage);
let soh_k = self.calendar_soh(years, usage);
soh_c.min(soh_k).max(0.0)
}
fn cycle_soh(&self, cycles: f64, usage: &UsagePattern) -> f64 {
let p = &self.params;
let t_ref = 298.15_f64;
let t_op = (273.15 + usage.avg_temp_c).max(200.0);
let t_factor = (2000.0_f64 * (1.0 / t_ref - 1.0 / t_op)).exp().max(0.5);
let dod = (usage.avg_dod_pct / 100.0).clamp(0.0, 1.0);
let n_eff = cycles * dod.powf(p.alpha_dod);
let c_rate_term = usage.avg_c_rate.max(1e-6).powf(p.z_crate);
let strat = usage.charge_strategy.degradation_multiplier();
let fade = p.b_cycle * t_factor * (n_eff * c_rate_term).powf(p.beta) * strat;
(1.0 - fade).max(0.0)
}
fn calendar_soh(&self, years: f64, usage: &UsagePattern) -> f64 {
let p = &self.params;
let arrh = self.arrhenius_factor(usage.avg_temp_c);
let fade = p.a_cal * arrh * years.max(0.0).sqrt();
(1.0 - fade).max(0.0)
}
pub fn arrhenius_factor(&self, temp_c: f64) -> f64 {
let t_ref = 298.15_f64;
let t_op = (273.15 + temp_c).max(200.0);
let ea_r = self.params.ea_over_r_cal;
(ea_r * (1.0 / t_ref - 1.0 / t_op)).exp()
}
fn find_eol_cycles(&self, current_years: f64, usage: &UsagePattern, eol_threshold: f64) -> f64 {
let mut lo = 0.0_f64;
let mut hi = 1_000_000.0_f64;
for _ in 0..60 {
let mid = (lo + hi) / 2.0;
let soh = self.combined_soh(mid, current_years, usage);
if soh > eol_threshold {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
}
fn find_eol_years(&self, current_cycles: f64, usage: &UsagePattern, eol_threshold: f64) -> f64 {
let mut lo = 0.0_f64;
let mut hi = 200.0_f64;
let cpd = usage.cycles_per_day.max(1e-6);
for _ in 0..60 {
let mid = (lo + hi) / 2.0;
let cycles = current_cycles + mid * 365.25 * cpd;
let soh = self.combined_soh(cycles, mid, usage);
if soh > eol_threshold {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lfp_config() -> SohPredictionConfig {
SohPredictionConfig {
chemistry: BatteryChemistry::LfpLithiumIron,
nominal_capacity_kwh: 100.0,
nominal_voltage_v: 51.2,
end_of_life_soh: 0.80,
}
}
fn low_stress() -> UsagePattern {
UsagePattern {
avg_dod_pct: 30.0,
avg_c_rate: 0.5,
avg_temp_c: 25.0,
charge_strategy: ChargeStrategy::CcCv,
cycles_per_day: 1.0,
}
}
fn high_stress() -> UsagePattern {
UsagePattern {
avg_dod_pct: 90.0,
avg_c_rate: 2.0,
avg_temp_c: 45.0,
charge_strategy: ChargeStrategy::FastCharge,
cycles_per_day: 2.0,
}
}
#[test]
fn test_low_stress_longer_life_than_high_stress() {
let pred = SohPredictor::new(lfp_config());
let fc_low = pred.predict(0.0, 0.0, &low_stress()).unwrap();
let fc_high = pred.predict(0.0, 0.0, &high_stress()).unwrap();
assert!(
fc_low.predicted_eol_cycles > fc_high.predicted_eol_cycles,
"Low-stress EOL cycles ({:.0}) must exceed high-stress ({:.0})",
fc_low.predicted_eol_cycles,
fc_high.predicted_eol_cycles,
);
assert!(
fc_low.remaining_useful_life_years > fc_high.remaining_useful_life_years,
"Low-stress RUL ({:.1} yr) must exceed high-stress ({:.1} yr)",
fc_low.remaining_useful_life_years,
fc_high.remaining_useful_life_years,
);
}
#[test]
fn test_high_temperature_accelerates_degradation() {
let pred_hot = SohPredictor::new(lfp_config());
let pred_cold = SohPredictor::new(lfp_config());
let hot_usage = UsagePattern {
avg_temp_c: 50.0,
..low_stress()
};
let cold_usage = UsagePattern {
avg_temp_c: 15.0,
..low_stress()
};
let fc_hot = pred_hot.predict(0.0, 0.0, &hot_usage).unwrap();
let fc_cold = pred_cold.predict(0.0, 0.0, &cold_usage).unwrap();
assert!(
fc_hot.predicted_eol_calendar_years < fc_cold.predicted_eol_calendar_years,
"High temperature ({:.0} °C) should give shorter EOL ({:.1} yr) than low ({:.1} yr)",
50.0,
fc_hot.predicted_eol_calendar_years,
fc_cold.predicted_eol_calendar_years,
);
}
#[test]
fn test_eol_correctly_identified_at_80_pct() {
let config = SohPredictionConfig {
chemistry: BatteryChemistry::LfpLithiumIron,
nominal_capacity_kwh: 100.0,
nominal_voltage_v: 51.2,
end_of_life_soh: 0.80,
};
let pred = SohPredictor::new(config);
let fc = pred.predict(0.0, 0.0, &high_stress()).unwrap();
let params = ChemistryParams::for_chemistry(BatteryChemistry::LfpLithiumIron);
let predictor_inner = SohPredictor {
config: lfp_config(),
params,
};
let soh_at_eol = predictor_inner.combined_soh(fc.predicted_eol_cycles, 0.0, &high_stress());
assert!(
(soh_at_eol - 0.80).abs() < 0.02,
"SoH at EOL should be ≈80 %, got {:.3}",
soh_at_eol * 100.0
);
}
#[test]
fn test_calendar_and_cycle_both_contribute() {
let pred = SohPredictor::new(lfp_config());
let usage = UsagePattern {
avg_dod_pct: 60.0,
avg_c_rate: 1.0,
avg_temp_c: 40.0,
charge_strategy: ChargeStrategy::CcCv,
cycles_per_day: 1.0,
};
let fc = pred.predict(500.0, 2.0, &usage).unwrap();
assert!(
fc.degradation_rate_per_cycle > 0.0,
"cycle degradation rate must be positive, got {}",
fc.degradation_rate_per_cycle
);
assert!(
fc.degradation_rate_per_year > 0.0,
"calendar degradation rate must be positive, got {}",
fc.degradation_rate_per_year
);
}
#[test]
fn test_confidence_interval_in_range() {
let pred = SohPredictor::new(lfp_config());
let fc = pred.predict(0.0, 0.0, &low_stress()).unwrap();
assert!(
fc.confidence_interval_pct >= 5.0 && fc.confidence_interval_pct <= 20.0,
"confidence interval ({:.1} %) should be in [5, 20] %",
fc.confidence_interval_pct
);
}
#[test]
fn test_nmc_vs_lto_cycle_life() {
let nmc_config = SohPredictionConfig {
chemistry: BatteryChemistry::NmcLithiumIon,
nominal_capacity_kwh: 50.0,
nominal_voltage_v: 48.0,
end_of_life_soh: 0.80,
};
let lto_config = SohPredictionConfig {
chemistry: BatteryChemistry::LtoLithiumTitanate,
nominal_capacity_kwh: 50.0,
nominal_voltage_v: 48.0,
end_of_life_soh: 0.80,
};
let usage = UsagePattern {
avg_dod_pct: 80.0,
avg_c_rate: 1.0,
avg_temp_c: 25.0,
charge_strategy: ChargeStrategy::CcCv,
cycles_per_day: 1.0,
};
let nmc_fc = SohPredictor::new(nmc_config)
.predict(0.0, 0.0, &usage)
.unwrap();
let lto_fc = SohPredictor::new(lto_config)
.predict(0.0, 0.0, &usage)
.unwrap();
assert!(
lto_fc.predicted_eol_cycles > nmc_fc.predicted_eol_cycles,
"LTO ({:.0} cycles) should outlast NMC ({:.0} cycles)",
lto_fc.predicted_eol_cycles,
nmc_fc.predicted_eol_cycles,
);
}
#[test]
fn test_arrhenius_factor_gt1_above_25c() {
let pred = SohPredictor::new(lfp_config());
let factor_hot = pred.arrhenius_factor(40.0);
let factor_ref = pred.arrhenius_factor(25.0);
let factor_cold = pred.arrhenius_factor(10.0);
assert!(
(factor_ref - 1.0).abs() < 1e-6,
"Arrhenius factor at 25 °C should be 1.0, got {factor_ref}"
);
assert!(factor_hot > 1.0, "Factor at 40 °C should be > 1");
assert!(factor_cold < 1.0, "Factor at 10 °C should be < 1");
}
#[test]
fn test_invalid_dod_rejected() {
let pred = SohPredictor::new(lfp_config());
let bad = UsagePattern {
avg_dod_pct: 150.0, ..low_stress()
};
assert!(pred.predict(0.0, 0.0, &bad).is_err());
}
}