Source code for cideMOD.models.PXD.electrochemical.inputs
#
# Copyright (c) 2023 CIDETEC Energy Storage.
#
# This file is part of cideMOD.
#
# cideMOD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import numpy as np
import dolfinx as dfx
from petsc4py.PETSc import ScalarType
from pydantic import BaseModel, validator
from cideMOD.helpers.logging import VerbosityLevel, _print
from cideMOD.cell._factory import get_cell_component_label
from cideMOD.cell.parser import CellParser, BaseComponentParser
from cideMOD.models import model_factory, register_model_options, register_trigger, __mtypes__
from cideMOD.models.model_options import BaseModelOptions
from cideMOD.models.PXD.base_model import BasePXDModelInputs
from cideMOD.models.PXD.electrochemical import __model_name__
register_trigger(name='voltage', label='v', units='V', atol=1e-4)
register_trigger(name='current', label='i', units='A', need_abs=True, atol=1e-6)
[docs]
@register_model_options(__model_name__)
class ElectrochemicalModelOptions(BaseModel):
"""
Electrochemical Model
---------------------
particle_coupling: str
Coupling between cell and particle problem. Available options:
`implicit`, `explicit`. Default to `implicit`.
particle_model: int
Particle model to be used. Available models:
- `SGM`: Spectral Galerkin Model. Default option.
"""
model: str = 'P2D'
particle_coupling: str = 'implicit'
particle_model: str = 'SGM'
time_scheme: str = 'euler_implicit' # TODO: Make this useful
[docs]
@validator('particle_coupling')
def validate_particle_coupling(cls, v):
if v not in ('implicit', 'explicit'):
raise ValueError("'particle_coupling' must be 'implicit' or 'explicit'")
elif v == 'explicit':
raise NotImplementedError(f"The explicit particle model is not available yet")
return v
[docs]
@validator('model')
def validate_model(cls, v):
pxd_mtypes = __mtypes__['PXD']
if v not in pxd_mtypes:
raise ValueError("'model' keyword must be one of: '" + "' '".join(pxd_mtypes) + "'")
return v
[docs]
@validator('particle_model')
def validate_particle_model(cls, v):
particle_models = [name for name in model_factory() if name.startswith('PM_')]
if f"PM_{v}" not in particle_models:
raise ValueError(f"Unrecognized particle model '{v}'. Available particle models: '"
+ "' '".join(particle_models) + "'")
return v
[docs]
class ElectrochemicalModelInputs(BasePXDModelInputs):
# ******************************************************************************************* #
# *** ModelOptions *** #
# ******************************************************************************************* #
[docs]
@classmethod
def is_active_model(cls, model_options: BaseModelOptions) -> bool:
"""
This method checks the model options configured by the user to
evaluate if this model should be added to the cell model.
Parameters
----------
model_options: BaseModelOptions
Model options already configured by the user.
Returns
-------
bool
Whether or not this model should be added to the cell model.
"""
# TODO: Ensure that model_options has been extended with ElectrochemicalModelOptions
return True
# ******************************************************************************************* #
# *** Problem *** #
# ******************************************************************************************* #
[docs]
def set_cell_state(self, problem, SoC=None, T_ext=None, T_ini=None) -> None:
"""
This method set the current state of the cell.
Parameters
----------
problem: Problem
Object that handles the battery cell simulation.
SoC: float, optional
Current State of Charge of the battery cell. Default initial
value to 1.
T_ext: float, optional
External temperature. Default initial value to 298,15 K.
T_ini: float, optional
Uniform value of the internal temperature. Default initial
value to `T_ext`.
"""
if not problem._ready:
# The user is setting the initial cell state
problem.SoC_ini = SoC if SoC is not None else 1
T_ext = T_ext if T_ext is not None else 298.15
if self._T_ext is None:
self._T_ext = dfx.fem.Constant(problem.mesher.mesh, ScalarType(T_ext))
problem.T_ext = self._T_ext
else:
problem.T_ext.value = T_ext
T_ini = T_ini if T_ini is not None else T_ext
if self._T_ini is None:
self._T_ini = dfx.fem.Constant(problem.mesher.mesh, ScalarType(T_ini))
problem.T_ini = self._T_ini
else:
problem.T_ini.value = T_ini
else:
# TODO: Think about creating a method called set_new_cell_state
# Setting a new cell state
if SoC is not None and not np.isclose(SoC, problem.SoC_ini):
raise NotImplementedError(
"SoC is used by the initial guess. Set c_s directly to set a new cell state")
if T_ext is not None:
problem.T_ext.value = T_ext
if T_ini is not None:
problem.T_ini.value = T_ini
# ******************************************************************************************* #
# *** CellParser *** #
# ******************************************************************************************* #
[docs]
def build_cell_components(self, cell: CellParser) -> None:
"""
This method builds the components of the cell that fit our model
type, e.g. electrodes, separator, current collectors, etc.
Parameters
----------
cell: CellParser
Parser of the cell dictionary.
Examples
--------
>>> cell.set_component('anode')
It is also possible to create the class dinamically:
>>> cell.set_component('anode')
"""
# NOTE: Notice that ordering matters, in this case some components will need some
# parameters from the electrolyte, that's why it is built first.
cell.electrolyte = cell.set_component('electrolyte')
cell.anode = cell.set_component('anode')
cell.cathode = cell.set_component('cathode')
cell.separator = cell.set_component('separator')
# Check if there are collectors
ncc_tag = get_cell_component_label('negativeCC')
pcc_tag = get_cell_component_label('positiveCC')
cell.has_collectors = ncc_tag in cell.structure or pcc_tag in cell.structure
cell.negativeCC = cell.set_component('negativeCC') if cell.has_collectors else None
cell.positiveCC = cell.set_component('positiveCC') if cell.has_collectors else None
[docs]
def parse_cell_structure(self, cell: CellParser):
"""
This method parse the cell structure. If there are any component
this model does not know, then this method should return the
list of unrecognized components. Maybe this components has been
defined by other models, so this task should be delegated to
these model.
Parameters
----------
cell: CellParser
Parser of the cell dictionary.
Returns
-------
Union[bool, list]
Whether or not the cell structure is valid. If there are any
component this model does not know, then this method should
return the list of unrecognized components.
"""
# Get recognized component labels
if cell.has_collectors:
recognized_components = [cell.negativeCC, cell.anode, cell.separator,
cell.cathode, cell.positiveCC, cell.electrolyte]
else:
recognized_components = [cell.anode, cell.separator, cell.cathode, cell.electrolyte]
recognized_labels = [component._label_ for component in recognized_components[:-1]]
unrecognized_labels = set(cell.structure).difference(recognized_labels)
# If there are unrecognized labels, return them
if unrecognized_labels:
return list(unrecognized_labels)
# Minimum cell structure length
if len(cell.structure) <= 2:
raise ValueError("The cell structure should be composed at least by "
+ "an anode, a separator and a cathode")
# Check the mandatory cell structure components
mandatory_components = [cell.anode, cell.separator, cell.cathode]
for component in mandatory_components:
if component._label_ not in cell.structure:
raise ValueError(f"Component '{component._name_}' with label '{component._label}' "
+ "is missing in the cell structure")
# Check cell structure
def _check_component(idx, component, valid_components=[], different=True, extreme=False):
valids = [valid_component._label_ for valid_component in valid_components]
# Check previous element
prev = cell.structure[idx - 1] if idx > 0 else None
if prev is not None and prev not in valids:
prev_name = valid_components[valids.index(prev)]._name_
raise ValueError(f"Component '{prev_name}' cannot be next to '{component._name_}'")
# Check next element
next_ = cell.structure[idx + 1] if idx + 1 < len(cell.structure) else None
if next_ is not None and next_ not in valids:
next_name = valid_components[valids.index(next_)]._name_
raise ValueError(f"Component '{next_name}' cannot be next to '{component._name_}'")
# Final checks
if not extreme and idx in [0, len(cell.structure) - 1]:
raise ValueError(f"Component '{component._name_}' cannot be "
+ "at the extreme of the cell structure")
elif different and None not in [prev, next_] and prev == next_:
raise ValueError(f"Bad element in structure: {(prev, component._label_, next_)}")
if cell.has_collectors:
for idx, label in enumerate(cell.structure):
component = recognized_components[recognized_labels.index(label)]
if label == cell.negativeCC._label_:
_check_component(idx, component, [cell.anode], different=False, extreme=True)
elif label == cell.anode._label_:
_check_component(idx, component, [cell.negativeCC, cell.separator])
elif label == cell.separator._label_:
_check_component(idx, component, [cell.anode, cell.cathode])
elif label == cell.cathode._label_:
_check_component(idx, component, [cell.positiveCC, cell.separator])
elif label == cell.positiveCC._label_:
_check_component(idx, component, [cell.cathode], different=False, extreme=True)
else:
for idx, label in enumerate(cell.structure):
component = recognized_components[recognized_labels.index(label)]
if label == cell.anode._label_:
_check_component(idx, component, [cell.separator], extreme=True)
elif label == cell.separator._label_:
_check_component(idx, component, [cell.anode, cell.cathode])
elif label == cell.cathode._label_:
_check_component(idx, component, [cell.separator], extreme=True)
return True
def _parse_component_parameters(self, component: BaseComponentParser, default_area=None):
component.set_parameters(__cell_parameters__['component'])
if not component.area.was_provided:
if component.width.was_provided and component.height.was_provided:
component.area.set_value(component.width.user_value * component.height.user_value)
elif default_area is not None:
component.area.set_value(default_area)
else:
raise KeyError(component.area._get_error_msg(
reason="Parameter not found", action='initialization'))
elif component.width.was_provided and component.height.was_provided:
if not np.isclose(component.width.user_value * component.height.user_value,
component.area.user_value, rtol=1e-3):
raise ValueError(
f"Error parsing '{component.complete_tag}'. The given height*width != area")
def _parse_porous_component_parameters(self, component: BaseComponentParser):
self._parse_component_parameters(component)
component.set_parameters(__cell_parameters__['porous_component'])
if not component.bruggeman.was_provided and not component.tortuosity_e.was_provided:
raise KeyError(f"'{component.tag}' bruggeman or tortuosity must be provided")
# Set the porous parameters info within the electrolyte component
electrolyte = component.cell.electrolyte
component.D_e.set_value(electrolyte.diffusion_constant.dic)
component.kappa.set_value(electrolyte.ionic_conductivity.dic)
[docs]
def parse_cell_parameters(self, cell: CellParser) -> None:
"""
This methods parses the cell parameters of the electrochemical
model.
Parameters
----------
cell: CellParser
Parser of the cell dictionary.
"""
cell.set_parameters(__cell_parameters__['cell'])
[docs]
def parse_electrode_parameters(self, electrode: BaseComponentParser) -> None:
"""
This method parses the electrode parameters of the
electrochemical model.
Parameters
----------
electrode: BaseComponentParser
Object that parses the electrode parameters.
"""
self._parse_porous_component_parameters(electrode)
electrode.set_parameters(__cell_parameters__['electrode'])
if electrode.type.user_value != 'porous':
raise NotImplementedError(f"Only 'porous' electrodes available in this version")
[docs]
def parse_active_material_parameters(self, am: BaseComponentParser) -> None:
"""
This method parses the active material parameters of the
electrochemical model.
Parameters
----------
am: BaseComponentParser
Object that parses the active material parameters.
"""
am.material = am.set_parameter(
'material', is_optional=True, default=am.tag, dtypes='label')
am.set_parameters(__cell_parameters__['active_material'])
# Setup parameters that are not provided by the user
am.porosity.set_value(1)
# Additional checks
if not am.volume_fraction.was_provided:
required = [am.density, am.electrode.density, am.mass_fraction]
if not all([p.was_provided for p in required]):
raise ValueError(am.volume_fraction._get_error_msg(
reason=("Unable to compute it and it has not been provided. Required "
+ "parameters: '" + "' '".join([str(p) for p in required]) + "'"),
action='initialization'
))
elif not am.electrode.density.is_effective:
raise ValueError(am.volume_fraction._get_error_msg(
reason=("Unable to compute it from the mass fraction if "
+ f"{str(am.electrode.density)} is not effective"),
action='initialization'
))
else:
mass_fraction = am.mass_fraction.user_value
rho_el = am.electrode.density.user_value
rho_am = am.density.user_value
am.volume_fraction.set_value(mass_fraction * rho_el / rho_am)
if am.entropy_coefficient.was_provided:
am.ocp.make_reference_temperature_mandatory()
# Add the contribution of this inclusion (if so) to the porosity of the active material
if 'inc' in am.electrode.tag:
eps_am = am.electrode.porosity
if eps_am.is_dynamic_parameter:
raise NotImplementedError(eps_am._get_error_msg(
reason=f"It can't be defined as dynamic parameter yet",
action='initialization'))
eps_am.set_value(eps_am.user_value - am.volume_fraction.user_value)
if not 0 <= eps_am.user_value <= 1:
raise RuntimeError(f"Parameter '{eps_am}' has a value of {eps_am.user_value}")
[docs]
def parse_separator_parameters(self, separator: BaseComponentParser) -> None:
"""
This method parses the separator parameters of the
electrochemical model.
Parameters
----------
separator: BaseComponentParser
Object that parses the separator parameters.
"""
self._parse_porous_component_parameters(separator)
separator.set_parameters(__cell_parameters__['separator'])
if separator.type.user_value != 'porous':
raise NotImplementedError(f"Only 'porous' separator available in this version")
[docs]
def parse_current_collector_parameters(self, cc: BaseComponentParser) -> None:
"""
This method parses the current collector parameters of the
electrochemical model.
Parameters
----------
cc: BaseComponentParser
Object that parses the current collector parameters.
"""
self._parse_component_parameters(cc)
cc.set_parameters(__cell_parameters__['current_collector'])
if cc.type.user_value != 'solid':
raise NotImplementedError(f"Current collectors must be 'solid'.")
[docs]
def parse_electrolyte_parameters(self, electrolyte: BaseComponentParser) -> None:
"""
This method parses the electrolyte parameters of the
electrochemical model.
Parameters
----------
electrolyte: BaseComponentParser
Object that parses the electrolyte parameters.
"""
electrolyte.set_parameters(__cell_parameters__['electrolyte'])
if electrolyte.type.get_value() != 'liquid':
raise ValueError("Solid electrolytes not supported in this version")
if electrolyte.intercalation_type.get_value() != 'binary':
raise ValueError("Only binary electrolytes are supported in this version")
# Perform some hacking actions
# NOTE: The electrolyte itself is not porous, but we will use the same dictionary to
# build the corresponding porous component parameters.
electrolyte.diffusion_constant.is_effective = True
electrolyte.ionic_conductivity.is_effective = True
[docs]
def compute_reference_cell_properties(self, cell: CellParser):
"""
This method computes the general reference cell properties of
the electrochemical model.
Parameters
----------
cell: CellParser
Parser of the cell dictionary.
Notes
-----
This method is called once the cell parameters has been parsed.
"""
cell.anode.ref_capacity = self._get_reference_electrode_capacity(cell.anode)
cell.cathode.ref_capacity = self._get_reference_electrode_capacity(cell.cathode)
cell.ref_capacity = min(cell.anode.ref_capacity or 9e99, cell.cathode.ref_capacity or 9e99)
if cell.verbose >= VerbosityLevel.BASIC_PROBLEM_INFO:
_print(f"Negative electrode capacity: {cell.anode.ref_capacity:.6f}", comm=cell._comm)
_print(f"Positive electrode capacity: {cell.cathode.ref_capacity :.6f}",
comm=cell._comm)
_print(f"Cell capacity: {cell.ref_capacity:.6f}", comm=cell._comm)
cell.ref_area = min(cell.anode.area.get_reference_value() or 9e99,
cell.cathode.area.get_reference_value() or 9e99)
components = [v for k, v in cell._components_.items() if k != 'electrolyte']
if any([element.height.was_provided for element in components]):
cell.ref_height = max([element.height.get_reference_value()
for element in components
if element.height.get_reference_value()])
else:
cell.ref_height = None
if any([element.width.was_provided for element in components]):
cell.ref_width = max([element.width.get_reference_value()
for element in components
if element.width.get_reference_value()])
else:
cell.ref_width = None
def _get_reference_electrode_capacity(self, electrode: BaseComponentParser):
area = electrode.area.get_reference_value()
L = electrode.thickness.get_reference_value()
F = electrode.cell.F.get_reference_value()
cap = 0
for am in electrode.active_materials:
am_eps_s = am.volume_fraction.get_reference_value()
am_porosity = am.porosity.get_reference_value()
am_c_s_max = am.maximum_concentration.get_reference_value()
am_stoichiometry0 = am.stoichiometry0.get_reference_value()
am_stoichiometry1 = am.stoichiometry1.get_reference_value()
cap += (am_eps_s * am_porosity * am_c_s_max
* abs(am_stoichiometry1 - am_stoichiometry0) / 3600)
for inc in am.inclusions:
inc_eps_s = inc.volume_fraction.get_reference_value()
inc_porosity = inc.porosity.get_reference_value()
inc_c_s_max = inc.maximum_concentration.get_reference_value()
inc_stoichiometry0 = inc.stoichiometry0.get_reference_value()
inc_stoichiometry1 = inc.stoichiometry1.get_reference_value()
cap += (am_eps_s * inc_eps_s * inc_c_s_max * inc_porosity
* abs(inc_stoichiometry1 - inc_stoichiometry0) / 3600)
return cap * area * L * F
__cell_parameters__ = {
'cell': {
'R': {'element': 'constants', 'default': 8.314472, 'is_optional': True},
'F': {'element': 'constants', 'default': 96485.3365, 'is_optional': True},
'doubleLayerCapacitance_cc': {'element': 'properties', 'is_optional': True}
},
'component': {
'thickness': {},
'area': {'is_optional': True},
'width': {'is_optional': True},
'height': {'is_optional': True},
'density': {'is_optional': True, 'aliases': 'rho'}
},
'porous_component': {
'porosity': {'aliases': 'eps_e'},
'bruggeman': {'is_optional': True, 'aliases': 'brug'},
'tortuosity_e': {'is_optional': True, 'aliases': 'tortuosity'},
'tortuosity_s': {'is_optional': True},
'D_e': {'can_effective': True, 'can_arrhenius': True,
'aliases': 'diffusionConstantElectrolyte', 'dtypes': ('real', 'expression'),
'is_user_input': False},
'kappa': {'can_effective': True, 'can_arrhenius': True,
'aliases': 'ionic_conductivity', 'dtypes': ('real', 'expression'),
'is_user_input': False},
'kappa_D': {'can_vary': False, 'aliases': 'ionicConductivityDiffusion',
'is_user_input': False},
},
'electrode': {
'type': {'is_optional': True, 'default': 'porous'},
'electronic_conductivity': {'can_effective': True,
'aliases': ['electronicConductivity']},
'double_layer_capacitance': {'is_optional': True,
'aliases': ['doubleLayerCapacitance', 'dl_capacitance']}
},
'active_material': {
# 'material': {'is_optional': True, 'default': ['!self.tag'], 'dtypes': 'label'},
'volume_fraction': {'is_optional': True, 'aliases': ['volFrac_active', 'volumeFraction']},
'density': {'is_optional': True},
'mass_fraction': {'is_optional': True, 'aliases': ['massFraction']},
'particle_radius': {'aliases': ['particleRadius', 'Rp']},
'maximum_concentration': {'aliases': ['maximumConcentration', 'c_s_max']},
'stoichiometry0': {'aliases': ['stoi0']},
'stoichiometry1': {'aliases': ['stoi1']},
'kinetic_constant': {'can_arrhenius': True,
'dtypes': ('real', 'expression'),
'aliases': ['kineticConstant']},
'alpha': {'default': 0.5, 'is_optional': True, 'aliases': ['charge_transfer_coefficient']},
'ocp': {'dtypes': ('expression', 'spline'),
'aliases': ['OCP', 'openCircuitPotential', 'open_circuit_potential'],
'can_ref_temperature': True, 'can_hysteresis': True},
'entropy_coefficient': {'dtypes': ('expression', 'spline'),
'aliases': ['entropyCoefficient'],
'is_optional': True, 'can_hysteresis': True},
'a_s': {'is_user_input': False},
'tortuosity_s': {'is_optional': True},
'porosity': {'is_user_input': False}
},
'separator': {
'type': {'is_optional': True, 'default': 'porous'}
},
'current_collector': {
'type': {'is_optional': True, 'default': 'solid'},
'electronic_conductivity': {'aliases': ['electronicConductivity']}
},
'electrolyte': {
'type': {'default': 'liquid', 'is_optional': True, 'dtypes': 'label'},
'intercalation_type': {'default': 'binary', 'is_optional': True, 'dtypes': 'label'},
'transference_number': {'aliases': ['transferenceNumber', 't_p']},
'activity_dependence': {'dtypes': ('real', 'expression'),
'default': 1, 'is_optional': True,
'aliases': ['activityDependence', '(1+d(lnf)/d(lnc))']},
'initial_concentration': {'aliases': ['initialConcentration', 'c_0']},
'diffusion_constant': {'dtypes': ('real', 'expression'),
'can_arrhenius': True, 'can_effective': True,
'aliases': ['D_e', 'diffusionConstant']},
'ionic_conductivity': {'dtypes': ('real', 'expression'),
'can_arrhenius': True, 'can_effective': True,
'aliases': ['kappa', 'ionicConductivity']}
}
}