The Importances of Accurate Battery State of Charge (SOC) for Consumer

State of charge (SoC) is one of critical parmater of battery especially for the electric car consumer because their main concern during the drive is the remaining battery charge (usually shown in percentage units with 100% as fully charged and zero percent for no more electrical power left).

Dataset

Publicly available dataset is used and it can be fetched from following links:

  1. filename: 555_Dis_0p5C.csv, can be downloaded at https://data.mendeley.com/datasets/cp3473x7xv/3

Data assumptions based on the experiment description:

  1. Battery is charged after each test means the SoC is close to 1.0 or 0.99 before its discharged at 0.5c
  2. Battery cell capacity 3 ah
  3. Minimum Voltage 3.7v
  4. Maximum voltage 4.2v
  5. Dataset has been ordered based on the timestamp (ascending, older to newer) - means the first row shall have 0.99 SOC

Project Setup

Create new conda project

conda create --name battery-soc-kalman-filter python=3.12 

postimage100 Create conda project

activate the conda project

conda active battery-soc-kalman-filter

postimage100 Activate conda project using CLI

install kernel for jupyer so the conda environment (dedicated python core libraries for that specific conda project) can be registered. Note that the package installation process must be done once the conda environment has been activated. Otherwise the kernel can’t be registered to jupyter notebook.

conda install ipykernel

postimage100 Install ipykernal through conda

or it can also be installed using pip install

pip install ipykernel

once the installation of ipkernel is completed, execute following command to ensure the package installed properly on conda environment

pip list | grep "ipykernel"

it shown return the ipykernel library version on the console

postimage100 validate the installation status of ipykernel packages

register the kernel

python -m ipykernel install --user --name battery-soc-kalman-filter --display-name "Python (battery-soc-kalman-filter)"

postimage100 register new kernel that utilize python 3.12 from the newly created conda environment to jupyter notebook

launch jupyter lab (dont forget to open new terminal without conda environment) and open it inside the conda environment folder source directory

jupyter lab

postimage100 Launch jupyterlab from console or cli

now change the kernel into the newly created kernel (in this case, the kernel name is battery-soc-kalman-filter) then we’re all set.

postimage100 change to newly created kernel that use python 3.12

— python version 3.12 is recommended for data science as it has the most compatability with many data science packages like tensorflow, etc.

Kalman Filter Method

kalman filter is a method to estimate or predict or mesaure indirect battery SOC (state of charge) based on following telemtry parameter / tags

  1. voltage (v)
  2. current (ampere)
  3. temperature (celcius)

Utilizing python to estimate battery SOC using pandas

below are the steps to estimate battery SOC using several python libraries

Install pandas & matplotlib

Open a console with activated conda environment and execute following command

conda install pandas

postimage100 Install pandas to load and transform csv in dataframe

and below is the command to install matplotlib

pip install matplotlib

postimage100 Install matplotlib for data visualization

Remove unnecessary data

Kalman filter method only utilize three battery tags/telemtry including voltage, current and temperature.

load csv into pandas dataframe

import pandas as pds
battery_telematics_dataset = "./dataset/555_Dis_0p5C.csv"
battery_telematics_dataframe = pds.read_csv(battery_telematics_dataset)
print(battery_telematics_dataframe.head())

remove unncesary column from the data frame and should only retain the battery telematics data

cleansed_telematics_dataframe : pds.DataFrame = battery_telematics_dataframe.drop(["Step", "Status", "Prog Time","Step Time", "Cycle", "Cycle Level", "Cnt", "Procedure", "WhAccu", "Capacity", "Unnamed: 14"], axis=1)
dischargetelematics_dataframe : pds.DataFrame = cleansed_telematics_dataframe[cleansed_telematics_dataframe["Current"] < 0].reset_index(drop=True)
print(dischargetelematics_dataframe.head())

Coulomb counting SOC estimation

Kalman filter method require a pre-estimated battery SOC because kalman filter is focus on re-evaluating and correct the pre-estimated SOC and often paired with coulomb counting method.

battery_cell_capacity = 3.0         
sampling_time = 1/60     
initial_soc = 0.99

def estimate_soc(prev_soc, current):
    negated_current = -current
    estimated_soc = prev_soc - (negated_current*sampling_time)/battery_cell_capacity
    return estimated_soc

