Demonstrate ECM Extractors#

The ECM extractors assemble the required components for Equivalent Circuit model using reference performance test data.

[1]:
%matplotlib inline
from matplotlib import pyplot as plt
from battdat.data import BatteryDataset, CellDataset
import numpy as np

Load an Example Dataset#

We use an example RPT cycle from the CAMP 2023 dataset to demonstrate parameter extraction

[2]:
data = BatteryDataset.from_hdf('files/example-camp-rpt.h5')
[3]:
data.tables['raw_data']
[3]:
cycle_number file_number test_time state current voltage step_index method substep_index cycle_capacity cycle_energy
0 1 0 120.042 b'charging' 0.260548 3.450065 0 b'constant_current' 0 0.000000 0.000000
1 1 0 139.182 b'charging' 0.260090 3.470207 0 b'constant_current' 0 0.001384 0.004789
2 1 0 439.182 b'charging' 0.260014 3.487297 0 b'constant_current' 0 0.023055 0.080177
3 1 0 739.182 b'charging' 0.259937 3.493553 0 b'constant_current' 0 0.044720 0.155796
4 1 0 1039.182 b'charging' 0.260166 3.499809 0 b'constant_current' 0 0.066391 0.231572
... ... ... ... ... ... ... ... ... ... ... ...
180 1 0 41767.320 b'hold' 0.000000 3.272145 3 b'rest' 8 -0.027665 0.011427
181 1 0 41777.322 b'hold' 0.000000 3.276875 3 b'rest' 8 -0.027665 0.011427
182 1 0 41787.318 b'hold' 0.000000 3.280995 3 b'rest' 8 -0.027665 0.011427
183 1 0 41797.320 b'hold' 0.000000 3.284504 3 b'rest' 8 -0.027665 0.011427
184 1 0 41807.310 b'hold' 0.000000 3.287556 3 b'rest' 8 -0.027665 0.011427

185 rows × 11 columns

The dataset contain a single slow cycle that samples the entire capacity of the cell.

[4]:
fig, axs = plt.subplots(2, 1, figsize=(3.5, 3.), sharex=True)

raw_data = data.tables['raw_data']
time = (raw_data['test_time'] - raw_data['test_time'].min()) / 3600
axs[0].plot(time, raw_data['voltage'])
axs[0].set_ylabel('Voltage (V)')
axs[1].plot(time, raw_data['current'])
axs[1].set_ylabel('Current (A)')
axs[1].set_xlabel('Time (hr)')
[4]:
Text(0.5, 0, 'Time (hr)')
../_images/extractors_demonstrate-ecm-extractors_6_1.png

Determining Capacity#

The MaxCapacityExtractor determines capacity by integrating current over time then measuring difference between the maximum and minimum change in charge state.

Note: Integration is actually implemented in battdat.

[5]:
from moirae.extractors.ecm import MaxCapacityExtractor
[6]:
cap = MaxCapacityExtractor().extract(data)
cap
[6]:
MaxTheoreticalCapacity(updatable=set(), base_values=array([[1.48220808]]))

This should match up with the measured change in charge

[7]:
fig, ax = plt.subplots(figsize=(3.5, 2.))

ax.plot(time, raw_data['cycle_capacity'])
ax.set_xlim(ax.get_xlim())
ax.set_ylabel('Capacity Change (A-hr)')
ax.set_xlabel('Time (hr)')

min_cap, max_cap = raw_data['cycle_capacity'].min(), raw_data['cycle_capacity'].max()
ax.fill_between(ax.get_xlim(), min_cap, max_cap, alpha=0.5, color='red', edgecolor='none')

[7]:
<matplotlib.collections.FillBetweenPolyCollection at 0x7f8411bf7bb0>
../_images/extractors_demonstrate-ecm-extractors_11_1.png

The width of the colored span is the estimated capacity and it spans from the lowest capacity (~11hr) and the highest (~6hr)

Open-Circuit Voltage Estimation#

The OCVExtractor determines the Open Circuit Voltage by fitting a spline to voltage as a function of state of charge.

The first step is to compute the SOC from the capacity change during the cycle and the total capacity estimated by MaxCapacityExtractor. As the actual state of charge cannot be directly measured, we assume the lowest capacity change is an SOC of 0.

[8]:
raw_data['soc'] = (raw_data['cycle_capacity'] - raw_data['cycle_capacity'].min()) / cap.base_values.item()
[9]:
fig, ax = plt.subplots(figsize=(3.5, 2.))

ax.plot(raw_data['soc'], raw_data['voltage'])
ax.set_ylabel('Voltage (V)')
ax.set_xlabel('SOC')
[9]:
Text(0.5, 0, 'SOC')
../_images/extractors_demonstrate-ecm-extractors_15_1.png

The voltage during charge and discharge are different. Moirae accounts for this by weighing points with lower current more strongly and fitting a smoothing spline.

Note: Make the spline fit data more closely by increating the number of SOC points

[10]:
from moirae.extractors.ecm import OCVExtractor
ocv = OCVExtractor(capacity=cap).extract(data)
[11]:
fig, ax = plt.subplots(figsize=(3.5, 2.))

ax.plot(raw_data['soc'], raw_data['voltage'], label='Data')
soc = np.linspace(0, 1, 64)
fit = ocv(soc)
ax.plot(soc, fit[0, 0, :], 'r--', label='OCV')
ax.set_ylabel('Voltage (V)')
ax.set_xlabel('SOC')

