Initial commit from internal repo.

This commit is contained in:
pauljgasper 2023-04-07 15:05:55 -06:00
commit de2d5b2091
14 changed files with 2382 additions and 0 deletions

5
NOTICE.txt Normal file
View File

@ -0,0 +1,5 @@
NOTICE
BLAST-Python Copyright ©2022 Alliance for Sustainable Energy, LLC
These data were produced by the Alliance for Sustainable Energy, LLC (Contractor) under Contract No. DE-AC36-08GO28308 with the U.S. Department of Energy (DOE). During the period of commercialization or such other time period specified by the DOE, the Government is granted for itself and others acting on its behalf a nonexclusive, paid-up, irrevocable worldwide license in this data to reproduce, prepare derivative works, and perform publicly and display publicly, by or on behalf of the Government. Subsequent to that period the Government is granted for itself and others acting on its behalf a nonexclusive, paid-up, irrevocable worldwide license in this data to reproduce, prepare derivative works, distribute copies to the public, perform publicly and display publicly, and to permit others to do so. The specific term of the license can be identified by inquiry made to the Contractor or DOE. NEITHER CONTRACTOR, THE UNITED STATES, NOR THE UNITED STATES DEPARTMENT OF ENERGY, NOR ANY OF THEIR EMPLOYEES, MAKES ANY WARRANTY, EXPRESS OR IMPLIED, OR ASSUMES ANY LEGAL LIABILITY OR RESPONSIBILITY FOR THE ACCURACY, COMPLETENESS, OR USEFULNESS OF ANY DATA, APPARATUS, PRODUCT, OR PROCESS DISCLOSED, OR REPRESENTS THAT ITS USE WOULD NOT INFRINGE PRIVATELY OWNED RIGHTS.

6
README.md Normal file
View File

@ -0,0 +1,6 @@
### BLAST-Lite
Battery Lifetime Analysis and Simulation Toolsuite in the python programming language.
Provides a library of battery lifetime and degradation models for various commercial lithium-ion batteries.
SWR-22-69

BIN
SWR-22-69.docx Normal file

Binary file not shown.

731
example.ipynb Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,32 @@
import numpy as np
import functions.rainflow as rainflow
def extract_stressors(t_secs, soc, T_celsius):
# Extract stressors
t_days = t_secs / (24*60*60)
delta_t_days = t_days[-1] - t_days[0]
delta_efc = np.sum(np.abs(np.ediff1d(soc, to_begin=0)))/2 # sum the total changes to SOC / 2
dod = np.max(soc) - np.min(soc)
abs_instantaneous_crate = np.abs(np.diff(soc)/np.diff(t_secs/(60*60))) # get instantaneous C-rates
abs_instantaneous_crate[abs_instantaneous_crate < 1e-2] = 0 # get rid of extremely small values (storage) before calculating mean
Crate = np.trapz(abs_instantaneous_crate, t_days[1:]) / delta_t_days
# Check storage condition, which will give nan Crate:
if np.isnan(Crate):
Crate = 0
T_kelvin = T_celsius + 273.15
# Estimate Ua (anode to reference potential) from SOC.
# Uses the equation from Safari and Delacourt, https://doi.org/10.1149/1.3567007.
# Anode stoichiometry is assumed to be the same for any chemistry/cell, and is calculated using the equation from Schimpe et al https://doi.org/10.1149/2.1181714jes
# While this will not be precise, it still helps get a guess as to where the plateaus of the anode-reference potential are.
def get_Xa(soc):
return 8.5*10**-3 + soc*(0.78 - 8.5*10**-3)
def get_Ua(Xa):
return (0.6379 + 0.5416*np.exp(-305.5309*Xa) +
0.044*np.tanh(-1*(Xa-0.1958)/0.1088) - 0.1978*np.tanh((Xa-1.0571)/0.0854) -
0.6875*np.tanh((Xa+0.0117)/0.0529) - 0.0175*np.tanh((Xa-0.5692)/0.0875))
Ua = get_Ua(get_Xa(soc))
cycles = rainflow.count_cycles(soc)
cycles = sum(i for _, i in cycles)
return delta_t_days, delta_efc, T_kelvin, soc, Ua, dod, Crate, cycles

176
functions/rainflow.py Normal file
View File