prev_soc = None
for i, row in dischargetelematics_dataframe.iterrows():
    if i == 0:
        dischargetelematics_dataframe.at[i, "soc_coulomb_counting"] = 0.99
        prev_soc = 0.99
    else:
        now_soc = estimate_soc(prev_soc, row["Current"])
        dischargetelematics_dataframe.at[i, "soc_coulomb_counting"] = now_soc
        prev_soc = now_soc
print(dischargetelematics_dataframe.head())

Coulomb Counting SOC correction with kalman-filter

Kalman filter genrally has lower corrected soc value compared to kalman filter because it accomodate drifting (correction of cumulative error) parameter.

battery_voltage_min = 3.7
battery_voltage_max = 4.2
uncertainty = 1e-4   # P
process_noise = 1e-5 # Q_k
measurement_noise = 1e-3 # R_k

def ocv(soc_coulomb_counting):
    return battery_voltage_min + soc_coulomb_counting * (battery_voltage_max - battery_voltage_min)

def kalman_filter(soc_coulomb_counting, now_voltage):
    predicted_uncertainty = uncertainty + process_noise
    predicted_voltage = ocv(soc_coulomb_counting)
    voltage_span = (battery_voltage_max - battery_voltage_min)
    kalman_gain = predicted_uncertainty * voltage_span / (voltage_span * predicted_uncertainty * voltage_span + measurement_noise)
    corrected_soc = soc_coulomb_counting + kalman_gain * (now_voltage - predicted_voltage)
    updated_uncertainty = (1 - kalman_gain * voltage_span) * predicted_uncertainty
    return corrected_soc, updated_uncertainty

for i, row in dischargetelematics_dataframe.iterrows():
    if i == 0:
        dischargetelematics_dataframe.at[i, "soc_kalman_filter"] = row["soc_coulomb_counting"]
    else:
        adjusted_soc, uncertainity = kalman_filter(row["soc_coulomb_counting"], row["Voltage"])
        dischargetelematics_dataframe.at[i, "soc_kalman_filter"] = adjusted_soc

print(dischargetelematics_dataframe.head())

Visualization

Both of coulomb counting SOC and kalman filter SOC will be displayed on different line in same chart to show the differences.

dischargetelematics_dataframe["Time Stamp"] = pds.to_datetime(
    dischargetelematics_dataframe["Time Stamp"]
)

import matplotlib.pyplot as plt

plt.figure(figsize=(10,5))

plt.plot(dischargetelematics_dataframe["Time Stamp"],
         dischargetelematics_dataframe["soc_coulomb_counting"],
         label="Coulomb Counting SOC")

plt.plot(dischargetelematics_dataframe["Time Stamp"],
         dischargetelematics_dataframe["soc_kalman_filter"],
         label="Kalman Filter SOC")

plt.legend()
plt.xticks(rotation=45)
plt.grid(True)

below are the visualization result

postimage100 Coulomb counting SOC vs Kalman Filter SOC Result

Jupyter notebook source code

below are the complete jupyter notebook source code

import pandas and load dataset into dataframe

import pandas as pds
battery_telematics_dataset = "./dataset/555_Dis_0p5C.csv"
battery_telematics_dataframe : pds.DataFrame = pds.read_csv(battery_telematics_dataset)
print(battery_telematics_dataframe.head())
             Time Stamp  Step Status  ...   WhAccu   Cnt  Unnamed: 14
0  11/5/2018 9:24:54 AM    28    DCH  ...  0.71189  14.0          NaN
1  11/5/2018 9:25:54 AM    28    DCH  ...  0.60916  14.0          NaN
2  11/5/2018 9:26:54 AM    28    DCH  ...  0.50682  14.0          NaN
3  11/5/2018 9:27:54 AM    28    DCH  ...  0.40478  14.0          NaN
4  11/5/2018 9:28:54 AM    28    DCH  ...  0.30295  14.0          NaN

[5 rows x 15 columns]

Remove unncesary column and retain only discharge telematics row

cleansed_telematics_dataframe : pds.DataFrame = battery_telematics_dataframe.drop(["Step", "Status", "Prog Time","Step Time", "Cycle", "Cycle Level", "Cnt", "Procedure", "WhAccu", "Capacity", "Unnamed: 14"], axis=1)
dischargetelematics_dataframe : pds.DataFrame = cleansed_telematics_dataframe[cleansed_telematics_dataframe["Current"] < 0].reset_index(drop=True)
print(dischargetelematics_dataframe.head())
             Time Stamp  Voltage  Current  Temperature
