use crate::units::{Current, Temperature, Voltage};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BmsThresholds {
pub ov_trip_v: f64,
pub ov_release_v: f64,
pub uv_trip_v: f64,
pub uv_release_v: f64,
pub ot_trip_c: f64,
pub ot_release_c: f64,
pub ut_charge_inhibit_c: f64,
pub oc_discharge_trip_a: f64,
pub oc_charge_trip_a: f64,
pub sc_trip_a: f64,
}
impl BmsThresholds {
pub fn lfp_default() -> Self {
Self {
ov_trip_v: 3.65,
ov_release_v: 3.55,
uv_trip_v: 2.80,
uv_release_v: 2.90,
ot_trip_c: 60.0,
ot_release_c: 55.0,
ut_charge_inhibit_c: 0.0,
oc_discharge_trip_a: 200.0,
oc_charge_trip_a: 100.0,
sc_trip_a: 500.0,
}
}
pub fn nmc_default() -> Self {
Self {
ov_trip_v: 4.25,
ov_release_v: 4.15,
uv_trip_v: 3.00,
uv_release_v: 3.10,
ot_trip_c: 55.0,
ot_release_c: 50.0,
ut_charge_inhibit_c: 5.0,
oc_discharge_trip_a: 150.0,
oc_charge_trip_a: 80.0,
sc_trip_a: 400.0,
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
pub struct FaultFlags {
pub over_voltage: bool,
pub under_voltage: bool,
pub over_temperature: bool,
pub under_temperature_charge: bool,
pub over_current_discharge: bool,
pub over_current_charge: bool,
pub short_circuit: bool,
pub cell_imbalance: bool,
pub comm_fault: bool,
}
impl FaultFlags {
pub fn any_fault(&self) -> bool {
self.over_voltage
|| self.under_voltage
|| self.over_temperature
|| self.under_temperature_charge
|| self.over_current_discharge
|| self.over_current_charge
|| self.short_circuit
|| self.cell_imbalance
|| self.comm_fault
}
pub fn hard_fault(&self) -> bool {
self.over_voltage
|| self.under_voltage
|| self.over_temperature
|| self.short_circuit
|| self.over_current_discharge
|| self.over_current_charge
}
pub fn count(&self) -> u32 {
[
self.over_voltage,
self.under_voltage,
self.over_temperature,
self.under_temperature_charge,
self.over_current_discharge,
self.over_current_charge,
self.short_circuit,
self.cell_imbalance,
self.comm_fault,
]
.iter()
.filter(|&&f| f)
.count() as u32
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BmsMode {
Idle,
Precharge,
Discharge,
Charge,
Balancing,
Fault,
Shutdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BmsCommand {
Continue,
OpenContactor,
CloseContactor,
EnablePrecharge,
DerateCharge,
DerateDischarge,
EnableBalancing,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CellMeasurement {
pub cell_id: usize,
pub voltage: Voltage,
pub temperature: Temperature,
pub current: Current,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BmsEvaluation {
pub mode: BmsMode,
pub faults: FaultFlags,
pub command: BmsCommand,
pub charge_current_limit_a: f64,
pub discharge_current_limit_a: f64,
pub v_min: f64,
pub v_max: f64,
pub t_max: f64,
pub imbalance_mv: f64,
}
pub fn evaluate_protection(
cells: &[CellMeasurement],
pack_current: Current,
thresholds: &BmsThresholds,
prior_faults: &FaultFlags,
) -> BmsEvaluation {
if cells.is_empty() {
return BmsEvaluation {
mode: BmsMode::Fault,
faults: FaultFlags {
comm_fault: true,
..Default::default()
},
command: BmsCommand::OpenContactor,
charge_current_limit_a: 0.0,
discharge_current_limit_a: 0.0,
v_min: 0.0,
v_max: 0.0,
t_max: 0.0,
imbalance_mv: 0.0,
};
}
let v_min = cells
.iter()
.map(|c| c.voltage.0)
.fold(f64::INFINITY, f64::min);
let v_max = cells
.iter()
.map(|c| c.voltage.0)
.fold(f64::NEG_INFINITY, f64::max);
let t_max = cells
.iter()
.map(|c| c.temperature.0)
.fold(f64::NEG_INFINITY, f64::max);
let imbalance_mv = (v_max - v_min) * 1000.0;
let i = pack_current.0;
let mut faults = *prior_faults;
if v_max >= thresholds.ov_trip_v {
faults.over_voltage = true;
} else if v_max <= thresholds.ov_release_v {
faults.over_voltage = false;
}
if v_min <= thresholds.uv_trip_v {
faults.under_voltage = true;
} else if v_min >= thresholds.uv_release_v {
faults.under_voltage = false;
}
if t_max >= thresholds.ot_trip_c {
faults.over_temperature = true;
} else if t_max <= thresholds.ot_release_c {
faults.over_temperature = false;
}
faults.under_temperature_charge = t_max < thresholds.ut_charge_inhibit_c;
faults.over_current_discharge = i >= thresholds.oc_discharge_trip_a;
faults.over_current_charge = i <= -thresholds.oc_charge_trip_a;
faults.short_circuit = i.abs() >= thresholds.sc_trip_a;
faults.cell_imbalance = imbalance_mv > 50.0;
let (mode, command, clim, dlim) = if faults.short_circuit || faults.over_temperature {
(BmsMode::Shutdown, BmsCommand::OpenContactor, 0.0, 0.0)
} else if faults.hard_fault() {
(BmsMode::Fault, BmsCommand::OpenContactor, 0.0, 0.0)
} else {
let derate = if t_max > 45.0 {
(1.0 - (t_max - 45.0) / (thresholds.ot_trip_c - 45.0)).max(0.0)
} else {
1.0
};
let clim = if faults.under_temperature_charge {
0.0
} else {
thresholds.oc_charge_trip_a * derate
};
let dlim = thresholds.oc_discharge_trip_a * derate;
let cmd = if faults.cell_imbalance {
BmsCommand::EnableBalancing
} else if derate < 1.0 && i > 0.0 {
BmsCommand::DerateDischarge
} else if derate < 1.0 && i < 0.0 {
BmsCommand::DerateCharge
} else {
BmsCommand::Continue
};
let mode = if faults.cell_imbalance {
BmsMode::Balancing
} else if i > 0.0 {
BmsMode::Discharge
} else if i < 0.0 {
BmsMode::Charge
} else {
BmsMode::Idle
};
(mode, cmd, clim, dlim)
};
BmsEvaluation {
mode,
faults,
command,
charge_current_limit_a: clim,
discharge_current_limit_a: dlim,
v_min,
v_max,
t_max,
imbalance_mv,
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct BalancingDecision {
pub cell_id: usize,
pub shunt_enable: bool,
pub balance_current_a: f64,
pub shunt_power_w: f64,
}
pub fn passive_balance(
cell_voltages: &[(usize, f64)],
threshold_mv: f64,
shunt_r_ohm: f64,
) -> Vec<BalancingDecision> {
if cell_voltages.is_empty() {
return vec![];
}
let v_min = cell_voltages
.iter()
.map(|&(_, v)| v)
.fold(f64::INFINITY, f64::min);
let thresh = threshold_mv / 1000.0;
cell_voltages
.iter()
.map(|&(id, v)| {
let excess = v - v_min;
let shunt_enable = excess > thresh;
let balance_current_a = if shunt_enable { v / shunt_r_ohm } else { 0.0 };
let shunt_power_w = balance_current_a * v;
BalancingDecision {
cell_id: id,
shunt_enable,
balance_current_a,
shunt_power_w,
}
})
.collect()
}
pub fn active_balance_pairs(
cell_voltages: &[(usize, f64)],
threshold_mv: f64,
) -> Vec<(usize, usize)> {
if cell_voltages.len() < 2 {
return vec![];
}
let thresh = threshold_mv / 1000.0;
let mut sorted = cell_voltages.to_vec();
sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut pairs = vec![];
let n = sorted.len();
for i in 0..n / 2 {
let (hi_id, hi_v) = sorted[i];
let (lo_id, lo_v) = sorted[n - 1 - i];
if hi_v - lo_v > thresh {
pairs.push((hi_id, lo_id));
}
}
pairs
}
pub fn soh_capacity(q_measured_ah: f64, q_nominal_ah: f64) -> f64 {
if q_nominal_ah <= 0.0 {
return 0.0;
}
(q_measured_ah / q_nominal_ah * 100.0).clamp(0.0, 100.0)
}
pub fn soh_resistance(r_now_ohm: f64, r_bol_ohm: f64, r_eol_ohm: f64) -> f64 {
if (r_eol_ohm - r_bol_ohm).abs() < 1e-12 {
return 100.0;
}
let soh = (r_eol_ohm - r_now_ohm) / (r_eol_ohm - r_bol_ohm) * 100.0;
soh.clamp(0.0, 100.0)
}
pub fn soh_blended(soh_cap: f64, soh_res: f64, alpha: f64) -> f64 {
let alpha = alpha.clamp(0.0, 1.0);
alpha * soh_cap + (1.0 - alpha) * soh_res
}
pub fn remaining_useful_life_cycles(
current_soh_pct: f64,
fade_rate_per_cycle: f64,
eol_threshold_pct: f64,
) -> Option<f64> {
if fade_rate_per_cycle <= 0.0 || current_soh_pct <= eol_threshold_pct {
return None;
}
Some((current_soh_pct - eol_threshold_pct) / fade_rate_per_cycle)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrechargeState {
pub elapsed_s: f64,
pub v_target: f64,
pub v_bus: f64,
pub r_precharge_ohm: f64,
pub c_bus_f: f64,
pub completion_threshold: f64,
}
impl PrechargeState {
pub fn step(&mut self, dt: f64) -> bool {
let tau = self.r_precharge_ohm * self.c_bus_f;
self.elapsed_s += dt;
self.v_bus = self.v_target * (1.0 - (-self.elapsed_s / tau).exp());
self.v_bus >= self.v_target * self.completion_threshold
}
pub fn inrush_current_a(&self) -> f64 {
(self.v_target - self.v_bus) / self.r_precharge_ohm
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cells(voltages_v: &[f64], temp_c: f64) -> Vec<CellMeasurement> {
voltages_v
.iter()
.enumerate()
.map(|(i, &v)| CellMeasurement {
cell_id: i,
voltage: Voltage(v),
temperature: Temperature(temp_c),
current: Current(0.0),
})
.collect()
}
#[test]
fn test_no_fault_normal_conditions() {
let cells = make_cells(&[3.30, 3.31, 3.29, 3.30], 25.0);
let thresh = BmsThresholds::lfp_default();
let prior = FaultFlags::default();
let eval = evaluate_protection(&cells, Current(50.0), &thresh, &prior);
assert!(
!eval.faults.any_fault(),
"Normal conditions should be fault-free"
);
assert_eq!(eval.command, BmsCommand::Continue);
}
#[test]
fn test_over_voltage_trip() {
let cells = make_cells(&[3.30, 3.30, 3.70, 3.30], 25.0); let thresh = BmsThresholds::lfp_default();
let prior = FaultFlags::default();
let eval = evaluate_protection(&cells, Current(10.0), &thresh, &prior);
assert!(eval.faults.over_voltage, "Should trip on OV");
assert_eq!(eval.command, BmsCommand::OpenContactor);
}
#[test]
fn test_under_voltage_trip() {
let cells = make_cells(&[3.30, 2.75, 3.30, 3.30], 25.0); let thresh = BmsThresholds::lfp_default();
let prior = FaultFlags::default();
let eval = evaluate_protection(&cells, Current(50.0), &thresh, &prior);
assert!(eval.faults.under_voltage, "Should trip on UV");
}
#[test]
fn test_over_temperature_trip() {
let cells = make_cells(&[3.30; 4], 65.0); let thresh = BmsThresholds::lfp_default();
let prior = FaultFlags::default();
let eval = evaluate_protection(&cells, Current(10.0), &thresh, &prior);
assert!(eval.faults.over_temperature);
assert_eq!(eval.mode, BmsMode::Shutdown);
}
#[test]
fn test_short_circuit_trip() {
let cells = make_cells(&[3.30; 4], 25.0);
let thresh = BmsThresholds::lfp_default();
let prior = FaultFlags::default();
let eval = evaluate_protection(&cells, Current(600.0), &thresh, &prior); assert!(eval.faults.short_circuit);
assert_eq!(eval.mode, BmsMode::Shutdown);
}
#[test]
fn test_over_voltage_hysteresis() {
let thresh = BmsThresholds::lfp_default();
let prior = FaultFlags {
over_voltage: true,
..Default::default()
};
let cells = make_cells(&[3.60; 4], 25.0);
let eval = evaluate_protection(&cells, Current(0.0), &thresh, &prior);
assert!(
eval.faults.over_voltage,
"OV should persist in hysteresis band"
);
let cells2 = make_cells(&[3.50; 4], 25.0);
let eval2 = evaluate_protection(&cells2, Current(0.0), &thresh, &eval.faults);
assert!(
!eval2.faults.over_voltage,
"OV should clear below release threshold"
);
}
#[test]
fn test_thermal_derating() {
let cells = make_cells(&[3.30; 4], 52.0); let thresh = BmsThresholds::lfp_default();
let prior = FaultFlags::default();
let eval = evaluate_protection(&cells, Current(80.0), &thresh, &prior);
assert!(
eval.discharge_current_limit_a < thresh.oc_discharge_trip_a,
"Current limit should be derated at high temp"
);
assert!(
!eval.faults.over_temperature,
"Should not be OT fault at 52°C"
);
}
#[test]
fn test_cell_imbalance_flag() {
let cells = make_cells(&[3.20, 3.40, 3.20, 3.20], 25.0); let thresh = BmsThresholds::lfp_default();
let prior = FaultFlags::default();
let eval = evaluate_protection(&cells, Current(0.0), &thresh, &prior);
assert!(eval.faults.cell_imbalance, "Should flag imbalance > 50 mV");
assert_eq!(eval.mode, BmsMode::Balancing);
}
#[test]
fn test_empty_cells_comm_fault() {
let thresh = BmsThresholds::lfp_default();
let prior = FaultFlags::default();
let eval = evaluate_protection(&[], Current(0.0), &thresh, &prior);
assert!(eval.faults.comm_fault);
}
#[test]
fn test_passive_balance_triggers_high_cell() {
let cells = vec![(0, 3.30), (1, 3.40), (2, 3.30)]; let decisions = passive_balance(&cells, 20.0, 10.0);
assert!(decisions[1].shunt_enable, "High cell should be bypassed");
assert!(!decisions[0].shunt_enable);
assert!(!decisions[2].shunt_enable);
}
#[test]
fn test_passive_balance_no_action_balanced() {
let cells = vec![(0, 3.30), (1, 3.31), (2, 3.30)]; let decisions = passive_balance(&cells, 20.0, 10.0);
assert!(
!decisions.iter().any(|d| d.shunt_enable),
"No balancing for < threshold"
);
}
#[test]
fn test_active_balance_pairs_high_to_low() {
let cells = vec![(0, 3.45), (1, 3.30), (2, 3.42), (3, 3.28)];
let pairs = active_balance_pairs(&cells, 50.0);
assert!(!pairs.is_empty(), "Should identify transfer pairs");
for (src, tgt) in &pairs {
let v_src = cells[*src].1;
let v_tgt = cells[*tgt].1;
assert!(
v_src > v_tgt,
"Source ({v_src:.3}) should be higher than target ({v_tgt:.3})"
);
}
}
#[test]
fn test_active_balance_no_pairs_balanced() {
let cells = vec![(0, 3.30), (1, 3.31)]; let pairs = active_balance_pairs(&cells, 50.0);
assert!(pairs.is_empty());
}
#[test]
fn test_soh_capacity_new_cell() {
assert!((soh_capacity(100.0, 100.0) - 100.0).abs() < 1e-10);
}
#[test]
fn test_soh_capacity_degraded() {
assert!((soh_capacity(85.0, 100.0) - 85.0).abs() < 1e-10);
}
#[test]
fn test_soh_resistance_new() {
assert!((soh_resistance(0.05, 0.05, 0.10) - 100.0).abs() < 1e-10);
}
#[test]
fn test_soh_resistance_eol() {
assert!((soh_resistance(0.10, 0.05, 0.10) - 0.0).abs() < 1e-10);
}
#[test]
fn test_soh_blended() {
let soh = soh_blended(90.0, 80.0, 0.5);
assert!((soh - 85.0).abs() < 1e-10);
}
#[test]
fn test_rul_cycles() {
let rul = remaining_useful_life_cycles(95.0, 0.01, 80.0).unwrap();
assert!((rul - 1500.0).abs() < 1e-6, "RUL={rul:.1}");
}
#[test]
fn test_rul_already_eol() {
let rul = remaining_useful_life_cycles(75.0, 0.01, 80.0);
assert!(rul.is_none(), "Already past EOL should return None");
}
#[test]
fn test_precharge_completes() {
let mut pc = PrechargeState {
elapsed_s: 0.0,
v_target: 800.0,
v_bus: 0.0,
r_precharge_ohm: 100.0,
c_bus_f: 0.01,
completion_threshold: 0.95,
};
let tau = pc.r_precharge_ohm * pc.c_bus_f; for _ in 0..300 {
if pc.step(tau / 100.0) {
break;
}
}
assert!(
pc.v_bus >= pc.v_target * 0.95,
"Pre-charge should reach 95%: {:.1}",
pc.v_bus
);
}
#[test]
fn test_precharge_inrush_decreases() {
let mut pc = PrechargeState {
elapsed_s: 0.0,
v_target: 400.0,
v_bus: 0.0,
r_precharge_ohm: 50.0,
c_bus_f: 0.005,
completion_threshold: 0.95,
};
let i0 = pc.inrush_current_a();
pc.step(0.1);
let i1 = pc.inrush_current_a();
assert!(
i1 < i0,
"Inrush current should decrease as capacitor charges"
);
}
#[test]
fn test_fault_flags_count() {
let f = FaultFlags {
over_voltage: true,
under_voltage: true,
..Default::default()
};
assert_eq!(f.count(), 2);
}
#[test]
fn test_fault_flags_hard_fault() {
let f = FaultFlags {
over_voltage: true,
..Default::default()
};
assert!(f.hard_fault());
let f2 = FaultFlags {
cell_imbalance: true,
comm_fault: false,
..Default::default()
};
assert!(!f2.hard_fault());
}
}