@ -0,0 +1,176 @@
"""
Implements rainflow cycle counting algorythm for fatigue analysis
according to section 5.4.4 in ASTM E1049-85 (2011).
"""
from __future__ import division
from collections import deque, defaultdict
import math
try:
from importlib import metadata as _importlib_metadata
except ImportError:
import importlib_metadata as _importlib_metadata
# __version__ = _importlib_metadata.version("rainflow")
def _get_round_function(ndigits=None):
if ndigits is None:
def func(x):
return x
else:
def func(x):
return round(x, ndigits)
return func
def reversals(series):
"""Iterate reversal points in the series.
A reversal point is a point in the series at which the first derivative
changes sign. Reversal is undefined at the first (last) point because the
derivative before (after) this point is undefined. The first and the last
points are treated as reversals.
Parameters
----------
series : iterable sequence of numbers
Yields
------
Reversal points as tuples (index, value).
"""
series = iter(series)
x_last, x = next(series, None), next(series, None)
if x_last is None or x is None:
return
d_last = (x - x_last)
yield 0, x_last
index = None
for index, x_next in enumerate(series, start=1):
if x_next == x:
continue
d_next = x_next - x
if d_last * d_next < 0:
yield index, x
x_last, x = x, x_next
d_last = d_next
if index is not None:
yield index + 1, x_next
def extract_cycles(series):
"""Iterate cycles in the series.
Parameters
----------
series : iterable sequence of numbers
Yields
------
cycle : tuple
Each tuple contains (range, mean, count, start index, end index).
Count equals to 1.0 for full cycles and 0.5 for half cycles.
"""
points = deque()
def format_output(point1, point2, count):
i1, x1 = point1
i2, x2 = point2
rng = abs(x1 - x2)
mean = 0.5 * (x1 + x2)
return rng, mean, count, i1, i2
for point in reversals(series):
points.append(point)
while len(points) >= 3:
# Form ranges X and Y from the three most recent points
x1, x2, x3 = points[-3][1], points[-2][1], points[-1][1]
X = abs(x3 - x2)
Y = abs(x2 - x1)
if X < Y:
# Read the next point
break
elif len(points) == 3:
# Y contains the starting point
# Count Y as one-half cycle and discard the first point
yield format_output(points[0], points[1], 0.5)
points.popleft()
else:
# Count Y as one cycle and discard the peak and the valley of Y
yield format_output(points[-3], points[-2], 1.0)
last = points.pop()
points.pop()
points.pop()
points.append(last)
else:
# Count the remaining ranges as one-half cycles
while len(points) > 1:
yield format_output(points[0], points[1], 0.5)
points.popleft()
def count_cycles(series, ndigits=None, nbins=None, binsize=None):
"""Count cycles in the series.
Parameters
----------
series : iterable sequence of numbers
ndigits : int, optional
Round cycle magnitudes to the given number of digits before counting.
Use a negative value to round to tens, hundreds, etc.
nbins : int, optional
Specifies the number of cycle-counting bins.
binsize : int, optional
Specifies the width of each cycle-counting bin
Arguments ndigits, nbins and binsize are mutually exclusive.
Returns
-------
A sorted list containing pairs of range and cycle count.
The counts may not be whole numbers because the rainflow counting
algorithm may produce half-cycles. If binning is used then ranges
correspond to the right (high) edge of a bin.
"""
if sum(value is not None for value in (ndigits, nbins, binsize)) > 1:
raise ValueError(
"Arguments ndigits, nbins and binsize are mutually exclusive"
)
counts = defaultdict(float)
cycles = (
(rng, count)
for rng, mean, count, i_start, i_end in extract_cycles(series)
)
if nbins is not None:
binsize = (max(series) - min(series)) / nbins
if binsize is not None:
nmax = 0
for rng, count in cycles:
quotient = rng / binsize
n = int(math.ceil(quotient)) # using int for Python 2 compatibility
if nbins and n > nbins:
# Due to floating point accuracy we may get n > nbins,
# in which case we move rng to the preceeding bin.
if (quotient % 1) > 1e-6:
raise Exception("Unexpected error")
n = n - 1
counts[n * binsize] += count
nmax = max(n, nmax)
for i in range(1, nmax):
counts.setdefault(i * binsize, 0.0)
elif ndigits is not None:
round_ = _get_round_function(ndigits)
for rng, count in cycles:
counts[round_(rng)] += count
else:
for rng, count in cycles:
counts[rng] += count
return sorted(counts.items())

View File

@ -0,0 +1,48 @@
# Paul Gasper, NREL
# Functions for updating time-varying states
import numpy as np
def update_power_state(y0, dx, k, p):
if y0 == 0:
if dx == 0:
dydx = 0
else:
y0 = k*(dx**p)
dydx = y0/dx
else:
if dx == 0:
dydx = 0
else:
dydx = k*p*((y0/k)**((p-1)/p))
return dydx * dx
def update_power_B_state(y0, dx, k, p):
if y0 == 0:
if dx == 0:
dydx = 0
else:
y0 = (k*dx)**p
dydx = y0/dx
else:
if dx == 0:
dydx = 0
else:
z = (y0 ** (1/p)) / k
dydx = (p * (k*z)**p)/z
return dydx * dx
def update_sigmoid_state(y0, dx, y_inf, k, p):
if y0 == 0:
if dx == 0:
dydx = 0
else:
dy = 2 * y_inf * (1/2 - 1 / (1 + np.exp((k * dx) ** p)))
dydx = dy / dx
else:
if dx == 0:
dydx = 0
else:
x_inv = (1 / k) * ((np.log(-(2 * y_inf/(y0-y_inf)) - 1)) ** (1 / p) )
z = (k * x_inv) ** p
dydx = (2 * y_inf * p * np.exp(z) * z) / (x_inv * (np.exp(z) + 1) ** 2)
return dydx * dx

View File

