use crate::core::scalar::ControlScalar;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BatteryError {
InvalidParameter,
Overcharged,
Overdischarged,
}
impl core::fmt::Display for BatteryError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
BatteryError::InvalidParameter => write!(f, "BatteryError: invalid parameter"),
BatteryError::Overcharged => write!(f, "BatteryError: overcharged (SOC > 1)"),
BatteryError::Overdischarged => write!(f, "BatteryError: overdischarged (SOC < 0)"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TheveninBattery<S: ControlScalar> {
state: [S; 2],
q_nom: S,
r0: S,
r1: S,
c1: S,
v_min: S,
v_max: S,
dt: S,
}
impl<S: ControlScalar> TheveninBattery<S> {
#[allow(clippy::too_many_arguments)]
pub fn new(
q_nom: S,
r0: S,
r1: S,
c1: S,
v_min: S,
v_max: S,
soc_init: S,
dt: S,
) -> Result<Self, BatteryError> {
if q_nom <= S::ZERO || r0 <= S::ZERO || r1 <= S::ZERO || c1 <= S::ZERO || dt <= S::ZERO {
return Err(BatteryError::InvalidParameter);
}
if v_min >= v_max {
return Err(BatteryError::InvalidParameter);
}
if soc_init < S::ZERO || soc_init > S::ONE {
return Err(BatteryError::InvalidParameter);
}
Ok(Self {
state: [soc_init, S::ZERO],
q_nom,
r0,
r1,
c1,
v_min,
v_max,
dt,
})
}
pub fn default_18650() -> Result<Self, BatteryError> {
Self::new(
S::from_f64(2.5),
S::from_f64(0.05),
S::from_f64(0.02),
S::from_f64(2000.0),
S::from_f64(3.0),
S::from_f64(4.2),
S::ONE,
S::from_f64(1e-3),
)
}
pub fn step(&mut self, current: S) -> Result<S, BatteryError> {
let soc = self.state[0];
let v_rc = self.state[1];
let v_oc = self.v_min + (self.v_max - self.v_min) * soc;
let v_term = v_oc - self.r0 * current - v_rc;
let soc_dot = -current / (S::from_f64(3600.0) * self.q_nom);
let tau_rc = self.r1 * self.c1;
let v_rc_dot = if tau_rc.abs() > S::EPSILON {
-v_rc / tau_rc + current / self.c1
} else {
current / self.c1
};
self.state[0] = soc + soc_dot * self.dt;
self.state[1] = v_rc + v_rc_dot * self.dt;
if self.state[0] > S::ONE {
return Err(BatteryError::Overcharged);
}
if self.state[0] < S::ZERO {
return Err(BatteryError::Overdischarged);
}
Ok(v_term)
}
#[inline]
pub fn soc(&self) -> S {
self.state[0]
}
#[inline]
pub fn v_rc(&self) -> S {
self.state[1]
}
#[inline]
pub fn open_circuit_voltage(&self) -> S {
self.v_min + (self.v_max - self.v_min) * self.state[0]
}
#[inline]
pub fn terminal_voltage(&self) -> S {
self.open_circuit_voltage() - self.state[1]
}
#[inline]
pub fn state_of_health(&self) -> S {
S::ONE
}
pub fn reset(&mut self, soc_init: S) -> Result<(), BatteryError> {
if soc_init < S::ZERO || soc_init > S::ONE {
return Err(BatteryError::InvalidParameter);
}
self.state = [soc_init, S::ZERO];
Ok(())
}
#[inline]
pub fn q_nom(&self) -> S {
self.q_nom
}
#[inline]
pub fn r0(&self) -> S {
self.r0
}
pub fn remaining_energy_joules(&self) -> S {
let soc = self.state[0];
let v_avg = self.v_min + (self.v_max - self.v_min) * soc * S::HALF;
self.q_nom * S::from_f64(3600.0) * v_avg * soc
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_battery(soc_init: f64) -> TheveninBattery<f64> {
TheveninBattery::new(2.5, 0.05, 0.02, 2000.0, 3.0, 4.2, soc_init, 1e-3).unwrap()
}
#[test]
fn zero_current_constant_soc() {
let mut bat = make_battery(0.8);
let initial_soc = bat.soc();
for _ in 0..100 {
let v = bat.step(0.0).expect("step should not fail at zero current");
assert!(
(bat.soc() - initial_soc).abs() < 1e-12,
"SOC changed: {} vs {}",
bat.soc(),
initial_soc
);
let v_oc = bat.open_circuit_voltage();
assert!(
(v - v_oc).abs() < 1e-10,
"V_term={v} should equal V_oc={v_oc} at zero current"
);
}
}
#[test]
fn discharge_reduces_soc() {
let mut bat = make_battery(1.0);
for _ in 0..360 {
bat.step(1.0).expect("discharge step failed");
}
assert!(
bat.soc() < 1.0,
"SOC should decrease during discharge, got {}",
bat.soc()
);
}
#[test]
fn rc_dynamics_decay_toward_zero() {
let mut bat = make_battery(0.9);
for _ in 0..200 {
bat.step(5.0).expect("discharge step");
}
let v_rc_charged = bat.v_rc().abs();
assert!(
v_rc_charged > 1e-6,
"v_rc should be non-zero after discharge"
);
for _ in 0..40_000 {
bat.step(0.0).expect("idle step");
}
let v_rc_after = bat.v_rc().abs();
assert!(
v_rc_after < v_rc_charged * 0.5,
"v_rc={v_rc_after:.6} should be < 50% of {v_rc_charged:.6} after 1 time constant"
);
}
#[test]
fn terminal_voltage_drops_with_load() {
let mut bat_idle = make_battery(0.8);
let mut bat_load = make_battery(0.8);
let v_idle = bat_idle.step(0.0).expect("idle step");
let v_load = bat_load.step(2.0).expect("load step");
assert!(
v_load < v_idle,
"Terminal voltage under load ({v_load:.4}) should be less than at no load ({v_idle:.4})"
);
}
#[test]
fn over_discharge_detected() {
let mut bat = TheveninBattery::<f64>::new(
0.001, 0.05, 0.02, 2000.0, 3.0, 4.2, 0.01, 0.1, )
.expect("battery construction");
let mut got_error = false;
for _ in 0..10_000 {
match bat.step(100.0) {
Err(BatteryError::Overdischarged) => {
got_error = true;
break;
}
Err(e) => panic!("Unexpected error: {e:?}"),
Ok(_) => {}
}
}
assert!(got_error, "Expected Overdischarged error but none occurred");
}
#[test]
fn invalid_params_rejected() {
let res = TheveninBattery::<f64>::new(2.5, 0.0, 0.02, 2000.0, 3.0, 4.2, 1.0, 1e-3);
assert!(
matches!(res, Err(BatteryError::InvalidParameter)),
"Expected InvalidParameter for r0=0"
);
let res = TheveninBattery::<f64>::new(-1.0, 0.05, 0.02, 2000.0, 3.0, 4.2, 1.0, 1e-3);
assert!(
matches!(res, Err(BatteryError::InvalidParameter)),
"Expected InvalidParameter for q_nom<0"
);
let res = TheveninBattery::<f64>::new(2.5, 0.05, 0.02, 2000.0, 4.2, 3.0, 1.0, 1e-3);
assert!(
matches!(res, Err(BatteryError::InvalidParameter)),
"Expected InvalidParameter for v_min >= v_max"
);
let res = TheveninBattery::<f64>::new(2.5, 0.05, 0.02, 2000.0, 3.0, 4.2, 1.5, 1e-3);
assert!(
matches!(res, Err(BatteryError::InvalidParameter)),
"Expected InvalidParameter for soc_init > 1"
);
let res = TheveninBattery::<f64>::new(2.5, 0.05, 0.02, 2000.0, 3.0, 4.2, 1.0, 0.0);
assert!(
matches!(res, Err(BatteryError::InvalidParameter)),
"Expected InvalidParameter for dt=0"
);
}
#[test]
fn reset_clears_state() {
let mut bat = make_battery(1.0);
for _ in 0..1000 {
if bat.step(1.0).is_err() {
break;
}
}
let soc_after_discharge = bat.soc();
assert!(soc_after_discharge < 1.0);
bat.reset(0.5).expect("reset should succeed");
assert!(
(bat.soc() - 0.5).abs() < 1e-12,
"SOC after reset should be 0.5, got {}",
bat.soc()
);
assert!(
bat.v_rc().abs() < 1e-12,
"v_rc should be 0 after reset, got {}",
bat.v_rc()
);
}
#[test]
fn default_18650_constructs() {
let bat = TheveninBattery::<f64>::default_18650().expect("default_18650 should succeed");
assert!((bat.soc() - 1.0).abs() < 1e-12);
assert_eq!(bat.state_of_health(), 1.0);
}
#[test]
fn ocv_linear_interpolation() {
let bat = TheveninBattery::<f64>::new(2.5, 0.05, 0.02, 2000.0, 3.0, 4.2, 0.5, 1e-3)
.expect("construct");
let v_oc = bat.open_circuit_voltage();
assert!(
(v_oc - 3.6).abs() < 1e-10,
"OCV at SOC=0.5 should be 3.6 V, got {v_oc}"
);
}
}