├── Readme.md └── Python ├── utils.py ├── kalman.py ├── protocol.py ├── battery.py └── main.py /Readme.md: -------------------------------------------------------------------------------- 1 | A simple and naive battery modelisation + Kalman filter for state of charge (SoC) estimation 2 | 3 | # Python implementation 4 | The "Python" folder contain a python3 implementation of an extended Kalman filter for state of charge (SoC) estimation of a simulated lithium battery. The model used for the battery is a simple Thevenin model. 5 | 6 | 7 | To run it just launch: 8 | ```sh 9 | $> python3 Python/main.py 10 | ``` 11 | 12 | A CC/CV charge and a pulse discharge will be simulated. Then a graph will be displayed showing simulation results. You can play around with the capacity and Thevenin model values in Python/main.py. The charge/discharge protocol can also be changed, values are found in Python/protocol.py. 13 | 14 | ## Dependencies: 15 | - python3 16 | - numpy 17 | - matplotlib 18 | -------------------------------------------------------------------------------- /Python/utils.py: -------------------------------------------------------------------------------- 1 | class Polynomial: 2 | def __init__(self, coeffs): 3 | self._coeffs = coeffs 4 | self._deg = len(coeffs) - 1 5 | 6 | def __call__(self, x): 7 | value = 0 8 | for i, coeff in enumerate(self._coeffs): 9 | value += coeff * x ** i 10 | return value 11 | 12 | @property 13 | def deriv(self): 14 | d_coeffs = [0]*self._deg 15 | for i in range(self._deg): 16 | d_coeffs[i] = (i+1)*self._coeffs[i+1] 17 | return Polynomial(d_coeffs) 18 | 19 | 20 | if __name__ == '__main__': 21 | my_poly = Polynomial([0]) 22 | my_poly_deriv = my_poly.deriv 23 | print(my_poly._coeffs) 24 | print(my_poly_deriv._coeffs) 25 | print("result : ", my_poly(1)) 26 | print("result : ", my_poly_deriv(1)) 27 | my_poly = Polynomial([1,2,3,4]) 28 | my_poly_deriv = my_poly.deriv 29 | print(my_poly._coeffs) 30 | print(my_poly_deriv._coeffs) 31 | print("result : ", my_poly(1)) 32 | print("result : ", my_poly_deriv(1)) 33 | -------------------------------------------------------------------------------- /Python/kalman.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from numpy import zeros, eye 3 | 4 | 5 | class ExtendedKalmanFilter(object): 6 | 7 | def __init__(self, x, F, B, P, Q, R, Hx, HJacobian): 8 | 9 | self._x = x 10 | self._F = F 11 | self._B = B 12 | self._P = P 13 | self._Q = Q 14 | self._R = R 15 | self._Hx = Hx 16 | self._HJacobian = HJacobian 17 | 18 | 19 | def update(self, z): 20 | 21 | P = self._P 22 | R = self._R 23 | x = self._x 24 | 25 | H = self._HJacobian(x) 26 | 27 | S = H * P * H.T + R 28 | K = P * H.T * S.I 29 | self._K = K 30 | 31 | hx = self._Hx(x) 32 | y = np.subtract(z, hx) 33 | self._x = x + K * y 34 | 35 | KH = K * H 36 | I_KH = np.identity((KH).shape[1]) - KH 37 | self._P = I_KH * P * I_KH.T + K * R * K.T 38 | 39 | def predict(self, u=0): 40 | self._x = self._F * self._x + self._B * u 41 | self._P = self._F * self._P * self._F.T + self._Q 42 | 43 | @property 44 | def x(self): 45 | return self._x 46 | -------------------------------------------------------------------------------- /Python/protocol.py: -------------------------------------------------------------------------------- 1 | def launch_experiment_protocol(Q_tot, time_step, experiment_callback): 2 | 3 | charge_current_rate = 0.5 #C 4 | discharge_current_rate = 1 #C 5 | discharge_constants_stages_time = 20*60 #s 6 | pulse_time = 60 #s 7 | total_pulse_time = 40*60 #s 8 | 9 | high_cut_off_voltage = 4.2 10 | low_cut_off_voltage = 2.5 11 | 12 | #charge CC 13 | current = -charge_current_rate * Q_tot 14 | voltage = 0 15 | while voltage < high_cut_off_voltage: 16 | voltage = experiment_callback(current) 17 | 18 | #charge CV 19 | while current < -0.1: 20 | #pseudo current control to simulate CV charge 21 | if voltage > high_cut_off_voltage*1.001: 22 | current += 0.01 * Q_tot 23 | # if battery_simulation.voltage < high_cut_off_voltage*0.999: 24 | # current += 0.02 * Q_tot 25 | voltage = experiment_callback(current) 26 | 27 | #discharge first stage 28 | time = 0 29 | current = discharge_current_rate * Q_tot 30 | while time < discharge_constants_stages_time: 31 | experiment_callback(current) 32 | time += time_step 33 | 34 | #discharge pulses stage 35 | time = 0 36 | while time < total_pulse_time: 37 | time_low = 0 38 | current = 0 39 | while time_low < pulse_time: 40 | experiment_callback(current) 41 | time_low += time_step 42 | time_high = 0 43 | current = discharge_current_rate * Q_tot 44 | while time_high < pulse_time: 45 | experiment_callback(current) 46 | time_high += time_step 47 | time += time_low + time_high 48 | 49 | #discharge last stage 50 | time = 0 51 | current = discharge_current_rate * Q_tot 52 | while time < discharge_constants_stages_time: 53 | experiment_callback(current) 54 | time += time_step 55 | -------------------------------------------------------------------------------- /Python/battery.py: -------------------------------------------------------------------------------- 1 | import math as m 2 | from utils import Polynomial 3 | 4 | 5 | class Battery: 6 | # capacity in Ah 7 | def __init__(self, total_capacity, R0, R1, C1): 8 | # capacity in As 9 | self.total_capacity = total_capacity * 3600 10 | self.actual_capacity = self.total_capacity 11 | 12 | # Thevenin model : OCV + R0 + R1//C1 13 | self.R0 = R0 14 | self.R1 = R1 15 | self.C1 = C1 16 | 17 | self._current = 0 18 | self._RC_voltage = 0 19 | 20 | # polynomial representation of OCV vs SoC 21 | self._OCV_model = Polynomial([3.1400, 3.9905, -14.2391, 24.4140, -13.5688, -4.0621, 4.5056]) 22 | 23 | def update(self, time_delta): 24 | self.actual_capacity -= self.current * time_delta 25 | exp_coeff = m.exp(-time_delta/(self.R1*self.C1)) 26 | self._RC_voltage *= exp_coeff 27 | self._RC_voltage += self.R1*(1-exp_coeff)*self.current 28 | 29 | @property 30 | def current(self): 31 | return self._current 32 | 33 | @current.setter 34 | def current(self, current): 35 | self._current = current 36 | 37 | @property 38 | def voltage(self): 39 | return self.OCV - self.R0 * self.current - self._RC_voltage 40 | 41 | @property 42 | def state_of_charge(self): 43 | return self.actual_capacity/self.total_capacity 44 | 45 | @property 46 | def OCV_model(self): 47 | return self._OCV_model 48 | 49 | @property 50 | def OCV(self): 51 | return self.OCV_model(self.state_of_charge) 52 | 53 | 54 | if __name__ == '__main__': 55 | capacity = 3.2 #Ah 56 | discharge_rate = 1 #C 57 | time_step = 10 #s 58 | cut_off_voltage = 2.5 59 | 60 | 61 | current = capacity*discharge_rate 62 | my_battery = Battery(capacity, 0.062, 0.01, 3000) 63 | my_battery.current = current 64 | 65 | time = [0] 66 | SoC = [my_battery.state_of_charge] 67 | OCV = [my_battery.OCV] 68 | RC_voltage = [my_battery._RC_voltage] 69 | voltage = [my_battery.voltage] 70 | 71 | while my_battery.voltage>cut_off_voltage: 72 | my_battery.update(time_step) 73 | time.append(time[-1]+time_step) 74 | SoC.append(my_battery.state_of_charge) 75 | OCV.append(my_battery.OCV) 76 | RC_voltage.append(my_battery._RC_voltage) 77 | voltage.append(my_battery.voltage) 78 | # print(time[-1], my_battery.state_of_charge, my_battery._OCV, my_battery.voltage) 79 | 80 | import matplotlib.pyplot as plt 81 | 82 | fig = plt.figure() 83 | ax1 = fig.add_subplot(111) 84 | 85 | # title, labels 86 | ax1.set_title('') 87 | ax1.set_xlabel('SoC') 88 | ax1.set_ylabel('Voltage') 89 | 90 | ax1.plot(SoC, OCV, label="OCV") 91 | ax1.plot(SoC, voltage, label="Total voltage") 92 | 93 | plt.show() 94 | 95 | 96 | -------------------------------------------------------------------------------- /Python/main.py: -------------------------------------------------------------------------------- 1 | from battery import Battery 2 | from kalman import ExtendedKalmanFilter as EKF 3 | from protocol import launch_experiment_protocol 4 | import numpy as np 5 | import math as m 6 | 7 | 8 | def get_EKF(R0, R1, C1, std_dev, time_step): 9 | # initial state (SoC is intentionally set to a wrong value) 10 | # x = [[SoC], [RC voltage]] 11 | x = np.matrix([[0.5],\ 12 | [0.0]]) 13 | 14 | exp_coeff = m.exp(-time_step/(C1*R1)) 15 | 16 | # state transition model 17 | F = np.matrix([[1, 0 ],\ 18 | [0, exp_coeff]]) 19 | 20 | # control-input model 21 | B = np.matrix([[-time_step/(Q_tot * 3600)],\ 22 | [ R1*(1-exp_coeff)]]) 23 | 24 | # variance from std_dev 25 | var = std_dev ** 2 26 | 27 | # measurement noise 28 | R = var 29 | 30 | # state covariance 31 | P = np.matrix([[var, 0],\ 32 | [0, var]]) 33 | 34 | # process noise covariance matrix 35 | Q = np.matrix([[var/50, 0],\ 36 | [0, var/50]]) 37 | 38 | def HJacobian(x): 39 | return np.matrix([[battery_simulation.OCV_model.deriv(x[0,0]), -1]]) 40 | 41 | def Hx(x): 42 | return battery_simulation.OCV_model(x[0,0]) - x[1,0] 43 | 44 | return EKF(x, F, B, P, Q, R, Hx, HJacobian) 45 | 46 | 47 | def plot_everything(time, true_voltage, mes_voltage, true_SoC, estim_SoC, current): 48 | import matplotlib.pyplot as plt 49 | 50 | fig = plt.figure() 51 | ax1 = fig.add_subplot(311) 52 | ax2 = fig.add_subplot(312) 53 | ax3 = fig.add_subplot(313) 54 | 55 | # title, labels 56 | ax1.set_title('') 57 | ax1.set_xlabel('Time / s') 58 | ax1.set_ylabel('voltage / V') 59 | ax2.set_xlabel('Time / s') 60 | ax2.set_ylabel('Soc') 61 | ax3.set_xlabel('Time / s') 62 | ax3.set_ylabel('Current / A') 63 | 64 | 65 | ax1.plot(time, true_voltage, label="True voltage") 66 | ax1.plot(time, mes_voltage, label="Mesured voltage") 67 | ax2.plot(time, true_SoC, label="True SoC") 68 | ax2.plot(time, estim_SoC, label="Estimated SoC") 69 | ax3.plot(time, current, label="Current") 70 | 71 | ax1.legend() 72 | ax2.legend() 73 | ax3.legend() 74 | 75 | plt.show() 76 | 77 | 78 | if __name__ == '__main__': 79 | # total capacity 80 | Q_tot = 3.2 81 | 82 | # Thevenin model values 83 | R0 = 0.062 84 | R1 = 0.01 85 | C1 = 3000 86 | 87 | # time period 88 | time_step = 10 89 | 90 | battery_simulation = Battery(Q_tot, R0, R1, C1) 91 | 92 | # discharged battery 93 | battery_simulation.actual_capacity = 0 94 | 95 | # measurement noise standard deviation 96 | std_dev = 0.015 97 | 98 | #get configured EKF 99 | Kf = get_EKF(R0, R1, C1, std_dev, time_step) 100 | 101 | time = [0] 102 | true_SoC = [battery_simulation.state_of_charge] 103 | estim_SoC = [Kf.x[0,0]] 104 | true_voltage = [battery_simulation.voltage] 105 | mes_voltage = [battery_simulation.voltage + np.random.normal(0,0.1,1)[0]] 106 | current = [battery_simulation.current] 107 | 108 | def update_all(actual_current): 109 | battery_simulation.current = actual_current 110 | battery_simulation.update(time_step) 111 | 112 | time.append(time[-1]+time_step) 113 | current.append(actual_current) 114 | 115 | true_voltage.append(battery_simulation.voltage) 116 | mes_voltage.append(battery_simulation.voltage + np.random.normal(0, std_dev, 1)[0]) 117 | 118 | Kf.predict(u=actual_current) 119 | Kf.update(mes_voltage[-1] + R0 * actual_current) 120 | 121 | true_SoC.append(battery_simulation.state_of_charge) 122 | estim_SoC.append(Kf.x[0,0]) 123 | 124 | return battery_simulation.voltage #mes_voltage[-1] 125 | 126 | # launch experiment 127 | launch_experiment_protocol(Q_tot, time_step, update_all) 128 | 129 | # plot stuff 130 | plot_everything(time, true_voltage, mes_voltage, true_SoC, estim_SoC, current) 131 | --------------------------------------------------------------------------------