├── LICENSE ├── README.md ├── dyn_data ├── A123_DYN_10_N15_s1.csv ├── A123_DYN_10_N15_s2.csv ├── A123_DYN_10_N15_s3.csv ├── A123_DYN_10_N25_s1.csv ├── A123_DYN_10_N25_s2.csv ├── A123_DYN_10_N25_s3.csv ├── A123_DYN_30_N05_s1.csv ├── A123_DYN_30_N05_s2.csv ├── A123_DYN_30_N05_s3.csv ├── A123_DYN_45_P05_s1.csv ├── A123_DYN_45_P05_s2.csv ├── A123_DYN_45_P05_s3.csv ├── A123_DYN_45_P15_s1.csv ├── A123_DYN_45_P15_s2.csv ├── A123_DYN_45_P15_s3.csv ├── A123_DYN_50_P25_s1.csv ├── A123_DYN_50_P25_s2.csv ├── A123_DYN_50_P25_s3.csv ├── A123_DYN_50_P35_s1.csv ├── A123_DYN_50_P35_s2.csv ├── A123_DYN_50_P35_s3.csv ├── A123_DYN_50_P45_s1.csv ├── A123_DYN_50_P45_s2.csv └── A123_DYN_50_P45_s3.csv ├── dyn_model ├── data.py ├── dyn_model.py ├── funcs.py └── models.py ├── ocv_data ├── A123_OCV_N05_S1.csv ├── A123_OCV_N05_S2.csv ├── A123_OCV_N05_S3.csv ├── A123_OCV_N05_S4.csv ├── A123_OCV_N15_S1.csv ├── A123_OCV_N15_S2.csv ├── A123_OCV_N15_S3.csv ├── A123_OCV_N15_S4.csv ├── A123_OCV_N25_S1.csv ├── A123_OCV_N25_S2.csv ├── A123_OCV_N25_S3.csv ├── A123_OCV_N25_S4.csv ├── A123_OCV_P05_S1.csv ├── A123_OCV_P05_S2.csv ├── A123_OCV_P05_S3.csv ├── A123_OCV_P05_S4.csv ├── A123_OCV_P15_S1.csv ├── A123_OCV_P15_S2.csv ├── A123_OCV_P15_S3.csv ├── A123_OCV_P15_S4.csv ├── A123_OCV_P25_S1.csv ├── A123_OCV_P25_S2.csv ├── A123_OCV_P25_S3.csv ├── A123_OCV_P25_S4.csv ├── A123_OCV_P35_S1.csv ├── A123_OCV_P35_S2.csv ├── A123_OCV_P35_S3.csv ├── A123_OCV_P35_S4.csv ├── A123_OCV_P45_S1.csv ├── A123_OCV_P45_S2.csv ├── A123_OCV_P45_S3.csv └── A123_OCV_P45_S4.csv └── ocv_model ├── data.py ├── funcs.py ├── models.py └── ocv.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 batterysim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ESCtoolbox (Python version) 2 | 3 | This is a Python version of Gregory Plett's enhanced self-correcting (ESC) 4 | battery cell model. The original code is written in Matlab which is available 5 | in the ESCtoolbox at 6 | [mocha-java.uccs.edu/BMS1/](http://mocha-java.uccs.edu/BMS1/). 7 | 8 | ## OCV model 9 | 10 | The open-circuit voltage (OCV) files are located in the `ocv_model/` folder 11 | where the OCV model is the `ocv.py` file. The results and plots generated from 12 | this model should be similar to the Matlab `runProcessOCV.m` plots. The 13 | `funcs.py` file contains the `OCVfromSOCtemp` function while the `models.py` 14 | file contains model objects used by the OCV model. The `data.py` file plots the 15 | experimental data from the csv files located in the `ocv_data/`. 16 | 17 | Battery test data for the A123 cell is available in the `ocv_data/` folder as 18 | csv files. The data files were exported from the Excel spreadsheets in the 19 | `OCV_Files/A123_OCV` directory of the Matlab ESCtoolbox. Plots of the battery 20 | test data are generated with the `data.py` script. Change the tc variable to 21 | view plots from the other temperature tests. For example, change the `tc` 22 | string to `N05` to create plots for the CSV files named A123_OCV_N05_S1, 23 | A123_OCV_N05_S2, A123_OCV_N05_S3, and A123_OCV_N05_S4. The figures produced 24 | from the script should be similar to the graphs shown in the `A123_OCV_N05_S1`, 25 | `A123_OCV_N05_S2`, `A123_OCV_N05_S3`, and `A123_OCV_N05_S4` Excel spreadsheets. 26 | 27 | See the comments in each file for more information. 28 | 29 | ## DYN model 30 | 31 | The dynamic model files are located in the `dyn_model/` folder where the 32 | `dyn_model.py` is the main file to run. The data from the dynamic battery tests 33 | are located in the `dyn_data/` folder. 34 | 35 | See the comments in each file for more information. 36 | 37 | ## Installation 38 | 39 | Requires Python 3.6, Matplotlib, NumPy, and Pandas. The preferred method to 40 | install Python 3 and associated packages is with the Anaconda or Miniconda 41 | distribtion available at 42 | [continuum.io/downloads](https://www.continuum.io/downloads). 43 | 44 | ## Usage 45 | 46 | Clone or download the files to your local machine. Start iPython from within 47 | the `ocv_model/` directory then type `run ocv.py` to run the OCV model. 48 | 49 | -------------------------------------------------------------------------------- /dyn_model/data.py: -------------------------------------------------------------------------------- 1 | """ 2 | Plot voltages recorded for the three scripts related to the A123 cell. Data is 3 | read from csv files located in the dyn_data/ folder. The csv files were created 4 | from the mat files located in the Matlab ESCtoolbox at DYN/A123_DYN. 5 | """ 6 | 7 | import matplotlib.pyplot as plt 8 | import pandas as pd 9 | 10 | # list of csv files based on magnitude and temperature 11 | magtemps = ['10_N25', '10_N15', '30_N05', '45_P05', '45_P15', '50_P25', '50_P35', '50_P45'] 12 | 13 | # choose which group of files to plot, index should be a number from 0-7 14 | mag = magtemps[0] 15 | 16 | # plot data from the csv files 17 | 18 | plt.ion() 19 | plt.close('all') 20 | 21 | # individual plots for each script 22 | 23 | # for s in ['s1', 's2', 's3']: 24 | # name = f'A123_DYN_{mag}_{s}' 25 | # nfile = f'../dyn_data/{name}.csv' 26 | # df = pd.read_csv(nfile, sep=', ', engine='python') 27 | # voltage = df['voltage'].values 28 | # current = df['current'].values 29 | # time = df['time'].values 30 | # t = (time - time[0])/3600 31 | 32 | # plt.figure() 33 | # plt.plot(t, voltage, color='red', lw=2) 34 | # plt.xlabel('Time (hr)') 35 | # plt.ylabel('Voltage (V)') 36 | # plt.title(name) 37 | 38 | # plt.figure() 39 | # plt.plot(t, current, color='blue', lw=2) 40 | # plt.xlabel('Time (hr)') 41 | # plt.ylabel('Current (A)') 42 | # plt.title(name) 43 | 44 | # data to plot from each script 45 | 46 | file1 = f'../dyn_data/A123_DYN_{mag}_s1.csv' 47 | df1 = pd.read_csv(file1, sep=', ', engine='python') 48 | voltage1 = df1['voltage'].values 49 | current1 = df1['current'].values 50 | time1 = df1['time'].values 51 | t1 = (time1 - time1[0])/3600 52 | 53 | file2 = f'../dyn_data/A123_DYN_{mag}_s2.csv' 54 | df2 = pd.read_csv(file2, sep=', ', engine='python') 55 | voltage2 = df2['voltage'].values 56 | current2 = df2['current'].values 57 | time2 = df2['time'].values 58 | t2 = (time2 - time2[0])/3600 59 | 60 | file3 = f'../dyn_data/A123_DYN_{mag}_s3.csv' 61 | df3 = pd.read_csv(file3, sep=', ', engine='python') 62 | voltage3 = df3['voltage'].values 63 | current3 = df3['current'].values 64 | time3 = df3['time'].values 65 | t3 = (time3 - time3[0])/3600 66 | 67 | # plot voltage from each script 68 | 69 | fig = plt.figure() 70 | 71 | ax1 = fig.add_subplot(221) 72 | ax1.plot(t1, voltage1, color='red') 73 | ax1.set_title(f'A123_DYN_{mag}_s1', fontsize=10) 74 | 75 | ax2 = fig.add_subplot(222) 76 | ax2.plot(t2, voltage2, color='red') 77 | ax2.set_title(f'A123_DYN_{mag}_s2', fontsize=10) 78 | 79 | ax3 = fig.add_subplot(223) 80 | ax3.plot(t3, voltage3, color='red') 81 | ax3.set_title(f'A123_DYN_{mag}_s3', fontsize=10) 82 | 83 | fig.text(0.5, 0.01, 'Time (hr)', ha='center') 84 | fig.text(0.01, 0.5, 'Voltage (V)', va='center', rotation='vertical') 85 | plt.tight_layout() 86 | 87 | # plot current from each script 88 | 89 | fig = plt.figure() 90 | 91 | ax1 = fig.add_subplot(221) 92 | ax1.plot(t1, current1, color='blue') 93 | ax1.set_title(f'A123_DYN_{mag}_s1', fontsize=10) 94 | 95 | ax2 = fig.add_subplot(222) 96 | ax2.plot(t2, current2, color='blue') 97 | ax2.set_title(f'A123_DYN_{mag}_s2', fontsize=10) 98 | 99 | ax3 = fig.add_subplot(223) 100 | ax3.plot(t3, current3, color='blue') 101 | ax3.set_title(f'A123_DYN_{mag}_s3', fontsize=10) 102 | 103 | fig.text(0.5, 0.01, 'Time (hr)', ha='center') 104 | fig.text(0.01, 0.5, 'Current (A)', va='center', rotation='vertical') 105 | plt.tight_layout() 106 | 107 | -------------------------------------------------------------------------------- /dyn_model/dyn_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dyn model 3 | """ 4 | 5 | import numpy as np 6 | import json 7 | 8 | from funcs import processDynamic 9 | from models import DataModel, ModelOcv 10 | 11 | from pathlib import Path 12 | 13 | # parameters 14 | cellID = 'A123' # cell identifier 15 | numpoles = 3 # number of resistor-capacitor pairs in final model 16 | temps = [-25, -15, -5, 5, 15, 25, 35, 45] # temperatures 17 | mags = [10, 10, 30, 45, 45, 50, 50, 50] # A123 18 | doHyst = 1 # 1 "find M, M0 and G params" or 0 "make hys params 0" 19 | 20 | # read model OCV file, previously computed by runProcessOCV 21 | modelocv = ModelOcv.load(Path(f'./modelocv.json')) 22 | 23 | # initialize array to store battery cell data 24 | data = np.zeros(len(mags), dtype=object) 25 | 26 | # load battery cell data for each temperature as objects then store in data array 27 | # note that data files are in the dyn_data folder 28 | print('Load files') 29 | for idx, temp in enumerate(temps): 30 | mag = mags[idx] 31 | if temp < 0: 32 | tempfmt = f'{abs(temp):02}' 33 | files = [Path(f'./dyn_data/{cellID}_DYN_{mag}_N{tempfmt}_s1.csv'), 34 | Path(f'./dyn_data/{cellID}_DYN_{mag}_N{tempfmt}_s2.csv'), 35 | Path(f'./dyn_data/{cellID}_DYN_{mag}_N{tempfmt}_s3.csv')] 36 | data[idx] = DataModel(temp, files) 37 | print(*files, sep='\n') 38 | else: 39 | tempfmt = f'{abs(temp):02}' 40 | files = [Path(f'./dyn_data/{cellID}_DYN_{mag}_P{tempfmt}_s1.csv'), 41 | Path(f'./dyn_data/{cellID}_DYN_{mag}_P{tempfmt}_s2.csv'), 42 | Path(f'./dyn_data/{cellID}_DYN_{mag}_P{tempfmt}_s3.csv')] 43 | data[idx] = DataModel(temp, files) 44 | print(*files, sep='\n') 45 | 46 | modeldyn = processDynamic(data, modelocv, numpoles, doHyst) 47 | 48 | # convert ocv and dyn results model object to dict, then save in JSON to disk 49 | modeldyn = {k:v.tolist() if isinstance(v, np.ndarray) else v for k,v in modeldyn.__dict__.items()} 50 | if True: 51 | if doHyst: 52 | with open('modeldyn.json', 'w') as json_file: 53 | json.dump(modeldyn, json_file, indent=4) 54 | else: 55 | with open('modeldyn-no-hys.json', 'w') as json_file: 56 | json.dump(modeldyn,json_file, indent=4) -------------------------------------------------------------------------------- /dyn_model/funcs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions used by the dyn_model 3 | """ 4 | 5 | # Modules 6 | # ------------------------------------------------------------------------------ 7 | 8 | import ipdb 9 | import matplotlib.pyplot as plt 10 | import numpy as np 11 | from scipy.optimize import fminbound, nnls, minimize_scalar 12 | from scipy.signal import dlsim, dlti 13 | from models import ModelDyn 14 | 15 | # Functions 16 | # ------------------------------------------------------------------------------ 17 | 18 | def OCVfromSOCtemp(soc, temp, model): 19 | """ OCV function """ 20 | SOC = model.SOC # force to be column vector 21 | OCV0 = model.OCV0 # force to be column vector 22 | OCVrel = model.OCVrel # force to be column vector 23 | 24 | # if soc is scalar then make it a vector 25 | soccol = np.asarray(soc) 26 | if soccol.ndim == 0: 27 | soccol = soccol[None] 28 | 29 | tempcol = temp * np.ones(np.size(soccol)) 30 | 31 | diffSOC = SOC[1] - SOC[0] # spacing between SOC points - assume uniform 32 | ocv = np.zeros(np.size(soccol)) # initialize output to zero 33 | I1, = np.where(soccol <= SOC[0]) # indices of socs below model-stored data 34 | I2, = np.where(soccol >= SOC[-1]) # and of socs above model-stored data 35 | I3, = np.where((soccol > SOC[0]) & (soccol < SOC[-1])) # the rest of them 36 | I6 = np.isnan(soccol) # if input is "not a number" for any locations 37 | 38 | # for voltages less than lowest stored soc datapoint, extrapolate off 39 | # low end of table 40 | if I1.any(): 41 | dv = (OCV0[1] + tempcol*OCVrel[1]) - (OCV0[0] + tempcol*OCVrel[0]) 42 | ocv[I1] = (soccol[I1] - SOC[0])*dv[I1]/diffSOC + OCV0[0] + tempcol[I1]*OCVrel[0] 43 | 44 | # for voltages greater than highest stored soc datapoint, extrapolate off 45 | # high end of table 46 | if I2.any(): 47 | dv = (OCV0[-1] + tempcol*OCVrel[-1]) - (OCV0[-2] + tempcol*OCVrel[-2]) 48 | ocv[I2] = (soccol[I2] - SOC[-1])*dv[I2]/diffSOC + OCV0[-1] + tempcol[I2]*OCVrel[-1] 49 | 50 | # for normal soc range, manually interpolate (10x faster than "interp1") 51 | I4 = (soccol[I3] - SOC[0])/diffSOC # using linear interpolation 52 | I5 = np.floor(I4) 53 | I5 = I5.astype(int) 54 | I45 = I4 - I5 55 | omI45 = 1 - I45 56 | ocv[I3] = OCV0[I5]*omI45 + OCV0[I5+1]*I45 57 | ocv[I3] = ocv[I3] + tempcol[I3]*(OCVrel[I5]*omI45 + OCVrel[I5+1]*I45) 58 | ocv[I6] = 0 # replace NaN SOCs with zero voltage 59 | return ocv 60 | 61 | 62 | def SISOsubid(y, u, n): 63 | """ 64 | Identify state-space "A" matrix from input-output data. 65 | y: vector of measured outputs 66 | u: vector of measured inputs 67 | n: number of poles in solution 68 | A: discrete-time state-space state-transition matrix. 69 | 70 | Theory from "Subspace Identification for Linear Systems Theory - Implementation 71 | - Applications" Peter Van Overschee / Bart De Moor (VODM) Kluwer Academic 72 | Publishers, 1996. Combined algorithm: Figure 4.8 page 131 (robust). Robust 73 | implementation: Figure 6.1 page 169. 74 | 75 | Code adapted from "subid.m" in "Subspace Identification for Linear Systems" 76 | toolbox on MATLAB CENTRAL file exchange, originally by Peter Van Overschee, 77 | Dec. 1995 78 | """ 79 | 80 | ny = len(y) 81 | i = 2*n 82 | twoi = 4*n 83 | 84 | # Determine the number of columns in the Hankel matrices 85 | j = ny - twoi + 1 86 | 87 | # Make Hankel matrices Y and U 88 | Y = np.zeros((twoi, j)) 89 | U = np.zeros((twoi, j)) 90 | 91 | for k in range(2*i): 92 | Y[k] = y[k:k+j] 93 | U[k] = u[k:k+j] 94 | 95 | # Compute the R factor 96 | UY = np.concatenate((U, Y)) # combine U and Y into one array 97 | _, r = np.linalg.qr(UY.T) # QR decomposition 98 | R = r.T # transpose of upper triangle 99 | 100 | # STEP 1: Calculate oblique and orthogonal projections 101 | # ------------------------------------------------------------------ 102 | 103 | Rf = R[-i:] # future outputs 104 | Rp = np.concatenate((R[:i], R[2*i:3*i])) # past inputs and outputs 105 | Ru = R[i:twoi, :twoi] # future inputs 106 | 107 | RfRu = np.linalg.lstsq(Ru.T, Rf[:, :twoi].T, rcond=None)[0].T 108 | RfRuRu = RfRu.dot(Ru) 109 | tm1 = Rf[:, :twoi] - RfRuRu 110 | tm2 = Rf[:, twoi:4*i] 111 | Rfp = np.concatenate((tm1, tm2), axis=1) # perpendicular future outputs 112 | 113 | RpRu = np.linalg.lstsq(Ru.T, Rp[:, :twoi].T, rcond=None)[0].T 114 | RpRuRu = RpRu.dot(Ru) 115 | tm3 = Rp[:, :twoi] - RpRuRu 116 | tm4 = Rp[:, twoi:4*i] 117 | Rpp = np.concatenate((tm3, tm4), axis=1) # perpendicular past inputs and outputs 118 | 119 | # The oblique projection is computed as (6.1) in VODM, page 166. 120 | # obl/Ufp = Yf/Ufp * pinv(Wp/Ufp) * (Wp/Ufp) 121 | # The extra projection on Ufp (Uf perpendicular) tends to give 122 | # better numerical conditioning (see algo on VODM page 131) 123 | 124 | # Funny rank check (SVD takes too long) 125 | # This check is needed to avoid rank deficiency warnings 126 | 127 | nmRpp = np.linalg.norm(Rpp[:, 3*i-3:-i], ord='fro') 128 | if nmRpp < 1e-10: 129 | # oblique projection as (Rfp*pinv(Rpp')') * Rp 130 | Ob = Rfp.dot(np.linalg.pinv(Rpp.T).T).dot(Rp) 131 | else: 132 | # oblique projection as (Rfp/Rpp) * Rp 133 | Ob = (np.linalg.lstsq(Rpp.T, Rfp.T, rcond=None)[0].T).dot(Rp) 134 | 135 | # STEP 2: Compute weighted oblique projection and its SVD 136 | # Extra projection of Ob on Uf perpendicular 137 | # ------------------------------------------------------------------ 138 | 139 | ObRu = np.linalg.lstsq(Ru.T, Ob[:, :twoi].T, rcond=None)[0].T 140 | ObRuRu = ObRu.dot(Ru) 141 | tm5 = Ob[:, :twoi] - ObRuRu 142 | tm6 = Ob[:, twoi:4*i] 143 | WOW = np.concatenate((tm5, tm6), axis=1) 144 | 145 | U, S, _ = np.linalg.svd(WOW, full_matrices=False) 146 | ss = S # In np.linalg.svd S is already the diagonal, generally ss = diag(S) 147 | 148 | # STEP 3: Partitioning U into U1 and U2 (the latter is not used) 149 | # ------------------------------------------------------------------ 150 | 151 | U1 = U[:, :n] # determine U1 152 | 153 | # STEP 4: Determine gam = Gamma(i) and gamm = Gamma(i-1) 154 | # ------------------------------------------------------------------ 155 | 156 | gam = U1 @ np.diag(np.sqrt(ss[:n])) 157 | gamm = gam[0:(i-1),:] 158 | gam_inv = np.linalg.pinv(gam) # pseudo inverse of gam 159 | gamm_inv = np.linalg.pinv(gamm) # pseudo inverse of gamm 160 | 161 | # STEP 5: Determine A matrix (also C, which is not used) 162 | # ------------------------------------------------------------------ 163 | 164 | tm7 = np.concatenate((gam_inv @ R[3*i:4*i, 0:3*i], np.zeros((n,1))), axis=1) 165 | tm8 = R[i:twoi, 0:3*i+1] 166 | Rhs = np.vstack((tm7, tm8)) 167 | tm9 = gamm_inv @ R[3*i+1:4*i, 0:3*i+1] 168 | tm10 = R[3*i:3*i+1, 0:3*i+1] 169 | Lhs = np.vstack((tm9, tm10)) 170 | sol = np.linalg.lstsq(Rhs.T, Lhs.T, rcond=None)[0].T # solve least squares for [A; C] 171 | A = sol[0:n, 0:n] # extract A 172 | 173 | return A 174 | 175 | 176 | def minfn(data, model, theTemp, doHyst): 177 | """ 178 | Using an assumed value for gamma (already stored in the model), find optimum 179 | values for remaining cell parameters, and compute the RMS error between true 180 | and predicted cell voltage 181 | """ 182 | 183 | alltemps = [d.temp for d in data] 184 | ind, = np.where(np.array(alltemps) == theTemp)[0] 185 | 186 | G = abs(model.GParam[ind]) 187 | 188 | Q = abs(model.QParam[ind]) 189 | eta = abs(model.etaParam[ind]) 190 | RC = abs(model.RCParam[ind]) 191 | numpoles = len(RC) 192 | 193 | ik = data[ind].s1.current.copy() 194 | vk = data[ind].s1.voltage.copy() 195 | tk = np.arange(len(vk)) 196 | etaik = ik.copy() 197 | etaik[ik < 0] = etaik[ik < 0] * eta 198 | 199 | hh = 0*ik 200 | sik = 0*ik 201 | fac = np.exp(-abs(G * etaik/(3600*Q))) 202 | 203 | for k in range(1, len(ik)): 204 | hh[k] = (fac[k-1]*hh[k-1]) - ((1-fac[k-1])*np.sign(ik[k-1])) 205 | sik[k] = np.sign(ik[k]) 206 | if abs(ik[k]) < Q/100: 207 | sik[k] = sik[k-1] 208 | 209 | # First modeling step: Compute error with model = OCV only 210 | vest1 = data[ind].OCV 211 | verr = vk - vest1 212 | 213 | # Second modeling step: Compute time constants in "A" matrix 214 | y = -np.diff(verr) 215 | u = np.diff(etaik) 216 | A = SISOsubid(y, u, numpoles) 217 | 218 | # Modify results to ensure real, preferably distinct, between 0 and 1 219 | 220 | eigA = np.linalg.eigvals(A) 221 | eigAr = eigA + 0.001 * np.random.normal(loc=0.0, scale=1.0, size=eigA.shape) 222 | eigA[eigA != np.conj(eigA)] = abs(eigAr[eigA != np.conj(eigA)]) # Make sure real 223 | eigA = np.real(eigA) # Make sure real 224 | eigA[eigA<0] = abs(eigA[eigA<0]) # Make sure in range 225 | eigA[eigA>1] = 1 / eigA[eigA>1] 226 | RCfact = np.sort(eigA) 227 | RCfact = RCfact[-numpoles:] 228 | RC = -1 / np.log(RCfact) 229 | 230 | # Compute RC time constants as Plett's Matlab ESCtoolbox 231 | # nup = numpoles 232 | # while 1: 233 | # A = SISOsubid(y, u, nup) 234 | 235 | # # Modify results to ensure real, preferably distinct, between 0 and 1 236 | # eigA = np.linalg.eigvals(A) 237 | # eigA = np.real(eigA[eigA == np.conj(eigA)]) # Make sure real 238 | # eigA = eigA[(eigA>0) & (eigA<1)] # Make sure in range 239 | # okpoles = len(eigA) 240 | # nup = nup + 1 241 | # if okpoles >= numpoles: 242 | # break 243 | # # print(nup) 244 | 245 | # RCfact = np.sort(eigA) 246 | # RCfact = RCfact[-numpoles:] 247 | # RC = -1 / np.log(RCfact) 248 | 249 | # Simulate the R-C filters to find R-C currents 250 | stsp = dlti(np.diag(RCfact), np.vstack(1-RCfact), np.eye(numpoles), np.zeros((numpoles, 1))) 251 | [tout, vrcRaw, xout] = dlsim(stsp, etaik) 252 | 253 | # Third modeling step: Hysteresis parameters 254 | if doHyst: 255 | H = np.column_stack((hh, sik, -etaik, -vrcRaw)) 256 | W = nnls(H, verr) 257 | M = W[0][0] 258 | M0 = W[0][1] 259 | R0 = W[0][2] 260 | Rfact = W[0][3:].T 261 | else: 262 | H = np.column_stack((-etaik, -vrcRaw)) 263 | W = np.linalg.lstsq(H,verr, rcond=None)[0] 264 | M = 0 265 | M0 = 0 266 | R0 = W[0] 267 | Rfact = W[1:].T 268 | 269 | idx, = np.where(np.array(model.temps) == data[ind].temp)[0] 270 | model.R0Param[idx] = R0 271 | model.M0Param[idx] = M0 272 | model.MParam[idx] = M 273 | model.RCParam[idx] = RC.T 274 | model.RParam[idx] = Rfact.T 275 | 276 | vest2 = vest1 + M*hh + M0*sik - R0*etaik - vrcRaw @ Rfact.T 277 | verr = vk - vest2 278 | 279 | # plot voltages 280 | plt.figure(1) 281 | plt.plot(tk[::10]/60, vk[::10], label='voltage') 282 | plt.plot(tk[::10]/60, vest1[::10], label='vest1 (OCV)') 283 | plt.plot(tk[::10]/60, vest2[::10], label='vest2 (DYN)') 284 | plt.xlabel('Time (min)') 285 | plt.ylabel('Voltage (V)') 286 | plt.title(f'Voltage and estimates at T = {data[ind].temp} C') 287 | plt.legend(loc='best', numpoints=1) 288 | #plt.show() 289 | 290 | # plot modeling errors 291 | plt.figure(2) 292 | plt.plot(tk[::10]/60, verr[::10], label='verr') 293 | plt.xlabel('Time (min)') 294 | plt.ylabel('Error (V)') 295 | plt.title(f'Modeling error at T = {data[ind].temp} C') 296 | #plt.show() 297 | 298 | # Compute RMS error only on data roughly in 5% to 95% SOC 299 | v1 = OCVfromSOCtemp(0.95, data[ind].temp, model)[0] 300 | v2 = OCVfromSOCtemp(0.05, data[ind].temp, model)[0] 301 | N1 = np.where(vk < v1)[0][0] 302 | N2 = np.where(vk < v2)[0][0] 303 | 304 | rmserr = np.sqrt(np.mean(verr[N1:N2]**2)) 305 | cost = np.sum(rmserr) 306 | print(f'RMS error = {cost*1000:.2f} mV') 307 | 308 | return cost, model 309 | 310 | 311 | def optfn(x, data, model, theTemp, doHyst): 312 | """ 313 | This minfn works for the enhanced self-correcting cell model 314 | """ 315 | 316 | idx, = np.where(np.array(model.temps) == theTemp) 317 | model.GParam[idx] = abs(x) 318 | 319 | cost, _ = minfn(data, model, theTemp, doHyst) 320 | return cost 321 | 322 | 323 | def processDynamic(data, modelocv, numpoles, doHyst): 324 | """ 325 | Technical note: PROCESSDYNAMIC assumes that specific Arbin test scripts have 326 | been executed to generate the input files. "makeMATfiles.m" converts the raw 327 | Excel data files into "MAT" format where the MAT files have fields for time, 328 | step, current, voltage, chgAh, and disAh for each script run. 329 | 330 | The results from three scripts are required at every temperature. 331 | The steps in each script file are assumed to be: 332 | Script 1 (thermal chamber set to test temperature): 333 | Step 1: Rest @ 100% SOC to acclimatize to test temperature 334 | Step 2: Discharge @ 1C to reach ca. 90% SOC 335 | Step 3: Repeatedly execute dynamic profiles (and possibly intermediate 336 | rests) until SOC is around 10% 337 | Script 2 (thermal chamber set to 25 degC): 338 | Step 1: Rest ca. 10% SOC to acclimatize to 25 degC 339 | Step 2: Discharge to min voltage (ca. C/3) 340 | Step 3: Rest 341 | Step 4: Constant voltage at vmin until current small (ca. C/30) 342 | Steps 5-7: Dither around vmin 343 | Step 8: Rest 344 | Script 3 (thermal chamber set to 25 degC): 345 | Step 2: Charge @ 1C to max voltage 346 | Step 3: Rest 347 | Step 4: Constant voltage at vmax until current small (ca. C/30) 348 | Steps 5-7: Dither around vmax 349 | Step 8: Rest 350 | 351 | All other steps (if present) are ignored by PROCESSDYNAMIC. The time step 352 | between data samples must be uniform -- we assume a 1s sample period in this 353 | code. 354 | 355 | The inputs: 356 | - data: An array, with one entry per temperature to be processed. 357 | One of the array entries must be at 25 degC. The fields of "data" are: 358 | temp (the test temperature), script1, script 2, and script 3, where the 359 | latter comprise data collected from each script. The sub-fields of 360 | these script structures that are used by PROCESSDYNAMIC are the 361 | vectors: current, voltage, chgAh, and disAh 362 | - model: The output from processOCV, comprising the OCV model 363 | - numpoles: The number of R-C pairs in the model 364 | - doHyst: 0 if no hysteresis model desired; 1 if hysteresis desired 365 | 366 | The output: 367 | - model: A modified model, which now contains the dynamic fields filled in. 368 | """ 369 | 370 | # used by minimize_scalar later on 371 | options = { 372 | 'xatol': 1e-08, 373 | 'maxiter': 1e5, 374 | 'disp': 0 375 | } 376 | 377 | # Step 1: Compute capacity and coulombic efficiency for every test 378 | # ------------------------------------------------------------------ 379 | 380 | alltemps = [d.temp for d in data] 381 | alletas = np.zeros(len(alltemps)) 382 | allQs = np.zeros(len(alltemps)) 383 | 384 | ind25, = np.where(np.array(alltemps) == 25)[0] 385 | not25, = np.where(np.array(alltemps) != 25) 386 | 387 | k = ind25 388 | 389 | totDisAh = data[k].s1.disAh[-1] + data[k].s2.disAh[-1] + data[k].s3.disAh[-1] 390 | totChgAh = data[k].s1.chgAh[-1] + data[k].s2.chgAh[-1] + data[k].s3.chgAh[-1] 391 | eta25 = totDisAh/totChgAh 392 | data[k].eta = eta25 393 | alletas[k] = eta25 394 | data[k].s1.chgAh = data[k].s1.chgAh * eta25 395 | data[k].s2.chgAh = data[k].s2.chgAh * eta25 396 | data[k].s3.chgAh = data[k].s3.chgAh * eta25 397 | 398 | Q25 = data[k].s1.disAh[-1] + data[k].s2.disAh[-1] - data[k].s1.chgAh[-1] - data[k].s2.chgAh[-1] 399 | data[k].Q = Q25 400 | allQs[k] = Q25 401 | 402 | eta25 = np.mean(alletas[ind25]) 403 | 404 | for k in not25: 405 | data[k].s2.chgAh = data[k].s2.chgAh*eta25 406 | data[k].s3.chgAh = data[k].s3.chgAh*eta25 407 | eta = (data[k].s1.disAh[-1] + data[k].s2.disAh[-1] + data[k].s3.disAh[-1] - data[k].s2.chgAh[-1] - data[k].s3.chgAh[-1])/data[k].s1.chgAh[-1] 408 | 409 | data[k].s1.chgAh = eta*data[k].s1.chgAh 410 | data[k].eta = eta 411 | alletas[k] = eta 412 | 413 | Q = data[k].s1.disAh[-1] + data[k].s2.disAh[-1] - data[k].s1.chgAh[-1] - data[k].s2.chgAh[-1] 414 | data[k].Q = Q 415 | allQs[k] = Q 416 | 417 | modeldyn = ModelDyn() 418 | modeldyn.temps = alltemps 419 | modeldyn.etaParam = alletas 420 | modeldyn.QParam = allQs 421 | 422 | # Step 2: Compute OCV for "discharge portion" of test 423 | # ------------------------------------------------------------------ 424 | 425 | for k, _ in enumerate(data): 426 | etaParam = modeldyn.etaParam[k] 427 | etaik = data[k].s1.current.copy() 428 | etaik[etaik < 0] = etaParam*etaik[etaik < 0] 429 | data[k].Z = 1 - np.cumsum(etaik) * 1/(data[k].Q * 3600) 430 | data[k].OCV = OCVfromSOCtemp(data[k].Z, alltemps[k], modelocv) 431 | 432 | # Step 3: Now, optimize! 433 | # ------------------------------------------------------------------ 434 | 435 | modeldyn.GParam = np.zeros(len(modeldyn.temps)) # gamma hysteresis parameter 436 | modeldyn.M0Param = np.zeros(len(modeldyn.temps)) # M0 hysteresis parameter 437 | modeldyn.MParam = np.zeros(len(modeldyn.temps)) # M hysteresis parameter 438 | modeldyn.R0Param = np.zeros(len(modeldyn.temps)) # R0 ohmic resistance parameter 439 | modeldyn.RCParam = np.zeros((len(modeldyn.temps), numpoles)) # time constant 440 | modeldyn.RParam = np.zeros((len(modeldyn.temps), numpoles)) # Rk 441 | 442 | modeldyn.SOC = modelocv.SOC # copy SOC values from OCV model 443 | modeldyn.OCV0 = modelocv.OCV0 # copy OCV0 values from OCV model 444 | modeldyn.OCVrel = modelocv.OCVrel # copy OCVrel values from OCV model 445 | 446 | for theTemp in range(len(modeldyn.temps)): 447 | temp = modeldyn.temps[theTemp] 448 | print('Processing temperature', temp, 'C') 449 | 450 | if doHyst: 451 | g = abs(minimize_scalar(optfn, bounds=(1, 250), args=(data, modeldyn, temp, doHyst), method='bounded', options=options).x) 452 | print('g =', g) 453 | 454 | else: 455 | modeldyn.GParam[theTemp] = 0 456 | theGParam = 0 457 | optfn(theGParam, data, modeldyn, temp, doHyst) 458 | return modeldyn 459 | -------------------------------------------------------------------------------- /dyn_model/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Class objects for the dyn_model calculations 3 | """ 4 | 5 | # Modules 6 | # ------------------------------------------------------------------------------ 7 | 8 | import json 9 | import pandas as pd 10 | import numpy as np 11 | 12 | # Class Objects 13 | # ------------------------------------------------------------------------------ 14 | 15 | class DataModel: 16 | """ 17 | Data from battery script tests. Requires the Script class which reads the 18 | csv file and assigns the data to class attributes. 19 | """ 20 | 21 | def __init__(self, temp, csvfiles): 22 | """ 23 | Initialize from script data. 24 | """ 25 | self.temp = temp 26 | self.s1 = Script(csvfiles[0]) 27 | self.s2 = Script(csvfiles[1]) 28 | self.s3 = Script(csvfiles[2]) 29 | 30 | 31 | class Script: 32 | """ 33 | Object to represent script data. 34 | """ 35 | 36 | def __init__(self, csvfile): 37 | """ 38 | Initialize with data from csv file. 39 | """ 40 | df = pd.read_csv(csvfile) 41 | time = df['time'].values 42 | step = df[' step'].values 43 | current = df[' current'].values 44 | voltage = df[' voltage'].values 45 | chgAh = df[' chgAh'].values 46 | disAh = df[' disAh'].values 47 | 48 | self.time = time 49 | self.step = step 50 | self.current = current 51 | self.voltage = voltage 52 | self.chgAh = chgAh 53 | self.disAh = disAh 54 | 55 | 56 | class ModelOcv: 57 | """ 58 | Model representing OCV results. 59 | """ 60 | # pylint: disable=too-many-instance-attributes 61 | 62 | def __init__(self, OCV0, OCVrel, SOC, OCV, SOC0, SOCrel, OCVeta, OCVQ): 63 | self.OCV0 = np.array(OCV0) 64 | self.OCVrel = np.array(OCVrel) 65 | self.SOC = np.array(SOC) 66 | self.OCV = np.array(OCV) 67 | self.SOC0 = np.array(SOC0) 68 | self.SOCrel = np.array(SOCrel) 69 | self.OCVeta = np.array(OCVeta) 70 | self.OCVQ = np.array(OCVQ) 71 | 72 | @classmethod 73 | def load(cls, pfile): 74 | """ 75 | Load attributes from json file where pfile is string representing 76 | path to the json file. 77 | """ 78 | ocv = json.load(open(pfile, 'r')) 79 | return cls(ocv['OCV0'], ocv['OCVrel'], ocv['SOC'], ocv['OCV'], ocv['SOC0'], ocv['SOCrel'], ocv['OCVeta'], ocv['OCVQ']) 80 | 81 | 82 | class ModelDyn: 83 | """ 84 | Model representing results from the dynamic calculations. 85 | """ 86 | # pylint: disable=too-many-instance-attributes 87 | 88 | def __init__(self): 89 | self.temps = None 90 | self.etaParam = None 91 | self.QParam = None 92 | self.GParam = None 93 | self.M0Param = None 94 | self.MParam = None 95 | self.R0Param = None 96 | self.RCParam = None 97 | self.RParam = None 98 | self.SOC = None 99 | self.OCV0 = None 100 | self.OCVrel = None 101 | 102 | 103 | -------------------------------------------------------------------------------- /ocv_model/data.py: -------------------------------------------------------------------------------- 1 | """ 2 | Plot the data from the four A123 battery tests conducted at a range of 3 | temperatures. Change the tc variable to view plots from the other test data. 4 | For example, change the tc string to N05 to create plots for the CSV files 5 | named A123_OCV_N05_S1, A123_OCV_N05_S2, A123_OCV_N05_S3, and A123_OCV_N05_S4. 6 | """ 7 | 8 | import matplotlib.pyplot as plt 9 | import pandas as pd 10 | 11 | # Data 12 | # ------------------------------------------------------------------------------ 13 | 14 | # string that represents temperature of battery test to determine which csv 15 | # files to read, values can be N05, N15, N25, P05, P15, P25, P35, or P45 16 | tc = 'N05' 17 | 18 | df1 = pd.read_csv('../ocv_data/A123_OCV_' + tc + '_S1.csv') 19 | test_time1 = df1['Test_Time(s)'] 20 | current1 = df1['Current(A)'] 21 | voltage1 = df1['Voltage(V)'] 22 | 23 | df2 = pd.read_csv('../ocv_data/A123_OCV_' + tc + '_S2.csv') 24 | test_time2 = df2['Test_Time(s)'] 25 | current2 = df2['Current(A)'] 26 | voltage2 = df2['Voltage(V)'] 27 | 28 | df3 = pd.read_csv('../ocv_data/A123_OCV_' + tc + '_S3.csv') 29 | test_time3 = df3['Test_Time(s)'] 30 | current3 = df3['Current(A)'] 31 | voltage3 = df3['Voltage(V)'] 32 | 33 | df4 = pd.read_csv('../ocv_data/A123_OCV_' + tc + '_S4.csv') 34 | test_time4 = df4['Test_Time(s)'] 35 | current4 = df4['Current(A)'] 36 | voltage4 = df4['Voltage(V)'] 37 | 38 | # Compare Temperature Data 39 | # ------------------------------------------------------------------------------ 40 | 41 | temps = ['N05', 'N15', 'N25', 'P05', 'P15', 'P25', 'P35', 'P45'] 42 | times = [] 43 | volts = [] 44 | 45 | for t in temps: 46 | df = pd.read_csv('../ocv_data/A123_OCV_' + t + '_S1.csv') 47 | time = df['Test_Time(s)'].values 48 | voltage = df['Voltage(V)'].values 49 | times.append(time) 50 | volts.append(voltage) 51 | 52 | # Plot 53 | # ------------------------------------------------------------------------------ 54 | 55 | plt.ion() 56 | plt.close('all') 57 | 58 | # Figure 1 59 | plt.figure(1) 60 | plt.plot(times[0], volts[0], label='-5$^{\circ}$C') 61 | plt.plot(times[1], volts[1], label='-15$^{\circ}$C') 62 | plt.plot(times[2], volts[2], label='-25$^{\circ}$C') 63 | plt.plot(times[3], volts[3], label='5$^{\circ}$C') 64 | plt.plot(times[4], volts[4], label='15$^{\circ}$C') 65 | plt.plot(times[5], volts[5], label='25$^{\circ}$C') 66 | plt.plot(times[6], volts[6], label='35$^{\circ}$C') 67 | plt.plot(times[7], volts[7], label='45$^{\circ}$C') 68 | plt.legend(loc='best') 69 | plt.xlabel('Time (s)') 70 | plt.ylabel('Voltage (V)') 71 | plt.title('A123 battery cell') 72 | 73 | # Figure 2 74 | fig, ax1 = plt.subplots() 75 | plt.title('A123_OCV_' + tc + '_S1') 76 | 77 | ax1.plot(test_time1, current1, color='b', lw=2, label='current') 78 | ax1.set_xlabel('Test Time (s)') 79 | ax1.set_ylabel('Current (A)', color='b') 80 | ax1.tick_params('y', colors='b') 81 | 82 | ax2 = ax1.twinx() 83 | ax2.plot(test_time1, voltage1, color='r', lw=2, label='voltage') 84 | ax2.set_ylabel('Voltage (V)', color='r') 85 | ax2.tick_params('y', colors='r') 86 | 87 | # Figure 3 88 | fig, ax1 = plt.subplots() 89 | plt.title('A123_OCV_' + tc + '_S2') 90 | 91 | ax1.plot(test_time2, current2, color='b', lw=2, label='current') 92 | ax1.set_xlabel('Test Time (s)') 93 | ax1.set_ylabel('Current (A)', color='b') 94 | ax1.tick_params('y', colors='b') 95 | 96 | ax2 = ax1.twinx() 97 | ax2.plot(test_time2, voltage2, color='r', lw=2, label='voltage') 98 | ax2.set_ylabel('Voltage (V)', color='r') 99 | ax2.tick_params('y', colors='r') 100 | 101 | # Figure 4 102 | fig, ax1 = plt.subplots() 103 | plt.title('A123_OCV_' + tc + '_S3') 104 | 105 | ax1.plot(test_time3, current3, color='b', lw=2, label='current') 106 | ax1.set_xlabel('Test Time (s)') 107 | ax1.set_ylabel('Current (A)', color='b') 108 | ax1.tick_params('y', colors='b') 109 | 110 | ax2 = ax1.twinx() 111 | ax2.plot(test_time3, voltage3, color='r', lw=2, label='voltage') 112 | ax2.set_ylabel('Voltage (V)', color='r') 113 | ax2.tick_params('y', colors='r') 114 | 115 | # Figure 5 116 | fig, ax1 = plt.subplots() 117 | plt.title('A123_OCV_' + tc + '_S4') 118 | 119 | ax1.plot(test_time4, current4, color='b', lw=2, label='current') 120 | ax1.set_xlabel('Test Time (s)') 121 | ax1.set_ylabel('Current (A)', color='b') 122 | ax1.tick_params('y', colors='b') 123 | 124 | ax2 = ax1.twinx() 125 | ax2.plot(test_time4, voltage4, color='r', lw=2, label='voltage') 126 | ax2.set_ylabel('Voltage (V)', color='r') 127 | ax2.tick_params('y', colors='r') 128 | 129 | -------------------------------------------------------------------------------- /ocv_model/funcs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions used by OCV model 3 | """ 4 | 5 | import numpy as np 6 | 7 | 8 | def OCVfromSOCtemp(soc, temp, model): 9 | soccol = soc # force soc to be column vector 10 | SOC = model.SOC # force to be column vector 11 | OCV0 = model.OCV0 # force to be column vector 12 | OCVrel = model.OCVrel # force to be column vector 13 | 14 | tempcol = temp * np.ones(len(soccol)) 15 | 16 | diffSOC = SOC[1] - SOC[0] # spacing between SOC points - assume uniform 17 | ocv = np.zeros(len(soccol)) # initialize output to zero 18 | I1, = np.where(soccol <= SOC[0]) # indices of socs below model-stored data 19 | I2, = np.where(soccol >= SOC[-1]) # and of socs above model-stored data 20 | I3, = np.where((soccol > SOC[0]) & (soccol < SOC[-1])) # the rest of them 21 | I6 = np.isnan(soccol) # if input is "not a number" for any locations 22 | 23 | # for voltages less than lowest stored soc datapoint, extrapolate off 24 | # low end of table 25 | if len(I1) != 0: 26 | dv = (OCV0[1] + tempcol*OCVrel[1]) - (OCV0[0] + tempcol*OCVrel[0]) 27 | ocv[I1] = (soccol[I1] - SOC[0])*dv[I1]/diffSOC + OCV0[0] + tempcol[I1]*OCVrel[0] 28 | 29 | # for voltages greater than highest stored soc datapoint, extrapolate off 30 | # high end of table 31 | if len(I2) != 0: 32 | dv = (OCV0[-1] + tempcol*OCVrel[-1]) - (OCV0[-2] + tempcol*OCVrel[-2]) 33 | ocv[I2] = (soccol[I2] - SOC[-1])*dv[I2]/diffSOC + OCV0[-1] + tempcol[I2]*OCVrel[-1] 34 | 35 | # for normal soc range, manually interpolate (10x faster than "interp1") 36 | I4 = (soccol[I3] - SOC[0])/diffSOC # using linear interpolation 37 | I5 = np.floor(I4) 38 | I5 = I5.astype(int) 39 | I45 = I4 - I5 40 | omI45 = 1 - I45 41 | ocv[I3] = OCV0[I5]*omI45 + OCV0[I5+1]*I45 42 | ocv[I3] = ocv[I3] + tempcol[I3]*(OCVrel[I5]*omI45 + OCVrel[I5+1]*I45) 43 | ocv[I6] = 0 # replace NaN SOCs with zero voltage 44 | return ocv 45 | 46 | 47 | -------------------------------------------------------------------------------- /ocv_model/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Models for the OCV calculations 3 | """ 4 | 5 | import pandas as pd 6 | 7 | 8 | class BatteryScript: 9 | """ 10 | Script or experiment performed on the battery cell. 11 | """ 12 | 13 | def __init__(self, csvdata): 14 | """ 15 | Initialize the script measurements. 16 | """ 17 | columns = ['Discharge_Capacity(Ah)', 'Charge_Capacity(Ah)', 'Step_Index', 'Voltage(V)'] 18 | df = pd.read_csv(csvdata, usecols=columns) 19 | self.disAh = df['Discharge_Capacity(Ah)'].values 20 | self.chgAh = df['Charge_Capacity(Ah)'].values 21 | self.step = df['Step_Index'].values 22 | self.voltage = df['Voltage(V)'].values 23 | 24 | 25 | class BatteryData: 26 | """ 27 | Object to store battery measurements from script or experiment for a 28 | certain temperature. 29 | """ 30 | 31 | def __init__(self, csvfiles): 32 | """ 33 | Initialize with list of CSV data files. 34 | """ 35 | self.s1 = BatteryScript(csvfiles[0]) 36 | self.s2 = BatteryScript(csvfiles[1]) 37 | self.s3 = BatteryScript(csvfiles[2]) 38 | self.s4 = BatteryScript(csvfiles[3]) 39 | 40 | 41 | class FileData: 42 | """ 43 | Calculated data from file. 44 | """ 45 | 46 | def __init__(self, disV, disZ, chgV, chgZ, rawocv, temp): 47 | self.disV = disV 48 | self.disZ = disZ 49 | self.chgV = chgV 50 | self.chgZ = chgZ 51 | self.rawocv = rawocv 52 | self.temp = temp 53 | 54 | 55 | class ModelOcv: 56 | """ 57 | Model representing OCV results. 58 | """ 59 | 60 | def __init__(self, OCV0, OCVrel, SOC, OCV, SOC0, SOCrel, OCVeta, OCVQ): 61 | self.OCV0 = OCV0 62 | self.OCVrel = OCVrel 63 | self.SOC = SOC 64 | self.OCV = OCV 65 | self.SOC0 = SOC0 66 | self.SOCrel = SOCrel 67 | self.OCVeta = OCVeta 68 | self.OCVQ = OCVQ 69 | 70 | 71 | -------------------------------------------------------------------------------- /ocv_model/ocv.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python version of the runProcessOCV Matlab file for A123_OCV battery cell. 3 | """ 4 | 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | import json 8 | 9 | from models import BatteryData, FileData, ModelOcv 10 | from funcs import OCVfromSOCtemp 11 | 12 | from pathlib import Path 13 | 14 | # Parameters and Data 15 | # ------------------------------------------------------------------------------ 16 | 17 | # temperatures for cell experiments 18 | temps = np.array([-25, -15, -5, 5, 15, 25, 35, 45]) 19 | 20 | minV = 2.00 # minimum cell voltage, used for plotting results 21 | maxV = 3.75 # maximum cell voltage, used for plotting results 22 | 23 | SOC = np.arange(0, 1+0.005, 0.005).round(decimals=3) # range for state of charge 24 | 25 | # initialize variables to store calculations 26 | eta = np.zeros(len(temps)) # coulombic efficiency 27 | Q = np.zeros(len(temps)) # apparent total capacity 28 | 29 | # initialize array to store battery cell data 30 | data = np.zeros(len(temps), dtype=object) 31 | 32 | # load battery cell data for each temperature as objects then store in data array 33 | for idx, temp in enumerate(temps): 34 | if temp < 0: 35 | tempfmt = f'{abs(temp):02}' 36 | files = [Path(f'./ocv_data/A123_OCV_N{tempfmt}_S1.csv'), Path(f'./ocv_data/A123_OCV_N{tempfmt}_S2.csv'), 37 | Path(f'./ocv_data/A123_OCV_N{tempfmt}_S3.csv'), Path(f'./ocv_data/A123_OCV_N{tempfmt}_S4.csv')] 38 | data[idx] = BatteryData(files) 39 | else: 40 | tempfmt = f'{abs(temp):02}' 41 | files = [Path(f'./ocv_data/A123_OCV_P{tempfmt}_S1.csv'), Path(f'./ocv_data/A123_OCV_P{tempfmt}_S2.csv'), 42 | Path(f'./ocv_data/A123_OCV_P{tempfmt}_S3.csv'), Path(f'./ocv_data/A123_OCV_P{tempfmt}_S4.csv')] 43 | data[idx] = BatteryData(files) 44 | 45 | # initial array to store calculated data 46 | filedata = np.zeros(len(temps), dtype=object) 47 | 48 | # Process 25 degC data to find raw OCV relationship and eta25 49 | # ------------------------------------------------------------------------------ 50 | 51 | k, = np.where(temps == 25)[0] # index where temperature is 25 degC 52 | p25 = data[k] 53 | 54 | # compute total discharge in ampere hours, Ah 55 | totDisAh = p25.s1.disAh[-1] + p25.s2.disAh[-1] + p25.s3.disAh[-1] + p25.s4.disAh[-1] 56 | 57 | # compute total charge in ampere hours, Ah 58 | totChgAh = p25.s1.chgAh[-1] + p25.s2.chgAh[-1] + p25.s3.chgAh[-1] + p25.s4.chgAh[-1] 59 | 60 | # the 25 degC coulombic efficiency 61 | eta25 = totDisAh/totChgAh 62 | eta[k] = eta25 63 | 64 | # adjust charge Ah in all scripts per eta25 65 | p25.s1.chgAh = p25.s1.chgAh * eta25 66 | p25.s2.chgAh = p25.s2.chgAh * eta25 67 | p25.s3.chgAh = p25.s3.chgAh * eta25 68 | p25.s4.chgAh = p25.s4.chgAh * eta25 69 | 70 | # compute cell capacity at 25 degC, should be essentially same at 71 | # all temps, but we're computing them individually to check this 72 | Q25 = p25.s1.disAh[-1] + p25.s2.disAh[-1] - p25.s1.chgAh[-1] - p25.s2.chgAh[-1] 73 | Q[k] = Q25 74 | 75 | # discharge 76 | indD = np.where(p25.s1.step == 2)[0] # slow discharge step 77 | IR1Da = p25.s1.voltage[indD[0]-1] - p25.s1.voltage[indD[0]] # the i*R voltage drop at beginning of discharge 78 | IR2Da = p25.s1.voltage[indD[-1]+1] - p25.s1.voltage[indD[-1]] # the i*R voltage drop at end of discharge 79 | 80 | # charge 81 | indC = np.where(p25.s3.step == 2)[0] # slow charge step 82 | IR1Ca = p25.s3.voltage[indC[0]] - p25.s3.voltage[indC[0]-1] # the i*R voltage rise at beginning of charge 83 | IR2Ca = p25.s3.voltage[indC[-1]] - p25.s3.voltage[indC[-1]+1] # the i*R voltage rise at end of charge 84 | 85 | # put bounds on R 86 | IR1D = min(IR1Da, 2*IR2Ca) 87 | IR2D = min(IR2Da, 2*IR1Ca) 88 | IR1C = min(IR1Ca, 2*IR2Da) 89 | IR2C = min(IR2Ca, 2*IR1Da) 90 | 91 | # discharge 92 | blendD = np.linspace(0, 1, len(indD)) # linear blending from 0 to 1 for discharge 93 | IRblendD = IR1D + (IR2D - IR1D)*blendD # blend resistances for discharge 94 | disV = p25.s1.voltage[indD] + IRblendD # approximate discharge voltage at each point 95 | disZ = 1 - p25.s1.disAh[indD]/Q25 # approximate SOC at each point 96 | disZ = disZ + (1 - disZ[0]) 97 | 98 | # charge 99 | blendC = np.linspace(0, 1, len(indC)) # linear blending from 0 to 1 for charge 100 | IRblendC = IR1C + (IR2C - IR1C)*blendC # blend resistances for charge 101 | chgV = p25.s3.voltage[indC] - IRblendC # approximate charge voltage at each point 102 | chgZ = p25.s3.chgAh[indC]/Q25 # approximate SOC at each point 103 | chgZ = chgZ - chgZ[0] 104 | 105 | # compute voltage difference between charge and discharge at 50% SOC force i*R 106 | # compensated curve to pass half-way between each charge and discharge at this 107 | # point notice that vector chgZ and disZ must be increasing 108 | deltaV50 = np.interp(0.5, chgZ, chgV) - np.interp(0.5, disZ[::-1], disV[::-1]) 109 | ind = np.where(chgZ < 0.5)[0] 110 | vChg = chgV[ind] - chgZ[ind]*deltaV50 111 | zChg = chgZ[ind] 112 | ind = np.where(disZ > 0.5)[0] 113 | vDis = disV[ind] + (1 - disZ[ind])*deltaV50 114 | zDis = disZ[ind] 115 | 116 | # rawocv now has our best guess of true ocv at this temperature 117 | rawocv = np.interp(SOC, np.concatenate([zChg, zDis[::-1]]), np.concatenate([vChg, vDis[::-1]])) 118 | 119 | # store calculated data into filedata object 120 | filedata[k] = FileData(p25.s1.voltage[indD], disZ, p25.s3.voltage[indC], chgZ, rawocv, temps[k]) 121 | 122 | # Process Other Temperatures to Find Raw OCV Relationship and Eta 123 | # Everything that follows is same as at 25 degC, except we need to compensate 124 | # for different coulombic efficiencies eta at different temperatures. 125 | # ------------------------------------------------------------------------------ 126 | 127 | not25, = np.where(temps != 25) 128 | 129 | for k in not25: 130 | # adjust charge Ah per eta25 131 | data[k].s2.chgAh = data[k].s2.chgAh * eta25 132 | data[k].s4.chgAh = data[k].s4.chgAh * eta25 133 | 134 | # coulombic efficiency 135 | eta[k] = ((data[k].s1.disAh[-1] + data[k].s2.disAh[-1] + data[k].s3.disAh[-1] 136 | + data[k].s4.disAh[-1] - data[k].s2.chgAh[-1] - data[k].s4.chgAh[-1]) 137 | / (data[k].s1.chgAh[-1] + data[k].s3.chgAh[-1])) 138 | 139 | # adjust charge Ah per eta at current temp 140 | data[k].s1.chgAh = data[k].s1.chgAh * eta[k] 141 | data[k].s3.chgAh = data[k].s3.chgAh * eta[k] 142 | 143 | # compute cell capacity 144 | Q[k] = data[k].s1.disAh[-1] + data[k].s2.disAh[-1] - data[k].s1.chgAh[-1] - data[k].s2.chgAh[-1] 145 | 146 | # discharge 147 | indD = np.where(data[k].s1.step == 2)[0] # slow discharge step 148 | IR1D = data[k].s1.voltage[indD[0]-1] - data[k].s1.voltage[indD[0]] # the i*R voltage drop at beginning of discharge 149 | IR2D = data[k].s1.voltage[indD[-1]+1] - data[k].s1.voltage[indD[-1]] # the i*R voltage drop at end of discharge 150 | 151 | # charge 152 | indC = np.where(data[k].s3.step == 2)[0] # slow charge step 153 | IR1C = data[k].s3.voltage[indC[0]] - data[k].s3.voltage[indC[0]-1] # the i*R voltage rise at beginning of charge 154 | IR2C = data[k].s3.voltage[indC[-1]] - data[k].s3.voltage[indC[-1]+1] # the i*R voltage rise at end of charge 155 | 156 | # put bounds on R 157 | IR1D = min(IR1D, 2*IR2C) 158 | IR2D = min(IR2D, 2*IR1C) 159 | IR1C = min(IR1C, 2*IR2D) 160 | IR2C = min(IR2C, 2*IR1D) 161 | 162 | # discharge 163 | blend = np.linspace(0, 1, len(indD)) # linear blending from 0 to 1 for discharge 164 | IRblend = IR1D + (IR2D - IR1D)*blend # blend resistances for discharge 165 | disV = data[k].s1.voltage[indD] + IRblend # approximate discharge voltage at each point 166 | disZ = 1 - data[k].s1.disAh[indD]/Q25 # approximate SOC at each point 167 | disZ = disZ + (1 - disZ[0]) 168 | 169 | # charge 170 | blend = np.linspace(0, 1, len(indC)) # linear blending from 0 to 1 for charge 171 | IRblend = IR1C + (IR2C - IR1C)*blend # blend resistances for charge 172 | chgV = data[k].s3.voltage[indC] - IRblend # approximate charge voltage at each point 173 | chgZ = data[k].s3.chgAh[indC]/Q25 # approximate SOC at each point 174 | chgZ = chgZ - chgZ[0] 175 | 176 | # compute voltage difference between charge and discharge at 50% SOC force i*R 177 | # compensated curve to pass half-way between each charge and discharge at this 178 | # point notice that vector chgZ and disZ must be increasing 179 | deltaV50 = np.interp(0.5, chgZ, chgV) - np.interp(0.5, disZ[::-1], disV[::-1]) 180 | ind = np.where(chgZ < 0.5)[0] 181 | vChg = chgV[ind] - chgZ[ind]*deltaV50 182 | zChg = chgZ[ind] 183 | ind = np.where(disZ > 0.5)[0] 184 | vDis = disV[ind] + (1 - disZ[ind])*deltaV50 185 | zDis = disZ[ind] 186 | 187 | # rawocv now has our best guess of true ocv at this temperature 188 | rawocv = np.interp(SOC, np.concatenate([zChg, zDis[::-1]]), np.concatenate([vChg, vDis[::-1]])) 189 | 190 | # store calculated data into filedata object 191 | filedata[k] = FileData(data[k].s1.voltage[indD], disZ, data[k].s3.voltage[indC], chgZ, rawocv, temps[k]) 192 | 193 | # Use the SOC versus OCV data now available at each individual 194 | # temperature to compute an OCV0 and OCVrel relationship 195 | # ------------------------------------------------------------------------------ 196 | 197 | # compile the voltages and temperatures into single arrays rather than structures 198 | postemps = temps[temps > 0] # temps > 0 199 | numtempskept = len(postemps) # number of temps > 0 200 | 201 | nocv = len(filedata[5].rawocv) # number of rawocv values based on 25 degC results 202 | Vraw = np.zeros([numtempskept, nocv]) # initialize rawocv array 203 | idxpos = np.where(temps > 0)[0] # indices of positive file temperatures 204 | 205 | for k in range(numtempskept): 206 | Vraw[k] = filedata[idxpos[k]].rawocv 207 | 208 | # use linear least squares to determine best guess for OCV at 0 degC 209 | # and then the per-degree OCV change 210 | OCV0 = np.zeros(len(SOC)) 211 | OCVrel = np.zeros(len(SOC)) 212 | H = np.ones([numtempskept, 2]) 213 | H[:, 1] = postemps 214 | 215 | for k in range(len(SOC)): 216 | X = np.linalg.lstsq(H, Vraw[:, k], rcond=None) 217 | OCV0[k] = X[0][0] 218 | OCVrel[k] = X[0][1] 219 | 220 | modelocv = ModelOcv(OCV0, OCVrel, SOC, 0, 0, 0, 0, 0) 221 | 222 | # Make SOC0 and SOCrel 223 | # Do same kind of analysis to find soc as a function of ocv 224 | # ------------------------------------------------------------------------------ 225 | 226 | z = np.arange(-0.1, 1.1, 0.01) # test soc vector 227 | v = np.arange(minV-0.01, maxV+0.02, 0.01).round(decimals=2) 228 | socs = np.zeros((len(temps), len(v))) 229 | 230 | for k, _ in enumerate(temps): 231 | T = temps[k] 232 | v1 = OCVfromSOCtemp(z, T, modelocv) 233 | socs[k, :] = np.interp(v, v1, z) 234 | 235 | SOC0 = np.zeros(len(v)) 236 | SOCrel = SOC0 237 | H = np.ones([len(temps), 2]) 238 | H[:, 1] = temps 239 | 240 | for k in range(len(v)): 241 | X = np.linalg.lstsq(H, socs[:, k], rcond=None) # fit SOC(v,T) = 1*SOC0(v) + T*SOCrel(v) 242 | SOC0[k] = X[0][0] 243 | SOCrel[k] = X[0][1] 244 | 245 | # store ocv results in model object 246 | # ------------------------------------------------------------------------------ 247 | modelocv = ModelOcv(OCV0, OCVrel, SOC, v, SOC0, SOCrel, eta, Q) 248 | 249 | # Plot Results 250 | # ------------------------------------------------------------------------------ 251 | 252 | plt.close('all') 253 | plt.ion() 254 | 255 | for k, _ in enumerate(temps): 256 | err = filedata[k].rawocv - OCVfromSOCtemp(SOC, filedata[k].temp, modelocv) 257 | rmserr = np.sqrt(np.mean(err**2)) 258 | 259 | plt.figure(k+1) 260 | plt.plot(100*SOC, OCVfromSOCtemp(SOC, filedata[k].temp, modelocv), 'k', label='model') 261 | plt.plot(100*SOC, filedata[k].rawocv, 'r', label='approx') 262 | plt.plot(100*filedata[k].disZ, filedata[k].disV, 'g--', label='dis') 263 | plt.plot(100*filedata[k].chgZ, filedata[k].chgV, 'b--', label='chg') 264 | plt.text(2, maxV-0.15, f'RMS error = {rmserr*1000:.01f} mV') 265 | plt.ylim(minV-0.2, maxV+0.2) 266 | plt.title(f'A123 OCV relationship at temp = {temps[k]}') 267 | plt.xlabel('SOC (%)') 268 | plt.ylabel('OCV (V)') 269 | plt.legend(numpoints=1, loc='lower right') 270 | plt.grid() 271 | 272 | 273 | # convert model object to dict, then save in JSON to disk 274 | # ------------------------------------------------------------------------------ 275 | if True: 276 | modelocv = {k:v.tolist() for k,v in modelocv.__dict__.items() if isinstance(v, np.ndarray)} 277 | with open('modelocv.json', 'w') as json_file: 278 | json.dump(modelocv, json_file, indent=4) 279 | 280 | --------------------------------------------------------------------------------