@ -0,0 +1,271 @@
# Paul Gasper, NREL
import numpy as np
from functions.extract_stressors import extract_stressors
from functions.state_functions import update_power_B_state, update_sigmoid_state
import scipy.stats as stats
# EXPERIMENTAL AGING DATA SUMMARY:
# Aging test matrix varied temperature and state-of-charge for calendar aging, and
# varied depth-of-discharge, average state-of-charge, and C-rates for cycle aging.
# There is NO LOW TEMPERATURE cycling aging data, i.e., no lithium-plating induced by
# kinetic limitations on cell performance; CYCLING WAS ONLY DONE AT 25 CELSIUS AND 45 CELSIUS,
# so any model predictions at low temperature cannot incorporate low temperature degradation modes.
# Discharge capacity
# 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 state-of-charge during a cycle, depth-of-discharge, and average of the
# charge and discharge C-rates.
# MODEL LIMITATIONS
# There is no influence of TEMPERATURE on CYCLING DEGRADATION RATE due to limited data. This is
# NOT PHYSICALLY REALISTIC AND IS BASED ON LIMITED DATA.
class Lfp_Gr_SonyMurata3Ah_Battery:
# Model predicting the degradation of Sony-Murata 3 Ah LFP-Gr cylindrical cells.
# Data is from Technical University of Munich, reported in studies led by Maik Naumann.
# Capacity model identification was conducted at NREL. Resistance model is from Naumann et al.
# Naumann et al used an interative fitting procedure, but it was found that lower model error could be
# achieved by simply reoptimizing all resistance growth parameters to the entire data set.
# Calendar aging data source: https://doi.org/10.1016/j.est.2018.01.019
# Cycle aging data source: https://doi.org/10.1016/j.jpowsour.2019.227666
# Model identification source: https://doi.org/10.1149/1945-7111/ac86a8
# Degradation rate is a function of the aging stressors, i.e., ambient temperature and use.
# The state of the battery is updated throughout the lifetime of the cell.
# Performance metrics are capacity and DC resistance. These metrics change as a function of the
# cell's current degradation state, as well as the ambient temperature. The model predicts time and
# cycling dependent degradation. Cycling dependent degradation includes a break-in mechanism as well
# as long term cycling fade; the break-in mechanism strongly influenced results of the accelerated
# aging test, but is not expected to have much influence on real-world applications.
# Parameters to modify to change fade rates:
# q1_b0: rate of capacity loss due to calendar degradation
# q5_b0: rate of capacity loss due to cycling degradation
# k_ref_r_cal: rate of resistance growth due to calendar degradation
# A_r_cyc: rate of resistance growth due to cycling degradation
def __init__(self):
# States: Internal states of the battery model
self.states = {
'qLoss_LLI_t': np.array([0]),
'qLoss_LLI_EFC': np.array([0]),
'qLoss_BreakIn_EFC': np.array([1e-10]),
'rGain_LLI_t': np.array([0]),
'rGain_LLI_EFC': np.array([0]),
}
# Outputs: Battery properties derived from state values
self.outputs = {
'q': np.array([1]),
'q_LLI_t': np.array([1]),
'q_LLI_EFC': np.array([1]),
'q_BreakIn_EFC': np.array([1]),
'r': np.array([1]),
'r_LLI_t': np.array([1]),
'r_LLI_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]),
'Ua': np.array([np.nan]),
'dod': np.array([np.nan]),
'Crate': np.array([np.nan]),
}
# Rates: History of stressor-dependent degradation rates
self.rates = {
'q1': np.array([np.nan]),
'q3': np.array([np.nan]),
'q5': np.array([np.nan]),
'q7': np.array([np.nan]),
'r_kcal': np.array([np.nan]),
'r_kcyc': np.array([np.nan]),
}
# Nominal capacity
@property
def _cap(self):
return 3
# Define life model parameters
@property
def _params_life(self):
return {
# Capacity fade parameters
'q2': 0.000130510034211874,
'q1_b0': 0.989687151293590, # CHANGE to modify calendar degradation rate
'q1_b1': -2881067.56019324,
'q1_b2': 8742.06309157261,
'q3_b0': 0.000332850281062177,
'q3_b1': 734553185711.369,
'q3_b2': -2.82161575620780e-06,
'q3_b3': -3284991315.45121,
'q3_b4': 0.00127227593657290,
'q8': 0.00303553871631028,
'q9': 1.43752162947637,
'q7_b0': 0.582258029148225,
'q7_soc_skew': 0.0583128906965484,
'q7_soc_width': 0.208738181522897,
'q7_dod_skew': -3.80744333129564,
'q7_dod_width': 1.16126260428210,
'q7_dod_growth': 25.4130804598602,
'q6': 1.12847759334355,
'q5_b0': -6.81260579372875e-06, # CHANGE to modify cycling degradation rate
'q5_b1': 2.59615973160844e-05,
'q5_b2': 2.11559710307295e-06,
# Resistance growth parameters
'k_ref_r_cal': 3.4194e-10, # CHANGE to modify calendar degradation rate
'Ea_r_cal': 71827,
'C_r_cal': -3.3903,
'D_r_cal': 1.5604,
'A_r_cyc': -0.002, # CHANGE to modify cycling degradation rate
'B_r_cyc': 0.0021,
'C_r_cyc': 6.8477,
'D_r_cyc': 0.91882
}
# 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)
# Grab parameters
p = self._params_life
# Calculate the degradation coefficients
q1 = np.abs(
p['q1_b0']
* np.exp(p['q1_b1']*(1/(TdegK**2))*(Ua**0.5))
* np.exp(p['q1_b2']*(1/TdegK)*(Ua**0.5))
)
q3 = np.abs(
p['q3_b0']
* np.exp(p['q3_b1']*(1/(TdegK**4))*(Ua**(1/3)))
* np.exp(p['q3_b2']*(TdegK**3)*(Ua**(1/4)))
* np.exp(p['q3_b3']*(1/(TdegK**3))*(Ua**(1/3)))
* np.exp(p['q3_b4']*(TdegK**2)*(Ua**(1/4)))
)
q5 = np.abs(
p['q5_b0']
+ p['q5_b1']*dod
+ p['q5_b2']*np.exp((dod**2)*(Crate**3))
)
q7 = np.abs(
p['q7_b0']
* skewnormpdf(soc, p['q7_soc_skew'], p['q7_soc_width'])
* skewnormpdf(dod, p['q7_dod_skew'], p['q7_dod_width'])
* sigmoid(dod, 1, p['q7_dod_growth'], 1)
)
k_temp_r_cal = (
p['k_ref_r_cal']
* np.exp((-p['Ea_r_cal'] / 8.3144) * (1/TdegK - 1/298.15))
)
k_soc_r_cal = p['C_r_cal'] * (soc - 0.5)**3 + p['D_r_cal']
k_Crate_r_cyc = p['A_r_cyc'] * Crate + p['B_r_cyc']
k_dod_r_cyc = p['C_r_cyc']* (dod - 0.5)**3 + p['D_r_cyc']
# Calculate time based average of each rate
q1 = np.trapz(q1, x=t_secs) / delta_t_secs
q3 = np.trapz(q3, x=t_secs) / delta_t_secs
#q5 = np.trapz(q5, x=t_secs) / delta_t_secs # no time varying inputs
q7 = np.trapz(q7, x=t_secs) / delta_t_secs # no time varying inputs
k_temp_r_cal = np.trapz(k_temp_r_cal, x=t_secs) / delta_t_secs
k_soc_r_cal = np.trapz(k_soc_r_cal, x=t_secs) / delta_t_secs # no time varying inputs
#k_Crate_r_cyc = np.trapz(k_Crate_r_cyc, x=t_secs) / delta_t_secs # no time varying inputs
#k_dod_r_cyc = np.trapz(k_dod_r_cyc, x=t_secs) / delta_t_secs # no time varying inputs
# Calculate incremental state changes
states = self.states
# Capacity
dq_LLI_t = update_sigmoid_state(states['qLoss_LLI_t'][-1], delta_t_days, q1, p['q2'], q3)
dq_LLI_EFC = update_power_B_state(states['qLoss_LLI_EFC'][-1], delta_efc, q5, p['q6'])
if delta_efc / delta_t_days > 2: # only evalaute if more than 2 full cycles per day
dq_BreakIn_EFC = update_sigmoid_state(states['qLoss_BreakIn_EFC'][-1], delta_efc, q7, p['q8'], p['q9'])
else:
dq_BreakIn_EFC = 0
# Resistance
dr_LLI_t = k_temp_r_cal * k_soc_r_cal * delta_t_secs
dr_LLI_EFC = k_Crate_r_cyc * k_dod_r_cyc * delta_efc / 100
# Accumulate and store states
dx = np.array([dq_LLI_t, dq_LLI_EFC, dq_BreakIn_EFC, dr_LLI_t, dr_LLI_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), np.mean(Ua), dod, Crate])
for k, v in zip(self.stressors.keys(), stressors):
self.stressors[k] = np.append(self.stressors[k], v)
# Store rates
rates = np.array([q1, q3, q5, q7, k_temp_r_cal * k_soc_r_cal, k_Crate_r_cyc * k_dod_r_cyc])
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_LLI_t = 1 - states['qLoss_LLI_t'][-1]
q_LLI_EFC = 1 - states['qLoss_LLI_EFC'][-1]
q_BreakIn_EFC = 1 - states['qLoss_BreakIn_EFC'][-1]
q = 1 - states['qLoss_LLI_t'][-1] - states['qLoss_LLI_EFC'][-1] - states['qLoss_BreakIn_EFC'][-1]
# Resistance
r_LLI_t = 1 + states['rGain_LLI_t'][-1]
r_LLI_EFC = 1 + states['rGain_LLI_EFC'][-1]
r = 1 + states['rGain_LLI_t'][-1] + states['rGain_LLI_EFC'][-1]
# Assemble output
out = np.array([q, q_LLI_t, q_LLI_EFC, q_BreakIn_EFC, r, r_LLI_t, r_LLI_EFC])
# Store results
for k, v in zip(list(self.outputs.keys()), out):
self.outputs[k] = np.append(self.outputs[k], v)
def sigmoid(x, alpha, beta, gamma):
return 2*alpha*(1/2 - 1/(1 + np.exp((beta*x)**gamma)))
def skewnormpdf(x, skew, width):
x_prime = (x-0.5)/width
return 2 * stats.norm.pdf(x_prime) * stats.norm.cdf(skew * (x_prime))

