use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq)]
pub enum StateEstimationError {
EmptyOcvTable,
SingularMatrix,
ParameterOutOfBounds(String),
}
impl core::fmt::Display for StateEstimationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::EmptyOcvTable => write!(f, "OCV-SoC table is empty or too small"),
Self::SingularMatrix => write!(f, "Matrix inversion failed: singular"),
Self::ParameterOutOfBounds(msg) => write!(f, "Parameter out of bounds: {msg}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BatteryChemistryType {
Lfp,
Nmc,
Nca,
Lto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EcmParameters {
pub r0_ohm: f64,
pub r1_ohm: f64,
pub c1_f: f64,
pub r2_ohm: f64,
pub c2_f: f64,
pub ocv_soc_table: Vec<(f64, f64)>,
}
impl EcmParameters {
pub fn validate(&self) -> Result<(), StateEstimationError> {
if self.r0_ohm <= 0.0 || !self.r0_ohm.is_finite() {
return Err(StateEstimationError::ParameterOutOfBounds(
"r0_ohm must be positive and finite".into(),
));
}
if self.r1_ohm <= 0.0 || !self.r1_ohm.is_finite() {
return Err(StateEstimationError::ParameterOutOfBounds(
"r1_ohm must be positive and finite".into(),
));
}
if self.c1_f <= 0.0 || !self.c1_f.is_finite() {
return Err(StateEstimationError::ParameterOutOfBounds(
"c1_f must be positive and finite".into(),
));
}
if self.r2_ohm <= 0.0 || !self.r2_ohm.is_finite() {
return Err(StateEstimationError::ParameterOutOfBounds(
"r2_ohm must be positive and finite".into(),
));
}
if self.c2_f <= 0.0 || !self.c2_f.is_finite() {
return Err(StateEstimationError::ParameterOutOfBounds(
"c2_f must be positive and finite".into(),
));
}
if self.ocv_soc_table.len() < 2 {
return Err(StateEstimationError::EmptyOcvTable);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatteryStateEstimatorConfig {
pub capacity_ah: f64,
pub nominal_voltage_v: f64,
pub dt_s: f64,
pub q_noise: f64,
pub r_noise: f64,
pub initial_soc: f64,
pub initial_soh: f64,
pub chemistry: BatteryChemistryType,
}
impl Default for BatteryStateEstimatorConfig {
fn default() -> Self {
Self {
capacity_ah: 100.0,
nominal_voltage_v: 3.6,
dt_s: 1.0,
q_noise: 1e-6,
r_noise: 1e-4,
initial_soc: 0.8,
initial_soh: 1.0,
chemistry: BatteryChemistryType::Nmc,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackStateEstimate {
pub soc: f64,
pub soh: f64,
pub voltage_model_v: f64,
pub voltage_residual_v: f64,
pub v_rc1_v: f64,
pub v_rc2_v: f64,
pub innovation: f64,
pub covariance_trace: f64,
}
#[derive(Debug, Clone)]
pub struct EkfResult {
pub state: [f64; 3],
pub innovation: f64,
pub kalman_gain: [f64; 3],
pub covariance: [[f64; 3]; 3],
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub valid: bool,
pub soc_in_range: bool,
pub voltage_residual_v: f64,
pub residual_ok: bool,
}
type Mat3 = [[f64; 3]; 3];
#[inline]
fn mat3_identity() -> Mat3 {
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
}
#[inline]
fn mat3_mul(a: &Mat3, b: &Mat3) -> Mat3 {
let mut c = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
for k in 0..3 {
c[i][j] += a[i][k] * b[k][j];
}
}
}
c
}
#[inline]
fn mat3_transpose(a: &Mat3) -> Mat3 {
let mut t = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
t[j][i] = a[i][j];
}
}
t
}
#[inline]
fn mat3_scale(a: &Mat3, s: f64) -> Mat3 {
let mut r = *a;
for row in r.iter_mut() {
for v in row.iter_mut() {
*v *= s;
}
}
r
}
#[inline]
fn mat3_add(a: &Mat3, b: &Mat3) -> Mat3 {
let mut r = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
r[i][j] = a[i][j] + b[i][j];
}
}
r
}
#[inline]
fn mat3_sub(a: &Mat3, b: &Mat3) -> Mat3 {
let mut r = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
r[i][j] = a[i][j] - b[i][j];
}
}
r
}
#[inline]
fn vec3_outer(a: &[f64; 3], b: &[f64; 3]) -> Mat3 {
let mut m = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = a[i] * b[j];
}
}
m
}
#[inline]
fn mat3_vec_mul(a: &Mat3, v: &[f64; 3]) -> [f64; 3] {
let mut r = [0.0_f64; 3];
for i in 0..3 {
for j in 0..3 {
r[i] += a[i][j] * v[j];
}
}
r
}
#[allow(dead_code)]
#[inline]
fn vec3_mat3_mul(h: &[f64; 3], a: &Mat3) -> [f64; 3] {
let mut r = [0.0_f64; 3];
for j in 0..3 {
for i in 0..3 {
r[j] += h[i] * a[i][j];
}
}
r
}
#[inline]
fn vec3_dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
fn mat3_trace(a: &Mat3) -> f64 {
a[0][0] + a[1][1] + a[2][2]
}
#[inline]
fn build_q_matrix(q: f64) -> Mat3 {
[[q, 0.0, 0.0], [0.0, q * 0.1, 0.0], [0.0, 0.0, q * 0.1]]
}
#[derive(Debug, Clone)]
pub struct BatteryPackEstimator {
pub config: BatteryStateEstimatorConfig,
pub ecm_params: EcmParameters,
state: [f64; 3],
covariance: Mat3,
soh: f64,
cycle_count: f64,
q_throughput_ah: f64,
}
impl BatteryPackEstimator {
pub fn new(
config: BatteryStateEstimatorConfig,
ecm_params: EcmParameters,
) -> Result<Self, StateEstimationError> {
ecm_params.validate()?;
let soc0 = config.initial_soc.clamp(0.0, 1.0);
let soh0 = config.initial_soh.clamp(0.0, 1.0);
let p0 = mat3_scale(&mat3_identity(), 0.01);
Ok(Self {
state: [soc0, 0.0, 0.0],
covariance: p0,
soh: soh0,
cycle_count: 0.0,
q_throughput_ah: 0.0,
config,
ecm_params,
})
}
pub fn with_chemistry(
config: BatteryStateEstimatorConfig,
) -> Result<Self, StateEstimationError> {
let ecm_params = Self::default_ecm_for_chemistry(config.chemistry);
Self::new(config, ecm_params)
}
pub fn ocv_at_soc(&self, soc: f64) -> Result<f64, StateEstimationError> {
Self::ocv_from_soc(soc, &self.ecm_params.ocv_soc_table)
}
pub fn ocv_from_soc(soc: f64, table: &[(f64, f64)]) -> Result<f64, StateEstimationError> {
if table.len() < 2 {
return Err(StateEstimationError::EmptyOcvTable);
}
let soc = soc.clamp(0.0, 1.0);
if soc <= table[0].0 {
return Ok(table[0].1);
}
if soc >= table[table.len() - 1].0 {
return Ok(table[table.len() - 1].1);
}
let pos = table.partition_point(|&(s, _)| s <= soc);
let (s0, v0) = table[pos - 1];
let (s1, v1) = table[pos];
let alpha = (soc - s0) / (s1 - s0);
Ok(v0 + alpha * (v1 - v0))
}
fn docv_dsoc(&self, soc: f64) -> Result<f64, StateEstimationError> {
let eps = 1e-4_f64;
let soc_c = soc.clamp(eps, 1.0 - eps);
let v_hi = Self::ocv_from_soc(soc_c + eps, &self.ecm_params.ocv_soc_table)?;
let v_lo = Self::ocv_from_soc(soc_c - eps, &self.ecm_params.ocv_soc_table)?;
Ok((v_hi - v_lo) / (2.0 * eps))
}
pub fn ekf_predict(&mut self, current_a: f64, dt_s: f64) -> [f64; 3] {
let cap_ah = self.config.capacity_ah;
let soh = self.soh;
let r1 = self.ecm_params.r1_ohm;
let c1 = self.ecm_params.c1_f;
let r2 = self.ecm_params.r2_ohm;
let c2 = self.ecm_params.c2_f;
let tau1 = r1 * c1;
let tau2 = r2 * c2;
let exp1 = (-dt_s / tau1).exp();
let exp2 = (-dt_s / tau2).exp();
let soc = self.state[0];
let vrc1 = self.state[1];
let vrc2 = self.state[2];
let soc_next = soc - current_a * dt_s / (3600.0 * cap_ah * soh);
let vrc1_next = vrc1 * exp1 + r1 * (1.0 - exp1) * current_a;
let vrc2_next = vrc2 * exp2 + r2 * (1.0 - exp2) * current_a;
self.state = [soc_next, vrc1_next, vrc2_next];
let f: Mat3 = [[1.0, 0.0, 0.0], [0.0, exp1, 0.0], [0.0, 0.0, exp2]];
let q = build_q_matrix(self.config.q_noise);
let ft = mat3_transpose(&f);
let fp = mat3_mul(&f, &self.covariance);
let fpft = mat3_mul(&fp, &ft);
self.covariance = mat3_add(&fpft, &q);
self.state
}
pub fn ekf_update(
&mut self,
measured_voltage_v: f64,
current_a: f64,
) -> Result<EkfResult, StateEstimationError> {
let soc = self.state[0];
let vrc1 = self.state[1];
let vrc2 = self.state[2];
let ocv = Self::ocv_from_soc(soc, &self.ecm_params.ocv_soc_table)?;
let v_model = ocv - current_a * self.ecm_params.r0_ohm - vrc1 - vrc2;
let innovation = measured_voltage_v - v_model;
let docv = self.docv_dsoc(soc)?;
let h: [f64; 3] = [docv, -1.0, -1.0];
let ph = mat3_vec_mul(&self.covariance, &h); let s = vec3_dot(&h, &ph) + self.config.r_noise;
if s.abs() < 1e-15 {
return Err(StateEstimationError::SingularMatrix);
}
let k: [f64; 3] = [ph[0] / s, ph[1] / s, ph[2] / s];
let soc_new = (soc + k[0] * innovation).clamp(0.0, 1.0);
let vrc1_new = vrc1 + k[1] * innovation;
let vrc2_new = vrc2 + k[2] * innovation;
self.state = [soc_new, vrc1_new, vrc2_new];
let kh = vec3_outer(&k, &h);
let i_kh = mat3_sub(&mat3_identity(), &kh);
self.covariance = mat3_mul(&i_kh, &self.covariance);
Ok(EkfResult {
state: self.state,
innovation,
kalman_gain: k,
covariance: self.covariance,
})
}
pub fn estimate_soh(
current_soh: f64,
capacity_fade: f64,
cycle_degradation_per_ah: f64,
) -> f64 {
(current_soh - cycle_degradation_per_ah * capacity_fade.abs()).clamp(0.0, 1.0)
}
pub fn adaptive_parameter_update(
&mut self,
innovation: f64,
current_a: f64,
_dt_s: f64,
) -> EcmParameters {
const INNOVATION_THRESHOLD: f64 = 0.010; const ALPHA: f64 = 0.05; const R0_MIN: f64 = 0.001; const R0_MAX: f64 = 0.100;
if innovation.abs() > INNOVATION_THRESHOLD && current_a.abs() > 0.1 {
let r0_implied = (innovation.abs() / current_a.abs()).clamp(R0_MIN, R0_MAX);
let r0_new = self.ecm_params.r0_ohm + ALPHA * (r0_implied - self.ecm_params.r0_ohm);
self.ecm_params.r0_ohm = r0_new.clamp(R0_MIN, R0_MAX);
}
self.ecm_params.clone()
}
pub fn update(
&mut self,
current_a: f64,
voltage_v: f64,
) -> Result<PackStateEstimate, StateEstimationError> {
let dt = self.config.dt_s;
self.ekf_predict(current_a, dt);
let ekf_result = self.ekf_update(voltage_v, current_a)?;
self.adaptive_parameter_update(ekf_result.innovation, current_a, dt);
let dq_ah = (current_a * dt / 3600.0).abs();
self.q_throughput_ah += dq_ah;
const DEGRAD_PER_AH: f64 = 1e-7;
self.soh = Self::estimate_soh(self.soh, dq_ah, DEGRAD_PER_AH);
self.cycle_count += dq_ah / (self.config.capacity_ah * 2.0);
let soc = ekf_result.state[0];
let vrc1 = ekf_result.state[1];
let vrc2 = ekf_result.state[2];
let ocv = Self::ocv_from_soc(soc, &self.ecm_params.ocv_soc_table)?;
let v_model = ocv - current_a * self.ecm_params.r0_ohm - vrc1 - vrc2;
let residual = voltage_v - v_model;
Ok(PackStateEstimate {
soc,
soh: self.soh,
voltage_model_v: v_model,
voltage_residual_v: residual,
v_rc1_v: vrc1,
v_rc2_v: vrc2,
innovation: ekf_result.innovation,
covariance_trace: mat3_trace(&ekf_result.covariance),
})
}
pub fn validate_estimate(
&self,
state: &[f64; 3],
measured_voltage: f64,
current_a: f64,
) -> Result<ValidationResult, StateEstimationError> {
let soc = state[0];
let vrc1 = state[1];
let vrc2 = state[2];
let soc_in_range = (0.0..=1.0).contains(&soc);
let ocv = Self::ocv_from_soc(soc.clamp(0.0, 1.0), &self.ecm_params.ocv_soc_table)?;
let v_model = ocv - current_a * self.ecm_params.r0_ohm - vrc1 - vrc2;
let residual = measured_voltage - v_model;
const RESIDUAL_LIMIT: f64 = 0.050; let residual_ok = residual.abs() <= RESIDUAL_LIMIT;
Ok(ValidationResult {
valid: soc_in_range && residual_ok,
soc_in_range,
voltage_residual_v: residual,
residual_ok,
})
}
pub fn soc(&self) -> f64 {
self.state[0]
}
pub fn soh(&self) -> f64 {
self.soh
}
pub fn q_throughput_ah(&self) -> f64 {
self.q_throughput_ah
}
pub fn cycle_count(&self) -> f64 {
self.cycle_count
}
pub fn covariance_trace(&self) -> f64 {
mat3_trace(&self.covariance)
}
pub fn generate_ocv_table(chemistry: BatteryChemistryType) -> Vec<(f64, f64)> {
match chemistry {
BatteryChemistryType::Lfp => vec![
(0.00, 3.000),
(0.05, 3.100),
(0.10, 3.180),
(0.15, 3.220),
(0.20, 3.250),
(0.30, 3.265),
(0.40, 3.278),
(0.50, 3.290),
(0.60, 3.300),
(0.70, 3.310),
(0.80, 3.325),
(0.85, 3.340),
(0.90, 3.360),
(0.95, 3.400),
(1.00, 3.650),
],
BatteryChemistryType::Nmc => vec![
(0.00, 3.000),
(0.05, 3.400),
(0.10, 3.520),
(0.20, 3.620),
(0.30, 3.680),
(0.40, 3.720),
(0.50, 3.760),
(0.60, 3.810),
(0.70, 3.860),
(0.80, 3.920),
(0.90, 3.980),
(0.95, 4.060),
(1.00, 4.200),
],
BatteryChemistryType::Nca => vec![
(0.00, 2.950),
(0.05, 3.350),
(0.10, 3.500),
(0.20, 3.600),
(0.30, 3.660),
(0.40, 3.710),
(0.50, 3.750),
(0.60, 3.800),
(0.70, 3.860),
(0.80, 3.930),
(0.90, 4.000),
(0.95, 4.080),
(1.00, 4.200),
],
BatteryChemistryType::Lto => vec![
(0.00, 1.500),
(0.05, 2.100),
(0.10, 2.250),
(0.20, 2.310),
(0.30, 2.320),
(0.40, 2.330),
(0.50, 2.335),
(0.60, 2.340),
(0.70, 2.345),
(0.80, 2.350),
(0.90, 2.360),
(0.95, 2.400),
(1.00, 2.700),
],
}
}
pub fn default_ecm_for_chemistry(chemistry: BatteryChemistryType) -> EcmParameters {
let ocv_table = Self::generate_ocv_table(chemistry);
match chemistry {
BatteryChemistryType::Lfp => EcmParameters {
r0_ohm: 0.005,
r1_ohm: 0.003,
c1_f: 3000.0,
r2_ohm: 0.002,
c2_f: 30000.0,
ocv_soc_table: ocv_table,
},
BatteryChemistryType::Nmc => EcmParameters {
r0_ohm: 0.006,
r1_ohm: 0.004,
c1_f: 2500.0,
r2_ohm: 0.002,
c2_f: 25000.0,
ocv_soc_table: ocv_table,
},
BatteryChemistryType::Nca => EcmParameters {
r0_ohm: 0.005,
r1_ohm: 0.003,
c1_f: 2800.0,
r2_ohm: 0.002,
c2_f: 28000.0,
ocv_soc_table: ocv_table,
},
BatteryChemistryType::Lto => EcmParameters {
r0_ohm: 0.003,
r1_ohm: 0.002,
c1_f: 5000.0,
r2_ohm: 0.001,
c2_f: 50000.0,
ocv_soc_table: ocv_table,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_nmc_estimator(soc: f64) -> BatteryPackEstimator {
let config = BatteryStateEstimatorConfig {
capacity_ah: 100.0,
nominal_voltage_v: 3.7,
dt_s: 1.0,
q_noise: 1e-6,
r_noise: 1e-4,
initial_soc: soc,
initial_soh: 1.0,
chemistry: BatteryChemistryType::Nmc,
};
BatteryPackEstimator::with_chemistry(config).expect("default estimator should be valid")
}
#[test]
fn test_ekf_predict_soc_decreases_on_discharge() {
let mut est = make_nmc_estimator(0.8);
let soc_before = est.soc();
est.ekf_predict(50.0, 1.0);
let soc_after = est.soc();
assert!(
soc_after < soc_before,
"SoC should decrease during discharge: {soc_before} → {soc_after}"
);
let expected_delta = 50.0 / (3600.0 * 100.0 * 1.0);
let actual_delta = soc_before - soc_after;
assert!(
(actual_delta - expected_delta).abs() < 1e-6,
"ΔSoC mismatch: expected {expected_delta:.2e}, got {actual_delta:.2e}"
);
}
#[test]
fn test_ekf_predict_soc_increases_on_charge() {
let mut est = make_nmc_estimator(0.5);
let soc_before = est.soc();
est.ekf_predict(-30.0, 1.0);
assert!(
est.soc() > soc_before,
"SoC should increase during charging"
);
}
#[test]
fn test_ekf_update_innovation_drives_correction() {
let mut est = make_nmc_estimator(0.5);
est.state[0] = 0.45;
let current_a = 10.0;
let v_measured = 3.70;
let result = est
.ekf_update(v_measured, current_a)
.expect("EKF update should succeed");
assert!(
result.innovation.abs() > 0.0,
"Non-zero innovation expected"
);
assert!(
result.state[0] > 0.45,
"State SoC should increase after positive innovation: {}",
result.state[0]
);
}
#[test]
fn test_ocv_interpolation_at_mid_soc() {
let table = BatteryPackEstimator::generate_ocv_table(BatteryChemistryType::Nmc);
let ocv = BatteryPackEstimator::ocv_from_soc(0.5, &table)
.expect("Should interpolate successfully");
assert!(
(ocv - 3.760).abs() < 1e-6,
"OCV at SoC=0.50 should be 3.760 V, got {ocv}"
);
}
#[test]
fn test_ocv_lfp_plateau_is_flat() {
let table = BatteryPackEstimator::generate_ocv_table(BatteryChemistryType::Lfp);
let ocv20 = BatteryPackEstimator::ocv_from_soc(0.20, &table).expect("interp ok");
let ocv50 = BatteryPackEstimator::ocv_from_soc(0.50, &table).expect("interp ok");
let ocv80 = BatteryPackEstimator::ocv_from_soc(0.80, &table).expect("interp ok");
let spread = ocv80 - ocv20;
assert!(
spread < 0.100,
"LFP plateau too wide: {spread:.4} V between 20% and 80% SoC"
);
for &ocv in &[ocv20, ocv50, ocv80] {
assert!(
(3.20..=3.40).contains(&ocv),
"LFP plateau OCV out of expected range: {ocv}"
);
}
}
#[test]
fn test_soh_decreases_with_throughput() {
let soh_start = 1.0_f64;
let cap_fade_ah = 1000.0_f64; const DEGRAD: f64 = 1e-7;
let soh_end = BatteryPackEstimator::estimate_soh(soh_start, cap_fade_ah, DEGRAD);
assert!(soh_end < soh_start, "SoH should decrease after throughput");
assert!(
soh_end > 0.0,
"SoH should remain positive for moderate throughput"
);
let expected = (soh_start - DEGRAD * cap_fade_ah).clamp(0.0, 1.0);
assert!(
(soh_end - expected).abs() < 1e-12,
"SoH mismatch: {soh_end} vs {expected}"
);
}
#[test]
fn test_adaptive_r0_adjusts_on_large_innovation() {
let mut est = make_nmc_estimator(0.8);
let r0_before = est.ecm_params.r0_ohm;
est.adaptive_parameter_update(0.050, 20.0, 1.0);
let r0_after = est.ecm_params.r0_ohm;
assert!(
(r0_after - r0_before).abs() > 1e-10,
"R0 should change after large innovation: before={r0_before}, after={r0_after}"
);
assert!(r0_after >= 0.001, "R0 below minimum: {r0_after}");
assert!(r0_after <= 0.100, "R0 above maximum: {r0_after}");
}
#[test]
fn test_validation_rejects_out_of_range_soc() {
let est = make_nmc_estimator(0.5);
let bad_state = [1.1_f64, 0.0, 0.0];
let result = est
.validate_estimate(&bad_state, 3.76, 0.0)
.expect("validate should not error");
assert!(!result.valid, "Estimate with SoC=1.1 should be invalid");
assert!(
!result.soc_in_range,
"soc_in_range should be false for SoC=1.1"
);
}
#[test]
fn test_full_cycle_soc_recovery() {
let mut est = make_nmc_estimator(0.50);
let cap = 100.0_f64; let current_charge = -10.0_f64; let current_disch = 10.0_f64; let steps = 360_usize;
#[allow(clippy::too_many_arguments)]
fn predicted_vt(
soc_now: f64,
vrc1_now: f64,
vrc2_now: f64,
current_a: f64,
dt: f64,
r0: f64,
r1: f64,
c1: f64,
r2: f64,
c2: f64,
cap_ah: f64,
soh: f64,
ocv_table: &[(f64, f64)],
) -> f64 {
let tau1 = r1 * c1;
let tau2 = r2 * c2;
let exp1 = (-dt / tau1).exp();
let exp2 = (-dt / tau2).exp();
let soc_p = soc_now - current_a * dt / (3600.0 * cap_ah * soh);
let vrc1_p = vrc1_now * exp1 + r1 * (1.0 - exp1) * current_a;
let vrc2_p = vrc2_now * exp2 + r2 * (1.0 - exp2) * current_a;
let ocv_p = BatteryPackEstimator::ocv_from_soc(soc_p, ocv_table).unwrap_or(3.76);
ocv_p - current_a * r0 - vrc1_p - vrc2_p
}
for _ in 0..steps {
let v_sim = predicted_vt(
est.state[0],
est.state[1],
est.state[2],
current_charge,
est.config.dt_s,
est.ecm_params.r0_ohm,
est.ecm_params.r1_ohm,
est.ecm_params.c1_f,
est.ecm_params.r2_ohm,
est.ecm_params.c2_f,
est.config.capacity_ah,
est.soh,
&est.ecm_params.ocv_soc_table,
);
let _ = est.update(current_charge, v_sim);
}
let soc_after_charge = est.soc();
for _ in 0..steps {
let v_sim = predicted_vt(
est.state[0],
est.state[1],
est.state[2],
current_disch,
est.config.dt_s,
est.ecm_params.r0_ohm,
est.ecm_params.r1_ohm,
est.ecm_params.c1_f,
est.ecm_params.r2_ohm,
est.ecm_params.c2_f,
est.config.capacity_ah,
est.soh,
&est.ecm_params.ocv_soc_table,
);
let _ = est.update(current_disch, v_sim);
}
let soc_after_cycle = est.soc();
let expected_delta = current_disch * (steps as f64) / (3600.0 * cap);
assert!(
soc_after_charge > 0.50,
"SoC should rise during charging: {soc_after_charge:.4}"
);
assert!(
(soc_after_cycle - 0.50).abs() < 0.02,
"SoC should return near 0.50 after symmetric cycle, got {soc_after_cycle:.4}"
);
assert!(
(soc_after_charge - 0.50 - expected_delta).abs() < 0.005,
"Charge SoC increment wrong: expected Δ{expected_delta:.4}, actual Δ{:.4}",
soc_after_charge - 0.50
);
}
#[test]
fn test_ocv_from_soc_empty_table_returns_error() {
let table: Vec<(f64, f64)> = vec![];
let result = BatteryPackEstimator::ocv_from_soc(0.5, &table);
assert!(
matches!(result, Err(StateEstimationError::EmptyOcvTable)),
"Expected EmptyOcvTable error, got {result:?}"
);
}
#[test]
fn test_ecm_parameters_validate_rejects_negatives() {
let bad = EcmParameters {
r0_ohm: -0.001,
r1_ohm: 0.003,
c1_f: 3000.0,
r2_ohm: 0.002,
c2_f: 30000.0,
ocv_soc_table: vec![(0.0, 3.0), (1.0, 4.2)],
};
assert!(
bad.validate().is_err(),
"Negative R0 should fail validation"
);
}
#[test]
fn test_covariance_trace_bounded_after_updates() {
let mut est = make_nmc_estimator(0.8);
for _ in 0..100 {
let soc = est.soc();
let ocv =
BatteryPackEstimator::ocv_from_soc(soc, &est.ecm_params.ocv_soc_table.clone())
.unwrap_or(3.76);
let v_meas = ocv - 5.0 * est.ecm_params.r0_ohm;
let _ = est.update(5.0, v_meas);
}
let trace = est.covariance_trace();
assert!(
trace < 0.01,
"Covariance trace should be small after convergence: {trace}"
);
}
}