If you have ever bench-tested a battery pack or evaluated an electric vehicle prototype, you have probably run into this problem: the dashboard displays 45% State of Charge (SoC), but three minutes of hard acceleration later, the battery management system trips an undervoltage alarm and the system shuts down.
The root cause almost always traces back to naive current integration, commonly called Coulomb counting. While current integration is simple to compute, deploying it on physical battery cells exposes major electrochemical flaws.
Below is an analysis of why current integration fails in hardware, how a second-order (2RC) Thevenin equivalent circuit fixes the model, and how to write and tune an Extended Kalman Filter (EKF) in MATLAB and Simulink.
Why Coulomb counting fails in real battery packs
Coulomb counting calculates remaining capacity by integrating current over time:
SoC(t) = SoC(0) - (1 / Qn) ∫ [η · I(τ)] dτ
Where Qn is nominal capacity in Ampere-seconds, I(τ) is load current (positive during discharge), and η is Coulombic efficiency.
In an ideal simulation without noise, this integration works. On an actual vehicle circuit board, three issues break it:
- Current sensor bias drift: Low-cost shunt or Hall-effect sensors always carry a slight DC offset. A bias of just 25 mA over four hours of driving accumulates into a 100 mAh estimation error. Over repeated discharge cycles without a full-charge reset, this open-loop integrator drifts 10% to 25% away from true capacity.
- The LiFePO4 flat voltage plateau: Lithium iron phosphate cells have an open-circuit voltage curve that stays almost completely flat between 20% and 80% SoC. A tiny 1 mV measurement error inside that zone results in a 10% to 15% error in estimated capacity. Taking static open-circuit voltage snapshots during brief pauses cannot reliably reset the counter.
- Capacity fade and temperature dependency: True capacity (
Qn) is not constant. It shrinks at lower ambient temperatures and degrades as the cell ages. Because standard Coulomb counting treatsQnas a static number in memory, the math drifts further as the pack cycles.
Open-loop integration cannot correct itself. To solve this, you need a closed-loop observer that models internal electrochemical dynamics and balances voltage readings against current integration. That is what an Extended Kalman Filter does.
The 2RC Thevenin equivalent circuit model
Before applying a Kalman filter, you must model cell impedance. A simple internal resistance ignores dynamic polarization, while an electrochemical PDE model is too computationally heavy for an automotive microcontroller. A 2RC Thevenin circuit hits the sweet spot:
- Voc(SoC): The open-circuit voltage, which changes non-linearly with battery charge.
- R0: Ohmic resistance across the electrolyte, current collectors, and terminals. This explains immediate voltage drops during sudden acceleration or regenerative braking.
- R1 || C1: Fast polarization pair modeling charge-transfer resistance and double-layer capacitance. Time constant: milliseconds to seconds.
- R2 || C2: Slow polarization pair modeling diffusion impedance inside the active materials. Time constant: tens of seconds to minutes.
The terminal voltage (Vt) measured at the cell terminals is expressed as:
Vt = Voc(SoC) - I · R0 - V1 - V2
Where V1 and V2 represent the transient voltages across the two RC branches.
State-space representation for EKF
The state vector defines the internal conditions of the cell:
x = [ SoC ; V1 ; V2 ]
Converted to discrete time with sampling interval Ts, the linear state transitions are:
SoC(k+1) = SoC(k) - (η · Ts / Qn) · I(k) + w1(k)
V1(k+1) = exp(-Ts / (R1C1)) · V1(k) + R1(1 - exp(-Ts / (R1C1))) · I(k) + w2(k)
V2(k+1) = exp(-Ts / (R2C2)) · V2(k) + R2(1 - exp(-Ts / (R2C2))) · I(k) + w3(k)
The non-linearity exists in the output equation because open-circuit voltage Voc(SoC) is a non-linear curve:
y(k) = Vt(k) = Voc(SoC(k)) - I(k) · R0 - V1(k) - V2(k) + v(k)
Here, w(k) ~ N(0, Q) is process noise (sensor inaccuracies, parameter shifts) and v(k) ~ N(0, R) is measurement noise from the ADC voltage input.
Because Voc is non-linear, the filter linearizes the output matrix at every execution step using the partial derivative of open-circuit voltage with respect to SoC:
Ck = [ dVoc / dSoC |x̂k- , -1 , -1 ]
Step-by-step EKF recursion cycle
At every sample interval Ts, the filter executes two sequential phases:
Phase 1: Time update (Prediction)
- Predict states using the system model and measured current:
x̂k- = A · x̂k-1 + B · Ik-1 - Predict error covariance:
Pk- = A · Pk-1 · AT + Q
Phase 2: Measurement update (Correction)
- Compute the Jacobian row vector
Ckfrom the local slope of the OCV curve. - Calculate the Kalman Gain:
Kk = Pk- CkT [ Ck Pk- CkT + R ]-1 - Correct the state estimate using the voltage residual (measured terminal voltage minus predicted terminal voltage):
x̂k = x̂k- + Kk · [ Vt,k - V̂t,k ] - Update error covariance for the next cycle:
Pk = (I - Kk Ck) · Pk-
If your current sensor has a DC offset, the state prediction starts to drift. But the moment the predicted voltage diverges from the physical ADC voltage measurement, the innovation residual forces the state estimate back on track.
Tuning the Q and R covariance matrices
Tuning the noise covariance matrices determines how effectively the filter balances measurement confidence against model confidence:
- If R is set too small: The filter over-trusts the voltage readings. Any transient voltage drop caused by sudden current pulses will cause erratic oscillations in your estimated SoC.
- If R is set too large: The filter ignores voltage corrections and behaves like basic Coulomb counting, drifting with sensor errors.
- Practical starting values: Set
R = σv2, whereσvis the standard deviation of your ADC voltage noise under load. SetQ(1,1)small (around1e-7) to reflect slow capacity depletion, and setQ(2,2)andQ(3,3)slightly higher (around1e-5) to give the polarization states flexibility to adapt.
Final engineering summary
Coulomb counting is an open-loop integrator, not an estimation algorithm. Any persistent sensor bias or thermal variation will accumulate error indefinitely. For high-reliability battery packs, combining a 2RC equivalent circuit model with an Extended Kalman Filter provides bounded estimation error, automated correction from poor starting values, and resilience against sensor noise.
Executable MATLAB Script & Model Setup
% Extended Kalman Filter for Battery SoC Estimation
% MatlabSolutions Practical Engineering Series
clear; clc; close all;
%% 1. Cell Parameters (Nominal 3.2V 2.5Ah LiFePO4 Cell)
Ts = 1; % Sampling time: 1 second
Q_nominal_Ah = 2.5; % Nominal capacity in Ah
Qn = Q_nominal_Ah * 3600; % Capacity in Ampere-seconds (Coulombs)
R0 = 0.035; % Ohmic resistance (Ohms)
R1 = 0.015; C1 = 1200; % First RC pair (Fast dynamics)
R2 = 0.025; C2 = 4500; % Second RC pair (Slow diffusion)
% Discrete state-space matrices
A_d = diag([1, exp(-Ts/(R1*C1)), exp(-Ts/(R2*C2))]);
B_d = [-Ts/Qn; R1*(1 - exp(-Ts/(R1*C1))); R2*(1 - exp(-Ts/(R2*C2)))];
%% 2. Polynomial Approximation of OCV vs SoC
% Voc = p1*s^4 + p2*s^3 + p3*s^2 + p4*s + p5
p = [-1.82, 4.31, -3.22, 1.28, 2.95];
p_der = polyder(p); % Analytical derivative for Jacobian C_k
%% 3. Simulation Setup (5000 seconds of pulse discharge)
t = 0:Ts:5000;
N = length(t);
% Create synthetic dynamic load current (positive = discharge)
I_true = 2.0 + 1.5 * sin(2*pi*t/600) + 0.8 * square(2*pi*t/250);
I_true(I_true < 0) = 0.2; % keep current mostly discharging
% Inject realistic sensor errors
I_measured = I_true + 0.08 + 0.04 * randn(1, N); % 80mA bias + Gaussian noise
V_noise_std = 0.005; % 5mV voltage measurement noise
%% 4. Initialize True Battery State & Estimator Variables
x_true = [0.95; 0.0; 0.0]; % True starting SoC: 95%
x_ekf = [0.60; 0.0; 0.0]; % Intentionally biased initial guess: 60%
x_coulomb = x_ekf(1); % Coulomb counting initialized with wrong guess
P = diag([1e-2, 1e-4, 1e-4]); % Initial estimation error covariance
Q = diag([1e-7, 1e-5, 1e-5]); % Process noise covariance
R = (V_noise_std)^2; % Measurement noise covariance
% Storage arrays
soc_true_hist = zeros(1, N);
soc_ekf_hist = zeros(1, N);
soc_cc_hist = zeros(1, N);
Vt_meas_hist = zeros(1, N);
%% 5. Main Simulation Loop
for k = 1:N
ik = I_true(k);
% A. Simulate Real Battery Physics
Voc_actual = polyval(p, x_true(1));
Vt_true = Voc_actual - ik*R0 - x_true(2) - x_true(3);
Vt_measured = Vt_true + V_noise_std * randn;
% Update true state for next step
x_true = A_d * x_true + B_d * ik;
x_true(1) = max(0, min(1, x_true(1)));
% B. Open-Loop Coulomb Counting (Drifting)
x_coulomb = x_coulomb - (Ts / Qn) * I_measured(k);
% C. Extended Kalman Filter (EKF)
% 1) Time update (Prediction)
x_pred = A_d * x_ekf + B_d * I_measured(k);
x_pred(1) = max(0, min(1, x_pred(1)));
P_pred = A_d * P * A_d' + Q;
% 2) Measurement update (Linearize OCV and correct)
dVoc_dSoC = polyval(p_der, x_pred(1));
C_k = [dVoc_dSoC, -1, -1];
Voc_pred = polyval(p, x_pred(1));
Vt_pred = Voc_pred - I_measured(k)*R0 - x_pred(2) - x_pred(3);
innovation = Vt_measured - Vt_pred;
S = C_k * P_pred * C_k' + R;
K = (P_pred * C_k') / S;
x_ekf = x_pred + K * innovation;
x_ekf(1) = max(0, min(1, x_ekf(1))); % Constrain SoC between [0, 1]
P = (eye(3) - K * C_k) * P_pred;
% Log states
soc_true_hist(k) = x_true(1);
soc_ekf_hist(k) = x_ekf(1);
soc_cc_hist(k) = x_coulomb;
Vt_meas_hist(k) = Vt_measured;
end
%% 6. Plotting Results
figure('Name', 'EKF vs Coulomb Counting', 'Color', 'w');
subplot(2,1,1);
plot(t/60, soc_true_hist*100, 'k', 'LineWidth', 2); hold on;
plot(t/60, soc_ekf_hist*100, 'b--', 'LineWidth', 1.8);
plot(t/60, soc_cc_hist*100, 'r:', 'LineWidth', 1.8);
grid on;
ylabel('State of Charge (%)');
legend('Ground Truth SoC', 'EKF Estimate (Self-Correcting)', 'Coulomb Counting (Drifting)', 'Location', 'northeast');
title('Battery SoC Estimation: Extended Kalman Filter vs Open-Loop Integration');
subplot(2,1,2);
plot(t/60, abs(soc_true_hist - soc_ekf_hist)*100, 'b', 'LineWidth', 1.5); hold on;
plot(t/60, abs(soc_true_hist - soc_cc_hist)*100, 'r', 'LineWidth', 1.5);
grid on;
xlabel('Time (Minutes)');
ylabel('Absolute Error (%)');
legend('EKF Absolute Error', 'Coulomb Counting Error', 'Location', 'northwest');
Related Verified MATLAB & Simulink Projects
Need pre-built, debugged Simulink models with complete parameter initialization scripts and documentation? Explore top related solutions:
Common Engineering Troubleshooting & Q&A
Frequently encountered bugs, solver convergence issues, and implementation questions answered by our engineering mentors:
Recommended Engineering Articles
Need Custom MATLAB / Simulink Implementation?
Our team of PhD engineers build custom simulation plants, train machine learning agents, tune PID/MPC controllers, and deliver complete, executable code with Turnitin reports.