View File

@ -0,0 +1,159 @@
# Paul Gasper, NREL
# This model is fit to SECOND LIFE data on Nissan Leaf half-modules (2p cells) by Braco et al.
# https://doi.org/10.1109/EEEIC/ICPSEUROPE54979.2022.9854784 (calendar aging data)
# https://doi.org/10.1016/j.est.2020.101695 (cycle aging data)
# Note that these cells are already hugely degraded, starting out at an average relative capacity
# of 70%. So the model reports q and qNew, where qNew is relative to initial
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 widely varied SOC and temperature.
# Cycle aging is only at a single condition (25 Celsius, 100% DOD, 1C-1C).
# MODEL SENSITIVITY
# The model predicts degradation rate versus time as a function of temperature and average
# state-of-charge and degradation rate is only a function equivalent full cycles.
# MODEL LIMITATIONS
# Cycling degradation IS ONLY A FUNCTION OF CHARGE THROUGHPUT due to limited aging data.
# Cycling degradation predictions ARE ONLY VALID NEAR 25 CELSIUS, 100% DOD, 1 C CHARGE/DISCHARGE RATE.
class Lmo_Gr_NissanLeaf66Ah_2ndLife_Battery:
def __init__(self):
# States: Internal states of the battery model
self.states = {
'qLoss_t': np.array([0]),
'qLoss_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]),
'qNew': np.array([0.7]),
}
# 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]),
}
# Rates: History of stressor-dependent degradation rates
self.rates = {
'k_cal': np.array([np.nan]),
}
# Nominal capacity
@property
def _cap_2ndLife(self):
return 46
@property
def _cap(self):
return 66
# Define life model parameters
@property
def _params_life(self):
return {
# Capacity fade parameters
'qcal_A': 3.25e+08,
'qcal_B': -7.58e+03,
'qcal_C': 162,
'qcal_p': 0.464,
'qcyc_A': 7.58e-05,
'qcyc_p': 1.08,
}
# 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)
# Grab parameters
p = self._params_life
# Calculate the degradation coefficients
k_cal = p['qcal_A'] * np.exp(p['qcal_B']/TdegK) * np.exp(p['qcal_C']*soc/TdegK)
# Calculate time based average of each rate
k_cal = np.trapz(k_cal, 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, k_cal, p['qcal_p'])
dq_EFC = update_power_state(states['qLoss_EFC'][-1], delta_efc, p['qcyc_A'], p['qcyc_p'])
# Accumulate and store states
dx = np.array([dq_t, dq_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)])
for k, v in zip(self.stressors.keys(), stressors):
self.stressors[k] = np.append(self.stressors[k], v)
# Store rates
rates = np.array([k_cal])
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
# 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]
qNew = 0.7 * q
# Assemble output
out = np.array([q, q_t, q_EFC, qNew])
# Store results
for k, v in zip(list(self.outputs.keys()), out):
self.outputs[k] = np.append(self.outputs[k], v)

