def _butler_volmer(i0, eta, T):
"""Butler-Volmer current density with symmetric transfer coefficients."""
f = _F / (_R_GAS * T)
arg_a = np.clip(_ALPHA_BV * f * eta, -_EXP_CLAMP, _EXP_CLAMP)
arg_c = np.clip(-_ALPHA_BV * f * eta, -_EXP_CLAMP, _EXP_CLAMP)
return i0 * (np.exp(arg_a) - np.exp(arg_c))
def _arrhenius_rate(A, E, T):
"""Safe Arrhenius rate: A * exp(-E/(R*T)) with clamped exponent."""
arg = np.clip(E / (_R_GAS * T), 0.0, _EXP_CLAMP)
return A * np.exp(-arg)
def _four_stage_decomposition(alpha_sei, alpha_ae, alpha_ca, alpha_el, T):
"""Compute the four decomposition rates and total volumetric heat.
Returns (d_sei, d_ae, d_ca, d_el, q_dot) where q_dot is W/kg (mass-specific).
"""
k_sei = _arrhenius_rate(_A_SEI, _E_SEI, T)
k_ae = _arrhenius_rate(_A_AE, _E_AE, T)
k_ca = _arrhenius_rate(_A_CA, _E_CA, T)
k_el = _arrhenius_rate(_A_EL, _E_EL, T)
# SEI: consumed (α decreases)
d_sei = -k_sei * alpha_sei
# Anode-electrolyte: consumed fraction increases
d_ae = k_ae * alpha_ae * (1.0 - alpha_ae)
# At α_ae=0 this would give zero rate, but the seed is the
# SEI decomposition products — use a small baseline nucleation
# once SEI has started decomposing.
if alpha_ae < 1e-12 and alpha_sei < 0.15 - 1e-6:
d_ae = k_ae * 1e-6
# Cathode: decomposed fraction increases
d_ca = k_ca * (1.0 - alpha_ca)
# Electrolyte: decomposed fraction increases
d_el = k_el * (1.0 - alpha_el)
q_dot = (_Q_SEI * _W_SEI * abs(d_sei)
+ _Q_AE * _W_AE * d_ae
+ _Q_CA * _W_CA * d_ca
+ _Q_EL * _W_EL * d_el)
return d_sei, d_ae, d_ca, d_el, q_dot
def _radial_diffusion_3node(c, D_s, r_p):
"""Spherical Fickian diffusion on 3 uniform radial nodes (center, mid, surface).
Returns dc/dt for the 3 nodes. Boundary: dc/dr=0 at center, zero-flux at surface.
"""
dr = r_p / 2.0
dc = np.zeros(3)
# center (symmetry BC): forward difference approximation
dc[0] = 6.0 * D_s * (c[1] - c[0]) / (dr * dr)
# mid-point
r_m = dr
flux_out = D_s * ((r_m + 0.5 * dr) ** 2) * (c[2] - c[1]) / dr
flux_in = D_s * ((r_m - 0.5 * dr) ** 2) * (c[1] - c[0]) / dr
dc[1] = (flux_out - flux_in) / (r_m * r_m * dr)
# surface (zero-flux BC at outer boundary for now)
r_s = r_p
flux_in_s = D_s * ((r_s - 0.5 * dr) ** 2) * (c[2] - c[1]) / dr
dc[2] = -flux_in_s / (r_s * r_s * dr)
return dc
def _echem_thermal_rhs(t, y):
# Decomposition fractions
alpha_sei = np.clip(y[0], 0.0, 1.0)
alpha_ae = np.clip(y[1], 0.0, 1.0)
alpha_ca = np.clip(y[2], 0.0, 1.0)
alpha_el = np.clip(y[3], 0.0, 1.0)
T = np.clip(y[4], 250.0, 2000.0)
# Butler-Volmer overpotentials (treated as state-like for stiffness)
eta_a = y[5]
eta_c = y[6]
# Li concentrations (3 radial nodes each)
c_a = np.clip(y[7:10], 0.0, _CS_MAX_A)
c_c = np.clip(y[10:13], 0.0, _CS_MAX_C)
Q_total = y[13]
P_gas = y[14]
R_int = y[15]
# --- 4-stage decomposition ---
d_sei, d_ae, d_ca, d_el, q_dot_decomp = _four_stage_decomposition(
alpha_sei, alpha_ae, alpha_ca, alpha_el, T,
)
# --- Butler-Volmer kinetics ---
i_bv_a = _butler_volmer(_I0_ANODE, eta_a, T)
i_bv_c = _butler_volmer(_I0_CATHODE, eta_c, T)
# Overpotential relaxation toward equilibrium (τ ~ RC time constant)
tau_relax = 1.0 # s
# Surface concentration deviation from equilibrium drives η
theta_a = c_a[2] / _CS_MAX_A
theta_c = c_c[2] / _CS_MAX_C
# OCV approximation (simplified lithium intercalation)
U_a = 0.6 - 0.5 * theta_a
U_c = 4.2 - 0.8 * theta_c
V_cell = U_c - U_a
d_eta_a = (-eta_a + (V_cell * 0.5 - U_a)) / tau_relax
d_eta_c = (-eta_c + (U_c - V_cell * 0.5)) / tau_relax
# --- Solid-phase diffusion ---
dc_a = _radial_diffusion_3node(c_a, _DS_ANODE, _RP_ANODE)
dc_c = _radial_diffusion_3node(c_c, _DS_CATHODE, _RP_CATHODE)
# Electrode coupling: BV current consumes/produces Li at surface node
# j_n = i_BV / F (flux in mol/(m²·s))
dc_a[2] += i_bv_a / _F
dc_c[2] -= i_bv_c / _F
# --- Temperature ---
q_echem = abs(i_bv_a * eta_a) + abs(i_bv_c * eta_c) # W/m² → scale to cell
q_gen = q_dot_decomp * _M_CELL + q_echem * _A_SURF
q_cool = _H_CONV * _A_SURF * (T - _T_AMB)
dT = (q_gen - q_cool) / (_M_CELL * _CP)
# Cumulative heat
dQ = q_gen
# Gas pressure
n_gas_max = 0.01
n_gas = alpha_el * n_gas_max
dn_gas = n_gas_max * d_el
dP = (dn_gas * _R_GAS * T + n_gas * _R_GAS * dT) / _V_HEAD
# Internal resistance
dR = _R0 * (0.5 * abs(d_sei) + 2.0 * d_ae)
dy = np.empty(16)
dy[0] = d_sei
dy[1] = d_ae
dy[2] = d_ca
dy[3] = d_el
dy[4] = dT
dy[5] = d_eta_a
dy[6] = d_eta_c
dy[7:10] = dc_a
dy[10:13] = dc_c
dy[13] = dQ
dy[14] = dP
dy[15] = dR
return dy