const R_GAS: f64 = 8.314;
const OCV_COEFFS: [f64; 5] = [0.05, 0.8, -0.4, 0.5, 0.05];
#[derive(Debug, Clone)]
pub struct ElectrothermalCell {
pub capacity_ah: f64,
pub v_nominal: f64,
pub v_max: f64,
pub v_min: f64,
pub t_ref_c: f64,
pub r0_ref: f64,
pub r1_ref: f64,
pub c1_ref: f64,
pub r2_ref: f64,
pub c2_ref: f64,
pub ea_r0: f64,
pub ea_diff: f64,
pub thermal_mass: f64,
pub r_conv: f64,
pub mass_kg: f64,
pub cp: f64,
pub entropy_coeff: f64,
}
impl ElectrothermalCell {
pub fn default_nmc() -> Self {
Self {
capacity_ah: 3.0,
v_nominal: 3.7,
v_max: 4.2,
v_min: 2.5,
t_ref_c: 25.0,
r0_ref: 0.02,
r1_ref: 0.01,
c1_ref: 1000.0,
r2_ref: 0.005,
c2_ref: 5000.0,
ea_r0: 20000.0,
ea_diff: 30000.0,
thermal_mass: 50.0,
r_conv: 10.0,
mass_kg: 0.048,
cp: 1000.0,
entropy_coeff: -0.0001,
}
}
}
#[derive(Debug, Clone)]
pub enum ThermalManagement {
NaturalConvection {
h_conv: f64,
area_m2: f64,
},
ForcedAir {
flow_rate_m3_s: f64,
h_conv: f64,
area_m2: f64,
},
LiquidCooling {
flow_rate_l_min: f64,
t_coolant_in_c: f64,
cp_coolant: f64,
h_conv: f64,
area_m2: f64,
},
Pcm {
t_melt_c: f64,
latent_heat_j_kg: f64,
pcm_mass_kg: f64,
},
}
impl ThermalManagement {
pub fn default_natural() -> Self {
Self::NaturalConvection {
h_conv: 10.0,
area_m2: 0.004,
}
}
pub fn default_liquid() -> Self {
Self::LiquidCooling {
flow_rate_l_min: 2.0,
t_coolant_in_c: 25.0,
cp_coolant: 4186.0,
h_conv: 500.0,
area_m2: 0.004,
}
}
}
#[derive(Debug, Clone)]
pub struct ElectrothermalState {
pub soc: f64,
pub v_terminal: f64,
pub v_ocv: f64,
pub v_r0: f64,
pub v_rc1: f64,
pub v_rc2: f64,
pub current_a: f64,
pub temperature_c: f64,
pub q_gen_w: f64,
pub q_irrev_w: f64,
pub q_rev_w: f64,
pub q_removed_w: f64,
pub time_s: f64,
}
impl ElectrothermalState {
fn initial(soc: f64, temp_c: f64, v_min: f64, v_max: f64) -> Self {
let v_ocv = ElectrothermalSimulator::ocv(soc, v_min, v_max);
Self {
soc,
v_terminal: v_ocv,
v_ocv,
v_r0: 0.0,
v_rc1: 0.0,
v_rc2: 0.0,
current_a: 0.0,
temperature_c: temp_c,
q_gen_w: 0.0,
q_irrev_w: 0.0,
q_rev_w: 0.0,
q_removed_w: 0.0,
time_s: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct EtSimConfig {
pub dt_s: f64,
pub duration_s: f64,
pub ambient_temp_c: f64,
pub initial_soc: f64,
pub initial_temp_c: f64,
}
impl Default for EtSimConfig {
fn default() -> Self {
Self {
dt_s: 1.0,
duration_s: 3600.0,
ambient_temp_c: 25.0,
initial_soc: 0.8,
initial_temp_c: 25.0,
}
}
}
#[derive(Debug, Clone)]
pub struct CurrentStep {
pub start_s: f64,
pub end_s: f64,
pub current_a: f64,
}
#[derive(Debug, Clone)]
pub struct DriveCycle {
pub name: String,
pub power_steps: Vec<CurrentStep>,
}
#[derive(Debug, Clone)]
pub struct EtSimResult {
pub states: Vec<ElectrothermalState>,
pub max_temp_c: f64,
pub min_temp_c: f64,
pub energy_wh: f64,
pub total_heat_j: f64,
pub thermal_efficiency_pct: f64,
pub tms_energy_removed_j: f64,
}
#[derive(Debug, Clone)]
pub struct ElectrothermalPack {
pub cells: Vec<ElectrothermalCell>,
pub n_series: usize,
pub n_parallel: usize,
pub tms: ThermalManagement,
pub r_cell_to_cell: f64,
}
#[derive(Debug, Clone)]
pub struct PackEtResult {
pub cell_results: Vec<EtSimResult>,
pub pack_voltage: Vec<f64>,
pub pack_current: Vec<f64>,
pub max_cell_temp_c: f64,
pub min_cell_temp_c: f64,
pub temp_spread_c: f64,
pub pack_energy_wh: f64,
}
#[derive(Debug, Clone)]
pub struct ElectrothermalSimulator {
pub cell: ElectrothermalCell,
pub tms: ThermalManagement,
pub config: EtSimConfig,
}
impl ElectrothermalSimulator {
pub fn new(cell: ElectrothermalCell, tms: ThermalManagement, config: EtSimConfig) -> Self {
Self { cell, tms, config }
}
pub fn ocv(soc: f64, v_min: f64, v_max: f64) -> f64 {
let s = soc.clamp(0.0, 1.0);
let a = &OCV_COEFFS;
let poly = a[0] + a[1] * s + a[2] * s * s + a[3] * s * s * s + a[4] * s * s * s * s;
v_min + (v_max - v_min) * poly
}
pub fn arrhenius_factor(ea: f64, t_c: f64, t_ref_c: f64) -> f64 {
let t_k = t_c + 273.15;
let t_ref_k = t_ref_c + 273.15;
if t_k <= 0.0 || t_ref_k <= 0.0 {
return 1.0;
}
let exponent = ea / R_GAS * (1.0 / t_k - 1.0 / t_ref_k);
exponent.clamp(-50.0, 50.0).exp()
}
fn temp_dependent_params(&self, t_c: f64) -> (f64, f64, f64, f64, f64) {
let c = &self.cell;
let arr_r0 = Self::arrhenius_factor(c.ea_r0, t_c, c.t_ref_c);
let arr_diff = Self::arrhenius_factor(c.ea_diff, t_c, c.t_ref_c);
let r0 = c.r0_ref * arr_r0;
let r1 = c.r1_ref * arr_diff;
let c1 = c.c1_ref / arr_diff; let r2 = c.r2_ref * arr_diff;
let c2 = c.c2_ref / arr_diff;
(r0, r1, c1, r2, c2)
}
fn heat_generation(&self, state: &ElectrothermalState, temp_c: f64) -> (f64, f64) {
let (r0, r1, _, r2, _) = self.temp_dependent_params(temp_c);
let i = state.current_a;
let q_r0 = i * i * r0;
let q_rc1 = if r1.abs() > 1e-15 {
state.v_rc1 * state.v_rc1 / r1
} else {
0.0
};
let q_rc2 = if r2.abs() > 1e-15 {
state.v_rc2 * state.v_rc2 / r2
} else {
0.0
};
let q_irrev = q_r0 + q_rc1 + q_rc2;
let t_k = temp_c + 273.15;
let q_rev = i * t_k * self.cell.entropy_coeff;
(q_irrev, q_rev)
}
fn tms_heat_removal(&self, temp_c: f64) -> f64 {
match &self.tms {
ThermalManagement::NaturalConvection { h_conv, area_m2 } => {
let dt = temp_c - self.config.ambient_temp_c;
h_conv * area_m2 * dt
}
ThermalManagement::ForcedAir {
flow_rate_m3_s: _,
h_conv,
area_m2,
} => {
let dt = temp_c - self.config.ambient_temp_c;
h_conv * area_m2 * dt
}
ThermalManagement::LiquidCooling {
flow_rate_l_min: _,
t_coolant_in_c,
cp_coolant: _,
h_conv,
area_m2,
} => {
let dt = temp_c - t_coolant_in_c;
h_conv * area_m2 * dt
}
ThermalManagement::Pcm {
t_melt_c,
latent_heat_j_kg,
pcm_mass_kg,
} => {
if temp_c >= *t_melt_c {
let capacity = latent_heat_j_kg * pcm_mass_kg;
let dt = temp_c - t_melt_c;
let q_max = capacity / self.config.duration_s.max(1.0);
let q = dt * self.cell.mass_kg * self.cell.cp * 0.1;
q.min(q_max).max(0.0)
} else {
0.0
}
}
}
}
fn step(&self, state: &ElectrothermalState, current_a: f64, dt: f64) -> ElectrothermalState {
let y0 = [state.soc, state.v_rc1, state.v_rc2, state.temperature_c];
let derivatives = |y: [f64; 4], i: f64| -> [f64; 4] {
let _soc = y[0].clamp(0.0, 1.0);
let v_rc1 = y[1];
let v_rc2 = y[2];
let temp = y[3];
let (r0, r1, c1, r2, c2) = self.temp_dependent_params(temp);
let tau1 = r1 * c1;
let tau2 = r2 * c2;
let dsoc_dt = -i / (3600.0 * self.cell.capacity_ah);
let dv_rc1_dt = if tau1.abs() > 1e-15 {
-v_rc1 / tau1 + i / c1.max(1e-12)
} else {
0.0
};
let dv_rc2_dt = if tau2.abs() > 1e-15 {
-v_rc2 / tau2 + i / c2.max(1e-12)
} else {
0.0
};
let q_r0 = i * i * r0;
let q_rc1 = if r1.abs() > 1e-15 {
v_rc1 * v_rc1 / r1
} else {
0.0
};
let q_rc2 = if r2.abs() > 1e-15 {
v_rc2 * v_rc2 / r2
} else {
0.0
};
let q_irrev = q_r0 + q_rc1 + q_rc2;
let t_k = temp + 273.15;
let q_rev = i * t_k * self.cell.entropy_coeff;
let q_total = q_irrev + q_rev;
let q_removed = match &self.tms {
ThermalManagement::NaturalConvection { h_conv, area_m2 } => {
h_conv * area_m2 * (temp - self.config.ambient_temp_c)
}
ThermalManagement::ForcedAir {
h_conv, area_m2, ..
} => h_conv * area_m2 * (temp - self.config.ambient_temp_c),
ThermalManagement::LiquidCooling {
t_coolant_in_c,
h_conv,
area_m2,
..
} => h_conv * area_m2 * (temp - t_coolant_in_c),
ThermalManagement::Pcm {
t_melt_c,
latent_heat_j_kg,
pcm_mass_kg,
} => {
if temp >= *t_melt_c {
let capacity = latent_heat_j_kg * pcm_mass_kg;
let dt_pcm = temp - t_melt_c;
let q_max = capacity / self.config.duration_s.max(1.0);
let q = dt_pcm * self.cell.mass_kg * self.cell.cp * 0.1;
q.min(q_max).max(0.0)
} else {
0.0
}
}
};
let thermal_cap = self.cell.mass_kg * self.cell.cp;
let dt_dt = if thermal_cap > 1e-12 {
(q_total - q_removed) / thermal_cap
} else {
0.0
};
[dsoc_dt, dv_rc1_dt, dv_rc2_dt, dt_dt]
};
let k1 = derivatives(y0, current_a);
let y1: [f64; 4] = [
y0[0] + 0.5 * dt * k1[0],
y0[1] + 0.5 * dt * k1[1],
y0[2] + 0.5 * dt * k1[2],
y0[3] + 0.5 * dt * k1[3],
];
let k2 = derivatives(y1, current_a);
let y2: [f64; 4] = [
y0[0] + 0.5 * dt * k2[0],
y0[1] + 0.5 * dt * k2[1],
y0[2] + 0.5 * dt * k2[2],
y0[3] + 0.5 * dt * k2[3],
];
let k3 = derivatives(y2, current_a);
let y3: [f64; 4] = [
y0[0] + dt * k3[0],
y0[1] + dt * k3[1],
y0[2] + dt * k3[2],
y0[3] + dt * k3[3],
];
let k4 = derivatives(y3, current_a);
let new_soc =
(y0[0] + dt / 6.0 * (k1[0] + 2.0 * k2[0] + 2.0 * k3[0] + k4[0])).clamp(0.0, 1.0);
let new_vrc1 = y0[1] + dt / 6.0 * (k1[1] + 2.0 * k2[1] + 2.0 * k3[1] + k4[1]);
let new_vrc2 = y0[2] + dt / 6.0 * (k1[2] + 2.0 * k2[2] + 2.0 * k3[2] + k4[2]);
let new_temp = y0[3] + dt / 6.0 * (k1[3] + 2.0 * k2[3] + 2.0 * k3[3] + k4[3]);
let (r0, _, _, _, _) = self.temp_dependent_params(new_temp);
let v_ocv = Self::ocv(new_soc, self.cell.v_min, self.cell.v_max);
let v_r0_drop = current_a * r0;
let v_terminal = v_ocv - v_r0_drop - new_vrc1 - new_vrc2;
let (q_irrev, q_rev) = self.heat_generation(
&ElectrothermalState {
soc: new_soc,
v_terminal,
v_ocv,
v_r0: v_r0_drop,
v_rc1: new_vrc1,
v_rc2: new_vrc2,
current_a,
temperature_c: new_temp,
q_gen_w: 0.0,
q_irrev_w: 0.0,
q_rev_w: 0.0,
q_removed_w: 0.0,
time_s: 0.0,
},
new_temp,
);
let q_gen = q_irrev + q_rev;
let q_removed = self.tms_heat_removal(new_temp);
ElectrothermalState {
soc: new_soc,
v_terminal,
v_ocv,
v_r0: v_r0_drop,
v_rc1: new_vrc1,
v_rc2: new_vrc2,
current_a,
temperature_c: new_temp,
q_gen_w: q_gen,
q_irrev_w: q_irrev,
q_rev_w: q_rev,
q_removed_w: q_removed,
time_s: state.time_s + dt,
}
}
pub fn simulate_constant_current(&self, current_a: f64) -> Result<EtSimResult, String> {
if self.config.dt_s <= 0.0 {
return Err("Time step must be positive".into());
}
if self.config.duration_s <= 0.0 {
return Err("Duration must be positive".into());
}
let mut state = ElectrothermalState::initial(
self.config.initial_soc,
self.config.initial_temp_c,
self.cell.v_min,
self.cell.v_max,
);
let n_steps = (self.config.duration_s / self.config.dt_s).ceil() as usize;
let mut states = Vec::with_capacity(n_steps + 1);
states.push(state.clone());
for _ in 0..n_steps {
state = self.step(&state, current_a, self.config.dt_s);
if state.soc <= 0.0 || state.soc >= 1.0 {
states.push(state.clone());
break;
}
states.push(state.clone());
}
Self::build_result(states, self.config.dt_s)
}
pub fn simulate_profile(&self, profile: &[CurrentStep]) -> Result<EtSimResult, String> {
if self.config.dt_s <= 0.0 {
return Err("Time step must be positive".into());
}
if profile.is_empty() {
return Err("Profile must not be empty".into());
}
let mut state = ElectrothermalState::initial(
self.config.initial_soc,
self.config.initial_temp_c,
self.cell.v_min,
self.cell.v_max,
);
let mut states = Vec::new();
states.push(state.clone());
for step in profile {
if step.end_s <= step.start_s {
continue;
}
let n_sub = ((step.end_s - step.start_s) / self.config.dt_s).ceil() as usize;
for _ in 0..n_sub {
state = self.step(&state, step.current_a, self.config.dt_s);
states.push(state.clone());
if state.soc <= 0.0 || state.soc >= 1.0 {
break;
}
}
}
Self::build_result(states, self.config.dt_s)
}
pub fn simulate_drive_cycle(&self, cycle: &DriveCycle) -> Result<EtSimResult, String> {
self.simulate_profile(&cycle.power_steps)
}
fn build_result(states: Vec<ElectrothermalState>, dt_s: f64) -> Result<EtSimResult, String> {
if states.is_empty() {
return Err("No states produced".into());
}
let mut max_temp = f64::NEG_INFINITY;
let mut min_temp = f64::INFINITY;
let mut total_energy_ws: f64 = 0.0;
let mut total_heat_j: f64 = 0.0;
let mut tms_removed_j: f64 = 0.0;
for s in &states {
if s.temperature_c > max_temp {
max_temp = s.temperature_c;
}
if s.temperature_c < min_temp {
min_temp = s.temperature_c;
}
total_energy_ws += s.v_terminal * s.current_a * dt_s;
total_heat_j += s.q_gen_w * dt_s;
tms_removed_j += s.q_removed_w * dt_s;
}
let energy_wh = total_energy_ws.abs() / 3600.0;
let thermal_eff = if total_energy_ws.abs() > 1e-12 {
(1.0 - total_heat_j.abs() / total_energy_ws.abs()) * 100.0
} else {
100.0
};
Ok(EtSimResult {
states,
max_temp_c: max_temp,
min_temp_c: min_temp,
energy_wh,
total_heat_j,
thermal_efficiency_pct: thermal_eff.clamp(0.0, 100.0),
tms_energy_removed_j: tms_removed_j,
})
}
}
impl ElectrothermalPack {
pub fn new(
cells: Vec<ElectrothermalCell>,
n_series: usize,
n_parallel: usize,
tms: ThermalManagement,
) -> Self {
Self {
cells,
n_series,
n_parallel,
tms,
r_cell_to_cell: 5.0,
}
}
pub fn simulate(
&self,
profile: &[CurrentStep],
config: &EtSimConfig,
) -> Result<PackEtResult, String> {
if self.cells.is_empty() {
return Err("Pack has no cells".into());
}
if config.dt_s <= 0.0 {
return Err("Time step must be positive".into());
}
if profile.is_empty() {
return Err("Profile must not be empty".into());
}
let n_cells = self.cells.len();
let cell_current_factor = if self.n_parallel > 0 {
1.0 / self.n_parallel as f64
} else {
1.0
};
let simulators: Vec<ElectrothermalSimulator> = self
.cells
.iter()
.map(|c| ElectrothermalSimulator::new(c.clone(), self.tms.clone(), config.clone()))
.collect();
let mut cell_states: Vec<ElectrothermalState> = simulators
.iter()
.map(|sim| {
ElectrothermalState::initial(
sim.config.initial_soc,
sim.config.initial_temp_c,
sim.cell.v_min,
sim.cell.v_max,
)
})
.collect();
let mut cell_histories: Vec<Vec<ElectrothermalState>> =
(0..n_cells).map(|i| vec![cell_states[i].clone()]).collect();
let mut pack_voltages: Vec<f64> = Vec::new();
let mut pack_currents: Vec<f64> = Vec::new();
let init_pack_v: f64 = cell_states
.iter()
.take(self.n_series.min(n_cells))
.map(|s| s.v_terminal)
.sum();
pack_voltages.push(init_pack_v);
pack_currents.push(0.0);
for step_def in profile {
if step_def.end_s <= step_def.start_s {
continue;
}
let cell_current = step_def.current_a * cell_current_factor;
let n_sub = ((step_def.end_s - step_def.start_s) / config.dt_s).ceil() as usize;
for _ in 0..n_sub {
for (idx, sim) in simulators.iter().enumerate() {
cell_states[idx] = sim.step(&cell_states[idx], cell_current, config.dt_s);
}
if n_cells > 1 && self.r_cell_to_cell > 1e-12 {
let mut delta_temps = vec![0.0f64; n_cells];
for i in 0..n_cells - 1 {
let dt_ij = cell_states[i].temperature_c - cell_states[i + 1].temperature_c;
let q_transfer = dt_ij / self.r_cell_to_cell;
let cap_i = self.cells[i].mass_kg * self.cells[i].cp;
let cap_j = self.cells[i + 1].mass_kg * self.cells[i + 1].cp;
if cap_i > 1e-12 {
delta_temps[i] -= q_transfer * config.dt_s / cap_i;
}
if cap_j > 1e-12 {
delta_temps[i + 1] += q_transfer * config.dt_s / cap_j;
}
}
for (i, dt) in delta_temps.iter().enumerate() {
cell_states[i].temperature_c += dt;
}
}
for (idx, s) in cell_states.iter().enumerate() {
cell_histories[idx].push(s.clone());
}
let pack_v: f64 = cell_states
.iter()
.take(self.n_series.min(n_cells))
.map(|s| s.v_terminal)
.sum();
pack_voltages.push(pack_v);
pack_currents.push(step_def.current_a);
}
}
let mut cell_results = Vec::with_capacity(n_cells);
for history in cell_histories {
cell_results.push(ElectrothermalSimulator::build_result(history, config.dt_s)?);
}
let max_cell_temp = cell_results
.iter()
.map(|r| r.max_temp_c)
.fold(f64::NEG_INFINITY, f64::max);
let min_cell_temp = cell_results
.iter()
.map(|r| r.min_temp_c)
.fold(f64::INFINITY, f64::min);
let temp_spread = max_cell_temp - min_cell_temp;
let pack_energy_wh: f64 = cell_results.iter().map(|r| r.energy_wh).sum();
Ok(PackEtResult {
cell_results,
pack_voltage: pack_voltages,
pack_current: pack_currents,
max_cell_temp_c: max_cell_temp,
min_cell_temp_c: min_cell_temp,
temp_spread_c: temp_spread,
pack_energy_wh,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_cell() -> ElectrothermalCell {
ElectrothermalCell::default_nmc()
}
fn default_sim(current_dur_s: f64) -> ElectrothermalSimulator {
ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::default_natural(),
EtSimConfig {
dt_s: 1.0,
duration_s: current_dur_s,
ambient_temp_c: 25.0,
initial_soc: 0.8,
initial_temp_c: 25.0,
},
)
}
#[test]
fn test_constant_discharge_soc_decreases() {
let sim = default_sim(60.0);
let res = sim.simulate_constant_current(1.0).expect("sim failed");
let first_soc = res.states.first().map(|s| s.soc).unwrap_or(1.0);
let last_soc = res.states.last().map(|s| s.soc).unwrap_or(1.0);
assert!(last_soc < first_soc, "SoC should decrease during discharge");
}
#[test]
fn test_constant_discharge_temp_increases() {
let sim = default_sim(120.0);
let res = sim.simulate_constant_current(3.0).expect("sim failed");
let first_t = res.states.first().map(|s| s.temperature_c).unwrap_or(25.0);
let last_t = res.states.last().map(|s| s.temperature_c).unwrap_or(25.0);
assert!(
last_t > first_t,
"Temperature should increase during discharge: {} vs {}",
last_t,
first_t
);
}
#[test]
fn test_vterminal_lt_ocv_discharge() {
let sim = default_sim(10.0);
let res = sim.simulate_constant_current(2.0).expect("sim failed");
for s in &res.states[1..] {
assert!(
s.v_terminal < s.v_ocv + 1e-9,
"V_terminal should be < OCV during discharge"
);
}
}
#[test]
fn test_vterminal_gt_ocv_charge() {
let sim = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::default_natural(),
EtSimConfig {
dt_s: 1.0,
duration_s: 10.0,
ambient_temp_c: 25.0,
initial_soc: 0.5,
initial_temp_c: 25.0,
},
);
let res = sim.simulate_constant_current(-2.0).expect("sim failed");
for s in &res.states[1..] {
assert!(
s.v_terminal > s.v_ocv - 1e-9,
"V_terminal should be > OCV during charge"
);
}
}
#[test]
fn test_r0_increases_at_low_temp() {
let cell = default_cell();
let factor_cold = ElectrothermalSimulator::arrhenius_factor(cell.ea_r0, 0.0, cell.t_ref_c);
let factor_ref = ElectrothermalSimulator::arrhenius_factor(cell.ea_r0, 25.0, cell.t_ref_c);
assert!(
factor_cold > factor_ref,
"R0 factor should be larger at low temperature"
);
}
#[test]
fn test_heat_gen_positive_discharge() {
let sim = default_sim(30.0);
let res = sim.simulate_constant_current(3.0).expect("sim failed");
assert!(res.total_heat_j > 0.0, "Total heat should be positive");
}
#[test]
fn test_energy_positive_discharge() {
let sim = default_sim(60.0);
let res = sim.simulate_constant_current(1.0).expect("sim failed");
assert!(
res.energy_wh > 0.0,
"Energy should be positive for discharge"
);
}
#[test]
fn test_thermal_efficiency_range() {
let sim = default_sim(60.0);
let res = sim.simulate_constant_current(2.0).expect("sim failed");
assert!(
res.thermal_efficiency_pct > 0.0 && res.thermal_efficiency_pct < 100.0,
"Thermal efficiency should be between 0 and 100: {}",
res.thermal_efficiency_pct
);
}
#[test]
fn test_ocv_endpoints() {
let cell = default_cell();
let ocv_0 = ElectrothermalSimulator::ocv(0.0, cell.v_min, cell.v_max);
let ocv_1 = ElectrothermalSimulator::ocv(1.0, cell.v_min, cell.v_max);
let range = cell.v_max - cell.v_min;
assert!(
(ocv_0 - (cell.v_min + range * 0.05)).abs() < 1e-6,
"OCV(0) should be near v_min"
);
let sum_a: f64 = OCV_COEFFS.iter().sum();
assert!(
(ocv_1 - (cell.v_min + range * sum_a)).abs() < 1e-6,
"OCV(1) should be near v_max"
);
}
#[test]
fn test_rc_voltage_builds_up() {
let sim = default_sim(30.0);
let res = sim.simulate_constant_current(3.0).expect("sim failed");
let last = res.states.last().expect("empty states");
assert!(
last.v_rc1.abs() > 1e-6,
"RC1 voltage should build up: {}",
last.v_rc1
);
}
#[test]
fn test_rc_voltage_decays_at_rest() {
let sim = default_sim(30.0);
let res = sim.simulate_constant_current(3.0).expect("sim failed");
let after_discharge = res.states.last().expect("empty states");
let vrc1_after = after_discharge.v_rc1;
let rest_sim = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::default_natural(),
EtSimConfig {
dt_s: 1.0,
duration_s: 60.0,
ambient_temp_c: 25.0,
initial_soc: after_discharge.soc,
initial_temp_c: after_discharge.temperature_c,
},
);
let mut state = after_discharge.clone();
state.time_s = 0.0;
let mut final_state = state.clone();
for _ in 0..60 {
final_state = rest_sim.step(&final_state, 0.0, 1.0);
}
assert!(
final_state.v_rc1.abs() < vrc1_after.abs(),
"RC1 voltage should decay during rest: {} vs {}",
final_state.v_rc1,
vrc1_after
);
}
#[test]
fn test_tms_natural_convection() {
let sim = default_sim(60.0);
let q1 = sim.tms_heat_removal(30.0); let q2 = sim.tms_heat_removal(35.0); assert!(q2 > q1, "More heat removal at higher dT");
assert!((q2 / q1 - 2.0).abs() < 0.01, "Should be proportional to dT");
}
#[test]
fn test_liquid_better_than_natural() {
let natural = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::default_natural(),
EtSimConfig::default(),
);
let liquid = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::default_liquid(),
EtSimConfig::default(),
);
let q_nat = natural.tms_heat_removal(35.0);
let q_liq = liquid.tms_heat_removal(35.0);
assert!(
q_liq > q_nat,
"Liquid cooling should remove more heat: {} vs {}",
q_liq,
q_nat
);
}
#[test]
fn test_pcm_absorbs_at_melt() {
let pcm_sim = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::Pcm {
t_melt_c: 30.0,
latent_heat_j_kg: 200000.0,
pcm_mass_kg: 0.05,
},
EtSimConfig {
duration_s: 100.0,
..EtSimConfig::default()
},
);
let q_below = pcm_sim.tms_heat_removal(28.0);
let q_above = pcm_sim.tms_heat_removal(32.0);
assert!(q_below < 1e-12, "No PCM absorption below melt point");
assert!(q_above > 0.0, "PCM should absorb heat above melt point");
}
#[test]
fn test_forced_air_more_cooling() {
let low_h = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::ForcedAir {
flow_rate_m3_s: 0.01,
h_conv: 25.0,
area_m2: 0.004,
},
EtSimConfig::default(),
);
let high_h = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::ForcedAir {
flow_rate_m3_s: 0.05,
h_conv: 100.0,
area_m2: 0.004,
},
EtSimConfig::default(),
);
assert!(
high_h.tms_heat_removal(35.0) > low_h.tms_heat_removal(35.0),
"Higher h_conv should give more cooling"
);
}
#[test]
fn test_pack_voltage_series() {
let n_s = 4;
let cells: Vec<ElectrothermalCell> = (0..n_s).map(|_| default_cell()).collect();
let pack = ElectrothermalPack::new(cells, n_s, 1, ThermalManagement::default_natural());
let profile = vec![CurrentStep {
start_s: 0.0,
end_s: 10.0,
current_a: 1.0,
}];
let config = EtSimConfig {
dt_s: 1.0,
duration_s: 10.0,
..EtSimConfig::default()
};
let res = pack.simulate(&profile, &config).expect("pack sim failed");
let cell_v = ElectrothermalSimulator::ocv(0.8, default_cell().v_min, default_cell().v_max);
let expected = n_s as f64 * cell_v;
let pack_v0 = res.pack_voltage.first().copied().unwrap_or(0.0);
assert!(
(pack_v0 - expected).abs() < 0.1,
"Pack voltage {} should be close to {} ({}*{})",
pack_v0,
expected,
n_s,
cell_v
);
}
#[test]
fn test_pack_temp_spread_identical() {
let n_s = 3;
let cells: Vec<ElectrothermalCell> = (0..n_s).map(|_| default_cell()).collect();
let pack = ElectrothermalPack::new(cells, n_s, 1, ThermalManagement::default_natural());
let profile = vec![CurrentStep {
start_s: 0.0,
end_s: 30.0,
current_a: 2.0,
}];
let config = EtSimConfig {
dt_s: 1.0,
duration_s: 30.0,
..EtSimConfig::default()
};
let res = pack.simulate(&profile, &config).expect("pack sim failed");
assert!(
res.temp_spread_c < 1.0,
"Temp spread should be small for identical cells: {}",
res.temp_spread_c
);
}
#[test]
fn test_zero_current_no_heat() {
let sim = default_sim(30.0);
let res = sim.simulate_constant_current(0.0).expect("sim failed");
let last = res.states.last().expect("empty states");
assert!(
(last.temperature_c - 25.0).abs() < 0.01,
"Temperature should be constant with zero current: {}",
last.temperature_c
);
assert!(
res.total_heat_j.abs() < 1e-6,
"No heat should be generated with zero current"
);
}
#[test]
fn test_high_current_faster_soc() {
let sim_low = default_sim(60.0);
let sim_high = default_sim(60.0);
let res_low = sim_low.simulate_constant_current(1.0).expect("sim failed");
let res_high = sim_high.simulate_constant_current(5.0).expect("sim failed");
let dsoc_low = res_low.states.first().map(|s| s.soc).unwrap_or(0.8)
- res_low.states.last().map(|s| s.soc).unwrap_or(0.8);
let dsoc_high = res_high.states.first().map(|s| s.soc).unwrap_or(0.8)
- res_high.states.last().map(|s| s.soc).unwrap_or(0.8);
assert!(
dsoc_high > dsoc_low,
"Higher current should drain SoC faster"
);
}
#[test]
fn test_low_temp_higher_loss() {
let cold_sim = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::default_natural(),
EtSimConfig {
dt_s: 1.0,
duration_s: 30.0,
ambient_temp_c: 0.0,
initial_soc: 0.8,
initial_temp_c: 0.0,
},
);
let warm_sim = default_sim(30.0);
let cold_res = cold_sim.simulate_constant_current(3.0).expect("sim failed");
let warm_res = warm_sim.simulate_constant_current(3.0).expect("sim failed");
assert!(
cold_res.total_heat_j > warm_res.total_heat_j,
"Cold cell should have higher losses: {} vs {}",
cold_res.total_heat_j,
warm_res.total_heat_j
);
}
#[test]
fn test_drive_cycle_nonempty() {
let sim = default_sim(100.0);
let cycle = DriveCycle {
name: "test_cycle".to_string(),
power_steps: vec![
CurrentStep {
start_s: 0.0,
end_s: 20.0,
current_a: 5.0,
},
CurrentStep {
start_s: 20.0,
end_s: 40.0,
current_a: 0.0,
},
CurrentStep {
start_s: 40.0,
end_s: 60.0,
current_a: -3.0,
},
],
};
let res = sim
.simulate_drive_cycle(&cycle)
.expect("drive cycle failed");
assert!(!res.states.is_empty(), "Drive cycle should produce states");
assert!(res.states.len() > 3, "Should have multiple time steps");
}
#[test]
fn test_profile_multi_step() {
let sim = default_sim(100.0);
let profile = vec![
CurrentStep {
start_s: 0.0,
end_s: 30.0,
current_a: 2.0,
},
CurrentStep {
start_s: 30.0,
end_s: 60.0,
current_a: -1.0,
},
];
let res = sim.simulate_profile(&profile).expect("profile failed");
assert!(res.states.len() > 50, "Should have enough time steps");
}
#[test]
fn test_arrhenius_at_ref() {
let factor = ElectrothermalSimulator::arrhenius_factor(20000.0, 25.0, 25.0);
assert!(
(factor - 1.0).abs() < 1e-12,
"Arrhenius factor at T_ref should be 1.0: {}",
factor
);
}
#[test]
fn test_max_temp_bounded() {
let cell = default_cell();
let sim = ElectrothermalSimulator::new(
cell.clone(),
ThermalManagement::NaturalConvection {
h_conv: 0.0, area_m2: 0.004,
},
EtSimConfig {
dt_s: 1.0,
duration_s: 60.0,
ambient_temp_c: 25.0,
initial_soc: 0.8,
initial_temp_c: 25.0,
},
);
let res = sim.simulate_constant_current(3.0).expect("sim failed");
let max_rise = res.total_heat_j / (cell.mass_kg * cell.cp);
let actual_rise = res.max_temp_c - 25.0;
assert!(
actual_rise <= max_rise + 1.0,
"Temp rise {} should not greatly exceed theoretical max {}",
actual_rise,
max_rise
);
}
#[test]
fn test_soc_clamped() {
let sim = default_sim(7200.0); let res = sim.simulate_constant_current(5.0).expect("sim failed");
for s in &res.states {
assert!(
s.soc >= 0.0 && s.soc <= 1.0,
"SoC should be clamped: {}",
s.soc
);
}
}
#[test]
fn test_charge_increases_soc() {
let sim = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::default_natural(),
EtSimConfig {
dt_s: 1.0,
duration_s: 60.0,
ambient_temp_c: 25.0,
initial_soc: 0.3,
initial_temp_c: 25.0,
},
);
let res = sim.simulate_constant_current(-2.0).expect("sim failed");
let first_soc = res.states.first().map(|s| s.soc).unwrap_or(0.3);
let last_soc = res.states.last().map(|s| s.soc).unwrap_or(0.3);
assert!(last_soc > first_soc, "SoC should increase during charge");
}
#[test]
fn test_ocv_monotonic() {
let cell = default_cell();
let mut prev = ElectrothermalSimulator::ocv(0.0, cell.v_min, cell.v_max);
for i in 1..=100 {
let soc = i as f64 / 100.0;
let v = ElectrothermalSimulator::ocv(soc, cell.v_min, cell.v_max);
assert!(v >= prev - 1e-10, "OCV should be monotonic at SoC={}", soc);
prev = v;
}
}
#[test]
fn test_default_config() {
let sim = ElectrothermalSimulator::new(
default_cell(),
ThermalManagement::default_natural(),
EtSimConfig::default(),
);
let res = sim.simulate_constant_current(1.0).expect("sim failed");
assert!(!res.states.is_empty());
assert!(res.max_temp_c >= 25.0);
}
}