Initial commit from internal repo.
This commit is contained in:
commit
de2d5b2091
|
@ -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.
|
|
@ -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
|
Binary file not shown.
File diff suppressed because one or more lines are too long
|
@ -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
|
|
@ -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())
|
|
@ -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
|
|
@ -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))
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
|
@ -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)
|
Loading…
Reference in New Issue