0  11/5/2018 9:24:54 AM  4.12328 -1.49925     38.38286
1  11/5/2018 9:25:54 AM  4.10457 -1.49925     38.59318
2  11/5/2018 9:26:54 AM  4.09075 -1.49670     38.69834
3  11/5/2018 9:27:54 AM  4.07980 -1.49925     38.38286
4  11/5/2018 9:28:54 AM  4.07103 -1.49925     38.38286

coulomb counting SOC estimation

battery_cell_capacity = 3.0         
sampling_time = 1/60     
initial_soc = 0.99

def estimate_soc(prev_soc, current):
    negated_current = -current
    estimated_soc = prev_soc - (negated_current*sampling_time)/battery_cell_capacity
    return estimated_soc

prev_soc = None
for i, row in dischargetelematics_dataframe.iterrows():
    if i == 0:
        dischargetelematics_dataframe.at[i, "soc_coulomb_counting"] = 0.99
        prev_soc = 0.99
    else:
        now_soc = estimate_soc(prev_soc, row["Current"])
        dischargetelematics_dataframe.at[i, "soc_coulomb_counting"] = now_soc
        prev_soc = now_soc
print(dischargetelematics_dataframe.head())
             Time Stamp  Voltage  Current  Temperature  soc_coulomb_counting
0  11/5/2018 9:24:54 AM  4.12328 -1.49925     38.38286              0.990000
1  11/5/2018 9:25:54 AM  4.10457 -1.49925     38.59318              0.981671
2  11/5/2018 9:26:54 AM  4.09075 -1.49670     38.69834              0.973356
3  11/5/2018 9:27:54 AM  4.07980 -1.49925     38.38286              0.965027
4  11/5/2018 9:28:54 AM  4.07103 -1.49925     38.38286              0.956697

Correct the estimated SOC from coulomb counting with kalman filter

battery_voltage_min = 3.7
battery_voltage_max = 4.2
uncertainty = 1e-4   # P
process_noise = 1e-5 # Q_k
measurement_noise = 1e-3 # R_k

def ocv(soc_coulomb_counting):
    return battery_voltage_min + soc_coulomb_counting * (battery_voltage_max - battery_voltage_min)

def kalman_filter(soc_coulomb_counting, now_voltage):
    predicted_uncertainty = uncertainty + process_noise
    predicted_voltage = ocv(soc_coulomb_counting)
    voltage_span = (battery_voltage_max - battery_voltage_min)
    kalman_gain = predicted_uncertainty * voltage_span / (voltage_span * predicted_uncertainty * voltage_span + measurement_noise)
    corrected_soc = soc_coulomb_counting + kalman_gain * (now_voltage - predicted_voltage)
    updated_uncertainty = (1 - kalman_gain * voltage_span) * predicted_uncertainty
    return corrected_soc, updated_uncertainty

for i, row in dischargetelematics_dataframe.iterrows():
    if i == 0:
        dischargetelematics_dataframe.at[i, "soc_kalman_filter"] = row["soc_coulomb_counting"]
    else:
        adjusted_soc, uncertainity = kalman_filter(row["soc_coulomb_counting"], row["Voltage"])
        dischargetelematics_dataframe.at[i, "soc_kalman_filter"] = adjusted_soc

print(dischargetelematics_dataframe.head())
             Time Stamp  Voltage  ...  soc_coulomb_counting  soc_kalman_filter
0  11/5/2018 9:24:54 AM  4.12328  ...              0.990000           0.990000
1  11/5/2018 9:25:54 AM  4.10457  ...              0.981671           0.977053
2  11/5/2018 9:26:54 AM  4.09075  ...              0.973356           0.968221
3  11/5/2018 9:27:54 AM  4.07980  ...              0.965027           0.959529
4  11/5/2018 9:28:54 AM  4.07103  ...              0.956697           0.950953

[5 rows x 6 columns]

Convert Timestmap to HH:II format and Visualize It

dischargetelematics_dataframe["Time Stamp"] = pds.to_datetime(
    dischargetelematics_dataframe["Time Stamp"]
)

import matplotlib.pyplot as plt

plt.figure(figsize=(10,5))

plt.plot(dischargetelematics_dataframe["Time Stamp"],
         dischargetelematics_dataframe["soc_coulomb_counting"],
         label="Coulomb Counting SOC")

plt.plot(dischargetelematics_dataframe["Time Stamp"],
         dischargetelematics_dataframe["soc_kalman_filter"],
         label="Kalman Filter SOC")

plt.legend()
plt.xticks(rotation=45)
plt.grid(True)

png