BatterySimulatorBLAST/lmo_gr_NissanLeaf66Ah_2ndLi...

159 lines
6.3 KiB
Python

# 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)