169
nca_gr_Panasonic3Ah_2018.py Normal file
View File

@ -0,0 +1,169 @@
# Paul Gasper, NREL
# This model is fit to Panasonic 18650B NCA-Gr cells.
# Calendar data is reported by Keil et al (https://dx.doi.org/10.1149/2.0411609jes)
# Cycling data is reported by Preger et al (https://doi.org/10.1149/1945-7111/abae37) and
# is available at batteryarchive.com.
# I'm not aware of any study conducting both calendar aging and cycle aging of these cells.
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 widely varied SOC at 25, 40, and 50 Celsius. 300 days max.
# Cycle aging varied temperature and C-rates, and DOD. Some accelerating fade is observed
# at room temperature and high DODs but isn't modeled well here. That's not a huge problem,
# because the modeled lifetime is quite short anyways.
# 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 C-rate, temperature, and depth-of-discharge (DOD dependence is assumed to be linear, no aging data)
# MODEL LIMITATIONS
# Cycle degradation predictions WILL NOT PREDICT KNEE-POINT due to limited data.
# Cycle aging is only modeled at 25, 35, and 45 Celsius, PREDICTIONS OUTSIDE THIS
# TEMPERATURE RANGE MAY BE OPTIMISTIC.
class Nca_Gr_Panasonic3Ah_Battery:
def __init__(self):
# States: Internal states of the battery model
self.states = {
'qLoss_t': np.array([0]),
'qLoss_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]),
}
# 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]),
'dod': np.array([np.nan]),
'Crate': np.array([np.nan]),
}
# Rates: History of stressor-dependent degradation rates
self.rates = {
'k_cal': np.array([np.nan]),
'k_cyc': np.array([np.nan]),
}
# Nominal capacity
@property
def _cap(self):
return 3.2
# Define life model parameters
@property
def _params_life(self):
return {
# Capacity fade parameters
'qcal_A': 75.4,
'qcal_B': -3.34e+03,
'qcal_C': 353,
'qcal_p': 0.512,
'qcyc_A': 1.86e-06,
'qcyc_B': 4.74e-11,
'qcyc_C': 0.000177,
'qcyc_D': 3.34e-11,
'qcyc_E': 2.81e-09,
'qcyc_p': 0.699,
}
# 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)
# Grab parameters
p = self._params_life
# Calculate the degradation coefficients
k_cal = p['qcal_A'] * np.exp(p['qcal_B']/TdegK) * np.exp(p['qcal_C']*soc/TdegK)
k_cyc = (
(p['qcyc_A'] + p['qcyc_B']*Crate + p['qcyc_C']*dod)
* (np.exp(p['qcyc_D']/TdegK) + np.exp(-p['qcyc_E']/TdegK))
)
# Calculate time based average of each rate
k_cal = np.trapz(k_cal, x=t_secs) / delta_t_secs
k_cyc = np.trapz(k_cyc, 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, k_cal, p['qcal_p'])
dq_EFC = update_power_state(states['qLoss_EFC'][-1], delta_efc, k_cyc, p['qcyc_p'])
# Accumulate and store states
dx = np.array([dq_t, dq_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), dod, Crate])
for k, v in zip(self.stressors.keys(), stressors):
self.stressors[k] = np.append(self.stressors[k], v)
# Store rates
rates = np.array([k_cal, k_cyc])
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
# 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]
# Assemble output
out = np.array([q, q_t, q_EFC])
# Store results
for k, v in zip(list(self.outputs.keys()), out):
self.outputs[k] = np.append(self.outputs[k], v)

217
nmc111_gr_Kokam75Ah_2017.py Normal file
View File