ax.legend()
[11]:
<matplotlib.legend.Legend at 0x7f84118494b0>
../_images/extractors_demonstrate-ecm-extractors_18_1.png

This yields a generally-good representation of the OCV.

Instantaneous Resistance (R0) Extractor#

The R0Extractor estimates the instantaneous resistance in the battery as a function of SOC from current steps observed in the current-voltage-time data.

We need to load a CellDataset that contains such details to demonstrate the usage of the extractor. In this case, we use a simulated dataset that represents a hybrid pulse power characterization test commonly used for electric vehicles.

[12]:
data = BatteryDataset.from_hdf('files/hppc.h5')
data.tables['raw_data']['state'] = data.tables['raw_data']['state'].str.decode('utf-8')

First, we visualize the simulated data:

[13]:
fig, axs = plt.subplots(2, 1, figsize=(3.5, 3.), sharex=True)

raw_data = data.tables['raw_data']
time = (raw_data['test_time'] - raw_data['test_time'].min()) / 3600
axs[0].plot(time, raw_data['voltage'])
axs[0].set_ylabel('Voltage (V)')
axs[1].plot(time, raw_data['current'])
axs[1].set_ylabel('Current (A)')
axs[1].set_xlabel('Time (hr)')
[13]:
Text(0.5, 0, 'Time (hr)')
../_images/extractors_demonstrate-ecm-extractors_23_1.png

Now we employ the extractor to identify the current steps observed in the raw data and estimate the resistance as a function of SOC. The extractor takes as input an optional parameter, dt_max, that determines the maximum allowable timestep for the computation of the R0. We set this to 11 seconds, as the data collection frequency for the dataset is 10 seconds. In real applications, a much smaller dt_max is recommended.

[14]:
from moirae.extractors.ecm import R0Extractor
ext = R0Extractor(capacity=10, dt_max=11)
r0 = ext.extract(data)
[15]:
fig, ax = plt.subplots(figsize=(3.5, 2.))
soc = np.linspace(0, 1, 64)
fit = r0.get_value(soc)
ax.plot(soc, 0.05*np.ones(soc.shape), 'b-', label='ground truth')
ax.plot(soc, fit[0, 0, :], 'r--', label='fit')
ax.set_ylabel('R0 (Ohms)')
ax.set_xlabel('SOC')
ax.set_xlim(0, 1)
ax.set_ylim(0, 0.07)
ax.legend()
[15]:
<matplotlib.legend.Legend at 0x7f8411b41990>
../_images/extractors_demonstrate-ecm-extractors_26_1.png

The extracted R0 is close to the ground truth, with less than 1% error across the SOC range.

Resistor-Capacitor (RC) Couple Extractor#

The RCExtractor estimates the parameters of the RC couples as a function of SOC from post-discharge or charge rests observed in the current-voltage-time data.

We will load a version of the HPPC dataset used for the previous example that contains a single RC pair and higher sampling rate.

[16]:
data = CellDataset.from_hdf('files/hppc_1rc.h5')
data.tables['raw_data']['state'] = data.tables['raw_data']['state'].str.decode('utf-8')

First, we visualize the simulated data:

[17]:
fig, axs = plt.subplots(2, 1, figsize=(3.5, 3.), sharex=True)

raw_data = data.tables['raw_data']
time = (raw_data['test_time'] - raw_data['test_time'].min()) / 3600
axs[0].plot(time, raw_data['voltage'])
axs[0].set_ylabel('Voltage (V)')
axs[1].plot(time, raw_data['current'])
axs[1].set_ylabel('Current (A)')
axs[1].set_xlabel('Time (hr)')

fig, ax = plt.subplots(figsize=(3.5, 2.))
ax.plot(time.iloc[50000:52000], raw_data['voltage'].iloc[50000:52000])
ax.set_ylabel('Voltage (V)')
ax.set_xlabel('Time (hrs)')
[17]:
Text(0.5, 0, 'Time (hrs)')
../_images/extractors_demonstrate-ecm-extractors_31_1.png
../_images/extractors_demonstrate-ecm-extractors_31_2.png

Now we employ the extractor to identify the relevant rests observed in the raw data and estimate the RC parameters as a function of SOC.

[18]:
from moirae.extractors.ecm import RCExtractor
from battdat.postprocess.integral import StateOfCharge, CapacityPerCycle
ext = RCExtractor(capacity=10)
StateOfCharge().enhance(data.tables['raw_data'])
rc = ext.extract(data)
[19]:
fig, axs = plt.subplots(2, 1, figsize=(3.5, 4.))

soc = np.linspace(0, 1, 64)
fit = rc[0].get_value(soc)

axs[0].plot(soc, 0.01*np.ones(soc.shape), 'b-', label='ground truth')
axs[0].plot(soc, np.squeeze(fit[0]), 'r--', label='fit')
axs[0].set_ylabel('R (Ohms)')
axs[0].set_xlabel('SOC')
axs[0].set_xlim(0, 1)
axs[0].set_ylim(0, 0.013)
axs[0].legend()

axs[1].plot(soc, 2500*np.ones(soc.shape), 'b-')
axs[1].plot(soc, np.squeeze(fit[1]), 'r--')
axs[1].set_ylabel('F (Farads)')
axs[1].set_xlabel('SOC')
axs[1].set_xlim(0, 1)
axs[1].set_ylim(0, 3000)
[19]:
(0.0, 3000.0)
../_images/extractors_demonstrate-ecm-extractors_34_1.png
[ ]: