# Paul Gasper, NREL # This model is replicated as reported by Schmalsteig et al, J. Power Sources 257 (2014) 325-334 # http://dx.doi.org/10.1016/j.jpowsour.2014.02.012 import numpy as np from functions.extract_stressors import extract_stressors from functions.state_functions import update_power_state # EXPERIMENTAL AGING DATA SUMMARY: # Calendar aging varied SOC at 50 Celsius, and temperature at 50% state-of-charge. # Cycle aging varied depth-of-discharge and average state-of-charge at 35 Celsius at # charge and discharge rates of 1C. # Relative discharge capacity is reported from measurements recorded at 35 Celsius and 1C rate. # Relative DC resistance is reported after fitting of 10s 1C discharge pulses near 50% state-of-charge. # MODEL SENSITIVITY # The model predicts degradation rate versus time as a function of temperature and average # state-of-charge and degradation rate versus equivalent full cycles (charge-throughput) as # a function of average voltage and depth-of-discharge. # MODEL LIMITATIONS # Cycle degradation predictions are NOT SENSITIVE TO TEMPERATURE OR C-RATE. Cycling degradation predictions # are ONLY ACCURATE NEAR 1C RATE AND 35 CELSIUS CELL TEMPERATURE. class Nmc111_Gr_Sanyo2Ah_Battery: # Model predicting the degradation of Sanyo UR18650E cells, published by Schmalsteig et al: # http://dx.doi.org/10.1016/j.jpowsour.2014.02.012. # More detailed analysis of cell performance and voltage vs. state-of-charge data was copied from # Ecker et al: http://dx.doi.org/10.1016/j.jpowsour.2013.09.143 (KNEE POINTS OBSERVED IN ECKER ET AL # AT HIGH DEPTH OF DISCHARGE WERE SIMPLY NOT ADDRESSED DURING MODEL FITTING BY SCHMALSTEIG ET AL). # Voltage lookup table here use data from Ecker et al for 0/10% SOC, and other values were extracted # from Figure 1 in Schmalsteig et al using WebPlotDigitizer. def __init__(self): # States: Internal states of the battery model self.states = { 'qLoss_t': np.array([0]), 'qLoss_EFC': np.array([0]), 'rGain_t': np.array([0]), 'rGain_EFC': np.array([0]), } # Outputs: Battery properties derived from state values self.outputs = { 'q': np.array([1]), 'q_t': np.array([1]), 'q_EFC': np.array([1]), 'r': np.array([1]), 'r_t': np.array([1]), 'r_EFC': np.array([1]), } # Stressors: History of stressors on the battery self.stressors = { 'delta_t_days': np.array([np.nan]), 't_days': np.array([0]), 'delta_efc': np.array([np.nan]), 'efc': np.array([0]), 'TdegK': np.array([np.nan]), 'soc': np.array([np.nan]), 'Vrms': np.array([np.nan]), 'dod': np.array([np.nan]), } # Rates: History of stressor-dependent degradation rates self.rates = { 'q_alpha': np.array([np.nan]), 'q_beta': np.array([np.nan]), 'r_alpha': np.array([np.nan]), 'r_beta': np.array([np.nan]), } # Nominal capacity @property def _cap(self): return 2.15 # SOC index @property def _soc_index(self): return np.array([0,0.008637153,0.026779514,0.044921875,0.063064236,0.081206597,0.099348958,0.117491319,0.135633681,0.153776042,0.171918403,0.190060764,0.208203125,0.226345486,0.244487847,0.262630208,0.280772569,0.298914931,0.317057292,0.335199653,0.353342014,0.371484375,0.389626736,0.407769097,0.425911458,0.444053819,0.462196181,0.480338542,0.498480903,0.516623264,0.534765625,0.552907986,0.571050347,0.589192708,0.607335069,0.625477431,0.643619792,0.661762153,0.679904514,0.698046875,0.716189236,0.734331597,0.752473958,0.770616319,0.788758681,0.806901042,0.825043403,0.843185764,0.861328125,0.879470486,0.897612847,0.915755208,0.933897569,0.952039931,0.970182292,0.988324653,0.998220486,1]) # OCV @property def _ocv(self): return np.array([3.331,3.345014187,3.37917149,3.411603677,3.440585632,3.466289865,3.490268982,3.511315401,3.529946658,3.547197821,3.561688798,3.574972194,3.586357962,3.597053683,3.605506753,3.613442288,3.620342753,3.626380661,3.632073544,3.63690387,3.642079219,3.646909545,3.652084894,3.657605266,3.663470662,3.670198615,3.677444104,3.686759732,3.696420384,3.708323686,3.720572012,3.734372943,3.749553967,3.765252525,3.781123596,3.797857224,3.814590852,3.832532062,3.849783226,3.866861877,3.884458064,3.900846669,3.917752809,3.934831461,3.953462717,3.971921462,3.991415276,4.011771649,4.03195551,4.05196686,4.070770628,4.087849279,4.104237884,4.120108955,4.135980025,4.152541142,4.160649189,4.162]) # Voltage lookup table def calc_voltage(self, soc): # calculate cell voltage from soc vector return np.interp(soc, self._soc_index, self._ocv, left=self._ocv[0], right=self._ocv[-1]) # Define life model parameters @property def _params_life(self): return { # Capacity fade parameters 'qcal_A_V': 7.543, 'qcal_B_V': -23.75, 'qcal_C_TdegK': -6976, 'qcal_p': 0.75, 'qcyc_A_V': 7.348e-3, 'qcyc_B_V': 3.667, 'qcyc_C': 7.6e-4, 'qcyc_D_DOD': 4.081e-3, 'qcyc_p': 0.5, # Resistance growth parameters 'rcal_A_V': 5.270, 'rcal_B_V': -16.32, 'rcal_C_TdegK': -5986, 'rcal_p': 0.75, 'rcyc_A_V': 2.153e-4, 'rcyc_B_V': 3.725, 'rcyc_C': -1.521e-5, 'rcyc_D_DOD': 2.798e-4, } # Battery model def update_battery_state(self, t_secs, soc, T_celsius): # Update the battery states, based both on the degradation state as well as the battery performance # at the ambient temperature, T_celsius # Inputs: # t_secs (ndarry): vector of the time in seconds since beginning of life for the soc_timeseries data points # soc (ndarry): vector of the state-of-charge of the battery at each t_sec # T_celsius (ndarray): the temperature of the battery during this time period, in Celsius units. # Check some input types: if not isinstance(t_secs, np.ndarray): raise TypeError('Input "t_secs" must be a numpy.ndarray') if not isinstance(soc, np.ndarray): raise TypeError('Input "soc" must be a numpy.ndarray') if not isinstance(T_celsius, np.ndarray): raise TypeError('Input "T_celsius" must be a numpy.ndarray') if not (len(t_secs) == len(soc) and len(t_secs) == len(T_celsius)): raise ValueError('All input timeseries must be the same length') self.__update_states(t_secs, soc, T_celsius) self.__update_outputs() def __update_states(self, t_secs, soc, T_celsius): # Update the battery states, based both on the degradation state as well as the battery performance # at the ambient temperature, T_celsius # Inputs: # t_secs (ndarry): vector of the time in seconds since beginning of life for the soc_timeseries data points # soc (ndarry): vector of the state-of-charge of the battery at each t_sec # T_celsius (ndarray): the temperature of the battery during this time period, in Celsius units. # Extract stressors delta_t_secs = t_secs[-1] - t_secs[0] delta_t_days, delta_efc, TdegK, soc, Ua, dod, Crate, cycles = extract_stressors(t_secs, soc, T_celsius) # Calculate RMS voltage, charge throughput V_rms = np.sqrt(np.mean(self.calc_voltage(soc)**2)) Ah_throughput = delta_efc * 2 * self._cap # Grab parameters p = self._params_life # Calculate the degradation coefficients alpha_cap = (p['qcal_A_V'] * self.calc_voltage(soc) + p['qcal_B_V']) * 1e6 * np.exp(p['qcal_C_TdegK'] / TdegK) alpha_res = (p['rcal_A_V'] * self.calc_voltage(soc) + p['rcal_B_V']) * 1e5 * np.exp(p['rcal_C_TdegK'] / TdegK) beta_cap = ( p['qcyc_A_V'] * (V_rms - p['qcyc_B_V']) ** 2 + p['qcyc_C'] + p['qcyc_D_DOD'] * dod ) beta_res = ( p['rcyc_A_V'] * (V_rms - p['rcyc_B_V']) ** 2 + p['rcyc_C'] + p['rcyc_D_DOD'] * dod ) # Calculate time based average of each rate alpha_cap = np.trapz(alpha_cap, x=t_secs) / delta_t_secs alpha_res = np.trapz(alpha_res, x=t_secs) / delta_t_secs # Calculate incremental state changes states = self.states # Capacity dq_t = update_power_state(states['qLoss_t'][-1], delta_t_days, alpha_cap, p['qcal_p']) dq_EFC = update_power_state(states['qLoss_EFC'][-1], Ah_throughput, beta_cap, p['qcyc_p']) # Resistance dr_t = update_power_state(states['rGain_t'][-1], delta_t_days, alpha_res, p['rcal_p']) dr_EFC = beta_res * Ah_throughput # Accumulate and store states dx = np.array([dq_t, dq_EFC, dr_t, dr_EFC]) for k, v in zip(states.keys(), dx): x = self.states[k][-1] + v self.states[k] = np.append(self.states[k], x) # Store stressors t_days = self.stressors['t_days'][-1] + delta_t_days efc = self.stressors['efc'][-1] + delta_efc stressors = np.array([delta_t_days, t_days, delta_efc, efc, np.mean(TdegK), np.mean(soc), V_rms, dod]) for k, v in zip(self.stressors.keys(), stressors): self.stressors[k] = np.append(self.stressors[k], v) # Store rates rates = np.array([alpha_cap, beta_cap, alpha_res, beta_res]) for k, v in zip(self.rates.keys(), rates): self.rates[k] = np.append(self.rates[k], v) def __update_outputs(self): # Calculate outputs, based on current battery state states = self.states p = self._params_life # Capacity q_t = 1 - states['qLoss_t'][-1] q_EFC = 1 - states['qLoss_EFC'][-1] q = 1 - states['qLoss_t'][-1] - states['qLoss_EFC'][-1] # Resistance r_t = 1 + states['rGain_t'][-1] r_EFC = 1 + states['rGain_EFC'][-1] r = 1 + states['rGain_t'][-1] + states['rGain_EFC'][-1] # Assemble output out = np.array([q, q_t, q_EFC, r, r_t, r_EFC]) # Store results for k, v in zip(list(self.outputs.keys()), out): self.outputs[k] = np.append(self.outputs[k], v)