@ -0,0 +1,217 @@
# Paul Gasper, NREL
import numpy as np
from functions.extract_stressors import extract_stressors
from functions.state_functions import update_power_state, update_sigmoid_state
# EXPERIMENTAL AGING DATA SUMMARY:
# Aging test matrix varied primarly temperature, with small DOD variation.
# Calendar and cycle aging were performed between 0 and 55 Celsius. C-rates always at 1C,
# except for charging at 0 Celsius, which was conducted at C/3. Depth-of-discharge was 80%
# for nearly all tests (3.4 V - 4.1 V), with one 100% DOD test (3 V - 4.2 V).
# Reported relative capacity was measured at C/5 rate at the aging temperatures. Reported
# relative DC resistance was measured by HPPC using a 10s, 1C DC pulse, averaged between
# charge and discharge, calculated using a simple ohmic fit of the voltage response.
# 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 temperature and depth-of-discharge. Sensitivity to cycling degradation rate
# at low temperature is inferred from physical insight due to limited data.
# MODEL LIMITATIONS
# There is NO C-RATE DEPENDENCE for degradation in this model. THIS IS NOT PHYSICALLY REALISTIC
# AND IS BASED ON LIMITED DATA.
class Nmc111_Gr_Kokam75Ah_Battery:
# Model predicting the degradation of a Kokam 75 Ah NMC-Gr pouch cell.
# https://ieeexplore.ieee.org/iel7/7951530/7962914/07963578.pdf
# It is uncertain if the exact NMC composition is 1-1-1, but it this is definitely not a high nickel (>80%) cell.
# Degradation rate is a function of the aging stressors, i.e., ambient temperature and use.
# The state of the battery is updated throughout the lifetime of the cell.
# Performance metrics are capacity and DC resistance. These metrics change as a function of the
# cell's current degradation state, as well as the ambient temperature. The model predicts time and
# cycling dependent degradation, using Loss of Lithium Inventory (LLI) and Loss of Active
# Material (LAM) degradation modes that interact competitively (cell performance is limited by
# one or the other.)
# Parameters to modify to change fade rates:
# Calendar capacity loss rate: q1_0
# Cycling capacity loss rate (LLI): q3_0
# Cycling capacity loss rate (LAM): q5_0, will also effect resistance growth onset due to LAM.
# Calendar resistance growth rate (LLI), relative to capacity loss rate: r1
# Cycling resistance growth rate (LLI), relative to capacity loss rate: r3
def __init__(self):
# States: Internal states of the battery model
self.states = {
'qLoss_LLI_t': np.array([0]), # relative Li inventory change, time dependent (SEI)
'qLoss_LLI_EFC': np.array([0]), # relative Li inventory change, charge-throughput dependent (SEI)
'qLoss_LAM': np.array([1e-8]), # relative active material change, charge-throughput dependent (electrode damage)
'rGain_LLI_t': np.array([0]), # relative SEI growth, time dependent (SEI)
'rGain_LLI_EFC': np.array([0]), # relative SEI growth, charge-throughput dependent (SEI)
}
# Outputs: Battery properties derived from state values
self.outputs = {
'q': np.array([1]), # relative capacity
'q_LLI': np.array([1]), # relative lithium inventory
'q_LLI_t': np.array([1]), # relative lithium inventory, time dependent loss
'q_LLI_EFC': np.array([1]), # relative lithium inventory, charge-throughput dependent loss
'q_LAM': np.array([1.01]), # relative active material, charge-throughput dependent loss
'r': np.array([1]), # relative resistance
'r_LLI': np.array([1]), # relative SEI resistance
'r_LLI_t': np.array([1]), # relative SEI resistance, time dependent growth
'r_LLI_EFC': np.array([1]), # relative SEI resistance, charge-throughput dependent growth
'r_LAM': np.array([1]), # relative electrode resistance, q_LAM dependent growth
}
# 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]),
'Ua': np.array([np.nan]),
'dod': np.array([np.nan]),
}
# Rates: History of stressor-dependent degradation rates
self.rates = {
'q1': np.array([np.nan]),
'q3': np.array([np.nan]),
'q5': np.array([np.nan]),
}
# Nominal capacity
@property
def _cap(self):
return 75
# Define life model parameters
@property
def _params_life(self):
return {
'q1_0' : 2.66e7, # CHANGE to modify calendar degradation rate (larger = faster degradation)
'q1_1' : -17.8,
'q1_2' : -5.21,
'q2' : 0.357,
'q3_0' : 3.80e3, # CHANGE to modify cycling degradation rate (LLI) (larger = faster degradation)
'q3_1' : -18.4,
'q3_2' : 1.04,
'q4' : 0.778,
'q5_0' : 1e4, # CHANGE to modify cycling degradation rate (LAM) (accelerating fade onset) (larger = faster degradation)
'q5_1' : 153,
'p_LAM' : 10,
'r1' : 0.0570, # CHANGE to modify change of resistance relative to change of capacity (calendar degradation)
'r2' : 1.25,
'r3' : 4.87, # CHANGE to modify change of resistance relative to change of capacity (cycling degradation)
'r4' : 0.712,
'r5' : -0.08,
'r6' : 1.09,
}
# 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)
TdegC = TdegK - 273.15
TdegKN = TdegK / (273.15 + 35) # normalized temperature
UaN = Ua / 0.123 # normalized anode-to-reference potential
# Grab parameters
p = self._params_life
# Calculate degradation rates
q1 = p['q1_0'] * np.exp(p['q1_1'] * (1 / TdegKN)) * np.exp(p['q1_2'] * (UaN / TdegKN))
q3 = p['q3_0'] * np.exp(p['q3_1'] * (1/TdegKN)) * np.exp(p['q3_2'] * np.exp(dod**2))
q5 = p['q5_0'] + p['q5_1'] * (TdegC - 55) * dod
# Calculate time based average of each rate
q1 = np.trapz(q1, x=t_secs) / delta_t_secs
q3 = np.trapz(q3, x=t_secs) / delta_t_secs
q5 = np.trapz(q5, x=t_secs) / delta_t_secs
# Calculate incremental state changes
states = self.states
# Capacity
dq_LLI_t = update_power_state(states['qLoss_LLI_t'][-1], delta_t_days, 2*q1, p['q2'])
dq_LLI_EFC = update_power_state(states['qLoss_LLI_EFC'][-1], delta_efc, q3, p['q4'])
dq_LAM = update_sigmoid_state(states['qLoss_LAM'][-1], delta_efc, 1, 1/q5, p['p_LAM'])
# Resistance
dr_LLI_t = update_power_state(states['rGain_LLI_t'][-1], delta_t_days, p['r1']*q1, p['r2'])
dr_LLI_EFC = update_power_state(states['rGain_LLI_EFC'][-1], delta_efc, p['r3']*q3, p['r4'])
# Accumulate and store states
dx = np.array([dq_LLI_t, dq_LLI_EFC, dq_LAM, dr_LLI_t, dr_LLI_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), np.mean(Ua), dod])
for k, v in zip(self.stressors.keys(), stressors):
self.stressors[k] = np.append(self.stressors[k], v)
# Store rates
rates = np.array([q1, q3, q5])
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_LLI = 1 - states['qLoss_LLI_t'][-1] - states['qLoss_LLI_EFC'][-1]
q_LLI_t = 1 - states['qLoss_LLI_t'][-1]
q_LLI_EFC = 1 - states['qLoss_LLI_EFC'][-1]
q_LAM = 1.01 - states['qLoss_LAM'][-1]
q = np.min(np.array([q_LLI, q_LAM]))
# Resistance
r_LLI = 1 + states['rGain_LLI_t'][-1] + states['rGain_LLI_EFC'][-1]
r_LLI_t = 1 + states['rGain_LLI_t'][-1]
r_LLI_EFC = 1 + states['rGain_LLI_EFC'][-1]
r_LAM = p['r5'] + p['r6'] * (1 / q_LAM)
r = np.max(np.array([r_LLI, r_LAM]))
# Assemble output
out = np.array([q, q_LLI, q_LLI_t, q_LLI_EFC, q_LAM, r, r_LLI, r_LLI_t, r_LLI_EFC, r_LAM])
# Store results
for k, v in zip(list(self.outputs.keys()), out):
self.outputs[k] = np.append(self.outputs[k], v)

225
nmc111_gr_Sanyo2Ah_2014.py Normal file
View File

@ -0,0 +1,225 @@
# 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)

View File

@ -0,0 +1,166 @@
# Paul Gasper, NREL
# This model is fit to LG MJ1 cell aging data reported as part of the EU EVERLASTING battery project, report D2.3
# https://everlasting-project.eu/wp-content/uploads/2020/03/EVERLASTING_D2.3_final_20200228.pdf
# Cell tests were reported in early 2020, so likely 2018 or 2019 LG MJ1 cells.
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 (10%, 70%, 90%) and temperature.
# Cycle aging varied temperature and C-rates; all DOD is 80% (10%-90%). NO ACCELERATED FADE OBSERVED.
# Relative discharge capacity is reported from measurements recorded at 25 Celsius and C/20 rate.
# 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 C-rate, temperature, and depth-of-discharge (DOD dependence is assumed to be linear, no aging data)
# MODEL LIMITATIONS
# Cycle degradation predictions WILL NOT PREDICT KNEE-POINT due to limited data.
# OPERATION AT HIGH DOD PREDCTIONS ARE LIKELY INACCURATE (it is unclear what voltage window corresponds to SOCs defined in the test data).
# NMC811 is known to degrade quickly at voltages above 4.1 V.
class Nmc811_GrSi_LGMJ1_4Ah_Battery:
def __init__(self):
# States: Internal states of the battery model
self.states = {
'qLoss_t': np.array([0]),
'qLoss_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]),
}
# 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]),
'dod': np.array([np.nan]),
'Crate': np.array([np.nan]),
}
# Rates: History of stressor-dependent degradation rates
self.rates = {
'k_cal': np.array([np.nan]),
'k_cyc': np.array([np.nan]),
}
# Nominal capacity
@property
def _cap(self):
return 3.5
# Define life model parameters
@property
def _params_life(self):
return {
# Capacity fade parameters
'qcal_A': 0.0353,
'qcal_B': -1.03e+03,
'qcal_C': 57.7,
'qcal_p': 0.743,
'qcyc_A': 1.77e-07,
'qcyc_B': 8.08e-13,
'qcyc_C': 2.21e-07,
'qcyc_D': 2.25e+03,
'qcyc_E': 1.14e+04,
'qcyc_p': 0.695,
}
# 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)
# Grab parameters
p = self._params_life
# Calculate the degradation coefficients
k_cal = p['qcal_A'] * np.exp(p['qcal_B']/TdegK) * np.exp(p['qcal_C']*soc/TdegK)
k_cyc = (
(p['qcyc_A'] + p['qcyc_B']*Crate + p['qcyc_C']*dod)
* (np.exp(p['qcyc_D']/TdegK) + np.exp(-p['qcyc_E']/TdegK))
)
# Calculate time based average of each rate
k_cal = np.trapz(k_cal, x=t_secs) / delta_t_secs
k_cyc = np.trapz(k_cyc, 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, k_cal, p['qcal_p'])
dq_EFC = update_power_state(states['qLoss_EFC'][-1], delta_efc, k_cyc, p['qcyc_p'])
# Accumulate and store states
dx = np.array([dq_t, dq_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), dod, Crate])
for k, v in zip(self.stressors.keys(), stressors):
self.stressors[k] = np.append(self.stressors[k], v)
# Store rates
rates = np.array([k_cal, k_cyc])
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
# 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]
# Assemble output
out = np.array([q, q_t, q_EFC])
# Store results
for k, v in zip(list(self.outputs.keys()), out):
self.outputs[k] = np.append(self.outputs[k], v)

177
nmc_lto_10Ah_2020.py Normal file
View File

@ -0,0 +1,177 @@
# Paul Gasper, NREL
# This model is fit to data reported by Bank et al from commercial NMC-LTO cells.
# https://doi.org/10.1016/j.jpowsour.2020.228566
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 varies temperature and SOC. There is almost no calendar aging impact
# at all until 80 Celsius.
# Cycle aging varies temperature, C-rate, and depth-of-discharge.
# 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 C-rate, temperature, and depth-of-discharge (DOD dependence is assumed to be linear, no aging data)
# MODEL LIMITATIONS
# Calendar aging has competition between capacity gain and capacity loss. There is an experimental
# case (80 Celsius, 5% SOC) that has complex behavior not modeled here.
# Astonishingly enough, the cycling degradation model is actually _overestimating_ capacity fade for most cases.
# The exception here is at very high temperature (60+ Celsius), where the fade is high, but not quite as high as observed degradation.
class Nmc_Lto_10Ah_Battery:
def __init__(self):
# States: Internal states of the battery model
self.states = {
'qLoss_t': np.array([0]),
'qGain_t': np.array([0]),
'qLoss_EFC': np.array([0]),
}
# Outputs: Battery properties derived from state values
self.outputs = {
'q': np.array([1]),
'q_t_loss': np.array([1]),
'q_t_gain': np.array([1]),
'q_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]),
'dod': np.array([np.nan]),
'Crate': np.array([np.nan]),
}
# Rates: History of stressor-dependent degradation rates
self.rates = {
'alpha': np.array([np.nan]),
'beta': np.array([np.nan]),
'gamma': np.array([np.nan]),
}
# Nominal capacity
@property
def _cap(self):
return 10.2
# Define life model parameters
@property
def _params_life(self):
return {
# Capacity fade parameters
'alpha_0': 3.11e+11,
'alpha_1': -34.8,
'alpha_2': 1.07,
'alpha_p': 0.473,
'beta_0': 7.86e+10,
'beta_1': -35.8,
'beta_2': 3.94,
'beta_p': -0.553,
'gamma_0': 1.29,
'gamma_1': 7.83e-05,
'gamma_2': 4.02,
'gamma_3': -8.33,
'gamma_p': 0.526,
}
# 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)
TdegKN = TdegK / (273.15 + 45)
# Grab parameters
p = self._params_life
# Calculate the degradation coefficients
alpha = p['alpha_0'] * np.exp(p['alpha_1']/TdegKN) * np.exp(p['alpha_2']*soc/TdegKN)
beta = p['beta_0'] * np.exp(p['beta_1']/TdegKN) * np.exp(p['beta_2']*soc/TdegKN)
gamma = (
(p['gamma_0'] + p['gamma_1']*Crate + p['gamma_2']*(dod**3))
* np.exp(p['gamma_3']/TdegKN)
)
# Calculate time based average of each rate
alpha = np.trapz(alpha, x=t_secs) / delta_t_secs
beta = np.trapz(beta, x=t_secs) / delta_t_secs
gamma = np.trapz(gamma, x=t_secs) / delta_t_secs
# Calculate incremental state changes
states = self.states
# Capacity
dq_t_gain = update_power_state(states['qGain_t'][-1], delta_t_days, alpha, p['alpha_p'])
dq_t_loss = update_power_state(states['qLoss_t'][-1], delta_t_days, beta, p['beta_p'])
dq_EFC = update_power_state(states['qLoss_EFC'][-1], delta_efc, gamma, p['gamma_p'])
# Accumulate and store states
dx = np.array([dq_t_loss, dq_t_gain, dq_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), dod, Crate])
for k, v in zip(self.stressors.keys(), stressors):
self.stressors[k] = np.append(self.stressors[k], v)
# Store rates
rates = np.array([alpha, beta, gamma])
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
# Capacity
q_t_loss = 1 - states['qLoss_t'][-1]
q_t_gain = 1 + states['qGain_t'][-1]
q_EFC = 1 - states['qLoss_EFC'][-1]
q = 1 - states['qLoss_t'][-1] + states['qGain_t'][-1] - states['qLoss_EFC'][-1]
# Assemble output
out = np.array([q, q_t_loss, q_t_gain, q_EFC])
# Store results
for k, v in zip(list(self.outputs.keys()), out):
self.outputs[k] = np.append(self.outputs[k], v)