diff --git a/CHANGELOG.md b/CHANGELOG.md index da87a88d6f..d1f2f67b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Features - +- Added Modulo, Floor and Ceiling operators ([#1121](https://github.com/pybamm-team/PyBaMM/pull/1121)) +- Added DFN model for a half cell ([#1121](https://github.com/pybamm-team/PyBaMM/pull/1121)) - Automatically compute surface area per unit volume based on particle shape for li-ion models ([#1120])(https://github.com/pybamm-team/PyBaMM/pull/1120) - Added "R-averaged particle concentration" variables ([#1118](https://github.com/pybamm-team/PyBaMM/pull/1118)) - Added support for sensitivity calculations to the casadi solver ([#1109](https://github.com/pybamm-team/PyBaMM/pull/1109)) diff --git a/examples/scripts/DFN_half_cell.py b/examples/scripts/DFN_half_cell.py new file mode 100644 index 0000000000..a27c4af046 --- /dev/null +++ b/examples/scripts/DFN_half_cell.py @@ -0,0 +1,65 @@ +# +# Example showing how to load and solve the DFN for the half cell +# + +import pybamm +import numpy as np + +pybamm.set_logging_level("INFO") + +# load model +options = {"working electrode": "positive"} +model = pybamm.lithium_ion.BasicDFNHalfCell(options=options) + +# create geometry +geometry = model.default_geometry + +# load parameter values +chemistry = pybamm.parameter_sets.Chen2020 +param = pybamm.ParameterValues(chemistry=chemistry) + +# add lithium counter electrode parameter values +param.update( + { + "Lithium counter electrode exchange-current density [A.m-2]": 12.6, + "Lithium counter electrode conductivity [S.m-1]": 1.0776e7, + "Lithium counter electrode thickness [m]": 250e-6, + }, + check_already_exists=False, +) + +# process model and geometry +param.process_model(model) +param.process_geometry(geometry) + +# set mesh +var = pybamm.standard_spatial_vars +var_pts = {var.x_n: 30, var.x_s: 30, var.x_p: 30, var.r_n: 10, var.r_p: 10} +mesh = pybamm.Mesh(geometry, model.default_submesh_types, var_pts) + +# discretise model +disc = pybamm.Discretisation(mesh, model.default_spatial_methods) +disc.process_model(model) + +# solve model +t_eval = np.linspace(0, 3800, 1000) +solver = pybamm.CasadiSolver(mode="fast", atol=1e-6, rtol=1e-3) +solution = solver.solve(model, t_eval) + +# plot +plot = pybamm.QuickPlot( + solution, + [ + "Negative particle surface concentration [mol.m-3]", + "Electrolyte concentration [mol.m-3]", + "Positive particle surface concentration [mol.m-3]", + "Current [A]", + "Negative electrode potential [V]", + "Electrolyte potential [V]", + "Positive electrode potential [V]", + "Terminal voltage [V]", + ], + time_unit="seconds", + spatial_unit="um", +) +plot.dynamic_plot() diff --git a/pybamm/expression_tree/binary_operators.py b/pybamm/expression_tree/binary_operators.py index f76e4ac7cf..9d5595b8df 100644 --- a/pybamm/expression_tree/binary_operators.py +++ b/pybamm/expression_tree/binary_operators.py @@ -730,6 +730,46 @@ def _binary_evaluate(self, left, right): return left < right +class Modulo(BinaryOperator): + "Calculates the remainder of an integer division" + + def __init__(self, left, right): + super().__init__("%", left, right) + + def _diff(self, variable): + """ See :meth:`pybamm.Symbol._diff()`. """ + # apply chain rule and power rule + left, right = self.orphans + # derivative if variable is in the base + diff = left.diff(variable) + # derivative if variable is in the right term (rare, check separately to avoid + # unecessarily big tree) + if any(variable.id == x.id for x in right.pre_order()): + diff += - pybamm.Floor(left / right) * right.diff(variable) + return diff + + def _binary_jac(self, left_jac, right_jac): + """ See :meth:`pybamm.BinaryOperator._binary_jac()`. """ + # apply chain rule and power rule + left, right = self.orphans + if left.evaluates_to_number() and right.evaluates_to_number(): + return pybamm.Scalar(0) + elif right.evaluates_to_number(): + return left_jac + elif left.evaluates_to_number(): + return - right_jac * pybamm.Floor(left / right) + else: + return left_jac - right_jac * pybamm.Floor(left / right) + + def __str__(self): + """ See :meth:`pybamm.Symbol.__str__()`. """ + return "{!s} mod {!s}".format(self.left, self.right) + + def _binary_evaluate(self, left, right): + """ See :meth:`pybamm.BinaryOperator._binary_evaluate()`. """ + return left % right + + class Minimum(BinaryOperator): " Returns the smaller of two objects " diff --git a/pybamm/expression_tree/operations/convert_to_casadi.py b/pybamm/expression_tree/operations/convert_to_casadi.py index 7ba7bc01b1..e47031e4fe 100644 --- a/pybamm/expression_tree/operations/convert_to_casadi.py +++ b/pybamm/expression_tree/operations/convert_to_casadi.py @@ -76,6 +76,8 @@ def _convert(self, symbol, t, y, y_dot, inputs): converted_left = self.convert(left, t, y, y_dot, inputs) converted_right = self.convert(right, t, y, y_dot, inputs) + if isinstance(symbol, pybamm.Modulo): + return casadi.fmod(converted_left, converted_right) if isinstance(symbol, pybamm.Minimum): return casadi.fmin(converted_left, converted_right) if isinstance(symbol, pybamm.Maximum): @@ -88,6 +90,10 @@ def _convert(self, symbol, t, y, y_dot, inputs): converted_child = self.convert(symbol.child, t, y, y_dot, inputs) if isinstance(symbol, pybamm.AbsoluteValue): return casadi.fabs(converted_child) + if isinstance(symbol, pybamm.Floor): + return casadi.floor(converted_child) + if isinstance(symbol, pybamm.Ceiling): + return casadi.ceil(converted_child) return symbol._unary_evaluate(converted_child) elif isinstance(symbol, pybamm.Function): diff --git a/pybamm/expression_tree/symbol.py b/pybamm/expression_tree/symbol.py index 2e03d622a9..e0db67aa98 100644 --- a/pybamm/expression_tree/symbol.py +++ b/pybamm/expression_tree/symbol.py @@ -467,6 +467,12 @@ def __abs__(self): pybamm.AbsoluteValue(self), keep_domains=True ) + def __mod__(self, other): + """return an :class:`Modulo` object""" + return pybamm.simplify_if_constant( + pybamm.Modulo(self, other), keep_domains=True + ) + def diff(self, variable): """ Differentiate a symbol with respect to a variable. For any symbol that can be diff --git a/pybamm/expression_tree/unary_operators.py b/pybamm/expression_tree/unary_operators.py index 85f0793cd5..036bcf2e78 100644 --- a/pybamm/expression_tree/unary_operators.py +++ b/pybamm/expression_tree/unary_operators.py @@ -166,6 +166,52 @@ def _unary_evaluate(self, child): return np.sign(child) +class Floor(UnaryOperator): + """A node in the expression tree representing an `floor` operator + + **Extends:** :class:`UnaryOperator` + """ + + def __init__(self, child): + """ See :meth:`pybamm.UnaryOperator.__init__()`. """ + super().__init__("floor", child) + + def diff(self, variable): + """ See :meth:`pybamm.Symbol.diff()`. """ + return pybamm.Scalar(0) + + def _unary_jac(self, child_jac): + """ See :meth:`pybamm.UnaryOperator._unary_jac()`. """ + return pybamm.Scalar(0) + + def _unary_evaluate(self, child): + """ See :meth:`UnaryOperator._unary_evaluate()`. """ + return np.floor(child) + + +class Ceiling(UnaryOperator): + """A node in the expression tree representing a `ceil` operator + + **Extends:** :class:`UnaryOperator` + """ + + def __init__(self, child): + """ See :meth:`pybamm.UnaryOperator.__init__()`. """ + super().__init__("ceil", child) + + def diff(self, variable): + """ See :meth:`pybamm.Symbol.diff()`. """ + return pybamm.Scalar(0) + + def _unary_jac(self, child_jac): + """ See :meth:`pybamm.UnaryOperator._unary_jac()`. """ + return pybamm.Scalar(0) + + def _unary_evaluate(self, child): + """ See :meth:`UnaryOperator._unary_evaluate()`. """ + return np.ceil(child) + + class Index(UnaryOperator): """A node in the expression tree, which stores the index that should be extracted from its child after the child has been evaluated. diff --git a/pybamm/models/full_battery_models/base_battery_model.py b/pybamm/models/full_battery_models/base_battery_model.py index baa702a8cf..e1103481fe 100644 --- a/pybamm/models/full_battery_models/base_battery_model.py +++ b/pybamm/models/full_battery_models/base_battery_model.py @@ -202,6 +202,7 @@ def options(self, extra_options): "external submodels": [], "sei": None, "sei porosity change": False, + "working electrode": None } # Change the default for cell geometry based on which thermal option is provided extra_options = extra_options or {} diff --git a/pybamm/models/full_battery_models/lithium_ion/__init__.py b/pybamm/models/full_battery_models/lithium_ion/__init__.py index 167a38f3b5..52e9e3dc76 100644 --- a/pybamm/models/full_battery_models/lithium_ion/__init__.py +++ b/pybamm/models/full_battery_models/lithium_ion/__init__.py @@ -7,3 +7,4 @@ from .dfn import DFN from .basic_dfn import BasicDFN from .basic_spm import BasicSPM +from .basic_dfn_half_cell import BasicDFNHalfCell diff --git a/pybamm/models/full_battery_models/lithium_ion/basic_dfn_half_cell.py b/pybamm/models/full_battery_models/lithium_ion/basic_dfn_half_cell.py new file mode 100644 index 0000000000..a427f533e2 --- /dev/null +++ b/pybamm/models/full_battery_models/lithium_ion/basic_dfn_half_cell.py @@ -0,0 +1,475 @@ +# +# Basic Doyle-Fuller-Newman (DFN) Half Cell Model +# +import pybamm +from .base_lithium_ion_model import BaseModel + + +class BasicDFNHalfCell(BaseModel): + """Doyle-Fuller-Newman (DFN) model of a lithium-ion battery with lithium counter + electrode, adapted from [2]_. + + This class differs from the :class:`pybamm.lithium_ion.BasicDFN` model class in + that it is for a cell with a lithium counter electrode (half cell). This is a + feature under development (for example, it cannot be used with the Simulation class + for the moment) and in the future it will be incorporated as a standard model with + the full functionality. + + Parameters + ---------- + name : str, optional + The name of the model. + options : dict + A dictionary of options to be passed to the model. For the half cell it should + include which is the working electrode. + + References + ---------- + .. [2] M Doyle, TF Fuller and JS Nwman. “Modeling of Galvanostatic Charge and + Discharge of the Lithium/Polymer/Insertion Cell”. Journal of The + Electrochemical Society, 140(6):1526-1533, 1993 + + **Extends:** :class:`pybamm.lithium_ion.BaseModel` + """ + + def __init__( + self, + name="Doyle-Fuller-Newman half cell model", + options=None, + ): + super().__init__({}, name) + pybamm.citations.register("marquis2019asymptotic") + # `param` is a class containing all the relevant parameters and functions for + # this model. These are purely symbolic at this stage, and will be set by the + # `ParameterValues` class when the model is processed. + param = self.param + options = options or {"working electrode": None} + + if options["working electrode"] not in ["negative", "positive"]: + raise ValueError( + "The option 'working electrode' should be either 'positive'" + "or 'negative'" + ) + + self.options.update(options) + working_electrode = options["working electrode"] + + ###################### + # Variables + ###################### + # Variables that depend on time only are created without a domain + Q = pybamm.Variable("Discharge capacity [A.h]") + # Variables that vary spatially are created with a domain. Depending on + # which is the working electrode we need to define a set variables or another + if working_electrode == "negative": + # Electrolyte concentration + c_e_n = pybamm.Variable( + "Negative electrolyte concentration", domain="negative electrode" + ) + c_e_s = pybamm.Variable( + "Separator electrolyte concentration", domain="separator" + ) + # Concatenations combine several variables into a single variable, to + # simplify implementing equations that hold over several domains + c_e = pybamm.Concatenation(c_e_n, c_e_s) + + # Electrolyte potential + phi_e_n = pybamm.Variable( + "Negative electrolyte potential", domain="negative electrode" + ) + phi_e_s = pybamm.Variable( + "Separator electrolyte potential", domain="separator" + ) + phi_e = pybamm.Concatenation(phi_e_n, phi_e_s) + + # Particle concentrations are variables on the particle domain, but also + # vary in the x-direction (electrode domain) and so must be provided with + # auxiliary domains + c_s_n = pybamm.Variable( + "Negative particle concentration", + domain="negative particle", + auxiliary_domains={"secondary": "negative electrode"}, + ) + # Set concentration in positive particle to be equal to the initial + # concentration as it is not the working electrode + x_p = pybamm.PrimaryBroadcast( + pybamm.standard_spatial_vars.x_p, "positive particle" + ) + c_s_p = param.c_n_init(x_p) + + # Electrode potential + phi_s_n = pybamm.Variable( + "Negative electrode potential", domain="negative electrode" + ) + # Set potential in positive electrode to be equal to the initial OCV + phi_s_p = param.U_p(pybamm.surf(param.c_p_init(x_p)), param.T_init) + else: + c_e_p = pybamm.Variable( + "Positive electrolyte concentration", domain="positive electrode" + ) + c_e_s = pybamm.Variable( + "Separator electrolyte concentration", domain="separator" + ) + # Concatenations combine several variables into a single variable, to + # simplify implementing equations that hold over several domains + c_e = pybamm.Concatenation(c_e_s, c_e_p) + + # Electrolyte potential + phi_e_s = pybamm.Variable( + "Separator electrolyte potential", domain="separator" + ) + phi_e_p = pybamm.Variable( + "Positive electrolyte potential", domain="positive electrode" + ) + phi_e = pybamm.Concatenation(phi_e_s, phi_e_p) + + # Particle concentrations are variables on the particle domain, but also + # vary in the x-direction (electrode domain) and so must be provided with + # auxiliary domains + c_s_p = pybamm.Variable( + "Positive particle concentration", + domain="positive particle", + auxiliary_domains={"secondary": "positive electrode"}, + ) + # Set concentration in negative particle to be equal to the initial + # concentration as it is not the working electrode + x_n = pybamm.PrimaryBroadcast( + pybamm.standard_spatial_vars.x_n, "positive particle" + ) + c_s_n = param.c_n_init(x_n) + + # Electrode potential + phi_s_p = pybamm.Variable( + "Positive electrode potential", domain="positive electrode" + ) + # Set potential in negative electrode to be equal to the initial OCV + phi_s_n = param.U_n(pybamm.surf(param.c_n_init(x_n)), param.T_init) + + # Constant temperature + T = param.T_init + + ###################### + # Other set-up + ###################### + + # Current density + i_cell = param.current_with_time + + # Porosity and Tortuosity + # Primary broadcasts are used to broadcast scalar quantities across a domain + # into a vector of the right shape, for multiplying with other vectors + eps_n = pybamm.PrimaryBroadcast( + pybamm.Parameter("Negative electrode porosity"), "negative electrode" + ) + eps_s = pybamm.PrimaryBroadcast( + pybamm.Parameter("Separator porosity"), "separator" + ) + eps_p = pybamm.PrimaryBroadcast( + pybamm.Parameter("Positive electrode porosity"), "positive electrode" + ) + + if working_electrode == "negative": + eps = pybamm.Concatenation(eps_n, eps_s) + tor = pybamm.Concatenation(eps_n ** param.b_e_n, eps_s ** param.b_e_s) + else: + eps = pybamm.Concatenation(eps_s, eps_p) + tor = pybamm.Concatenation(eps_s ** param.b_e_s, eps_p ** param.b_e_p) + + # Interfacial reactions + # Surf takes the surface value of a variable, i.e. its boundary value on the + # right side. This is also accessible via `boundary_value(x, "right")`, with + # "left" providing the boundary value of the left side + c_s_surf_n = pybamm.surf(c_s_n) + c_s_surf_p = pybamm.surf(c_s_p) + + if working_electrode == "negative": + j0_n = param.j0_n(c_e_n, c_s_surf_n, T) / param.C_r_n + j_n = ( + 2 + * j0_n + * pybamm.sinh( + param.ne_n / 2 * (phi_s_n - phi_e_n - param.U_n(c_s_surf_n, T)) + ) + ) + j_s = pybamm.PrimaryBroadcast(0, "separator") + j_p = pybamm.PrimaryBroadcast(0, "positive electrode") + j = pybamm.Concatenation(j_n, j_s) + else: + j0_p = param.gamma_p * param.j0_p(c_e_p, c_s_surf_p, T) / param.C_r_p + j_p = ( + 2 + * j0_p + * pybamm.sinh( + param.ne_p / 2 * (phi_s_p - phi_e_p - param.U_p(c_s_surf_p, T)) + ) + ) + j_s = pybamm.PrimaryBroadcast(0, "separator") + j_n = pybamm.PrimaryBroadcast(0, "negative electrode") + j = pybamm.Concatenation(j_s, j_p) + + ###################### + # State of Charge + ###################### + I = param.dimensional_current_with_time + # The `rhs` dictionary contains differential equations, with the key being the + # variable in the d/dt + self.rhs[Q] = I * param.timescale / 3600 + # Initial conditions must be provided for the ODEs + self.initial_conditions[Q] = pybamm.Scalar(0) + + ###################### + # Particles + ###################### + + if working_electrode == "negative": + # The div and grad operators will be converted to the appropriate matrix + # multiplication at the discretisation stage + N_s_n = -param.D_n(c_s_n, T) * pybamm.grad(c_s_n) + self.rhs[c_s_n] = -(1 / param.C_n) * pybamm.div(N_s_n) + + # Boundary conditions must be provided for equations with spatial + # derivatives + self.boundary_conditions[c_s_n] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": ( + -param.C_n * j_n / param.a_n / param.D_n(c_s_surf_n, T), + "Neumann", + ), + } + + # c_n_init can in general be a function of x + # Note the broadcasting, for domains + x_n = pybamm.PrimaryBroadcast( + pybamm.standard_spatial_vars.x_n, "negative particle" + ) + self.initial_conditions[c_s_n] = param.c_n_init(x_n) + + # Events specify points at which a solution should terminate + self.events += [ + pybamm.Event( + "Minimum negative particle surface concentration", + pybamm.min(c_s_surf_n) - 0.01, + ), + pybamm.Event( + "Maximum negative particle surface concentration", + (1 - 0.01) - pybamm.max(c_s_surf_n), + ), + ] + else: + # The div and grad operators will be converted to the appropriate matrix + # multiplication at the discretisation stage + N_s_p = -param.D_p(c_s_p, T) * pybamm.grad(c_s_p) + self.rhs[c_s_p] = -(1 / param.C_p) * pybamm.div(N_s_p) + + # Boundary conditions must be provided for equations with spatial + # derivatives + self.boundary_conditions[c_s_p] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": ( + -param.C_p + * j_p + / param.a_p + / param.gamma_p + / param.D_p(c_s_surf_p, T), + "Neumann", + ), + } + + # c_p_init can in general be a function of x + # Note the broadcasting, for domains + x_p = pybamm.PrimaryBroadcast( + pybamm.standard_spatial_vars.x_p, "positive particle" + ) + self.initial_conditions[c_s_p] = param.c_p_init(x_p) + + # Events specify points at which a solution should terminate + self.events += [ + pybamm.Event( + "Minimum positive particle surface concentration", + pybamm.min(c_s_surf_p) - 0.01, + ), + pybamm.Event( + "Maximum positive particle surface concentration", + (1 - 0.01) - pybamm.max(c_s_surf_p), + ), + ] + + ###################### + # Current in the solid + ###################### + + if working_electrode == "negative": + sigma_eff_n = param.sigma_n * (1 - eps_n) ** param.b_s_n + i_s_n = -sigma_eff_n * pybamm.grad(phi_s_n) + self.boundary_conditions[phi_s_n] = { + "left": ( + i_cell / pybamm.boundary_value(-sigma_eff_n, "left"), + "Neumann", + ), + "right": (pybamm.Scalar(0), "Neumann"), + } + # The `algebraic` dictionary contains differential equations, with the key + # being the main scalar variable of interest in the equation + self.algebraic[phi_s_n] = pybamm.div(i_s_n) + j_n + + # Initial conditions must also be provided for algebraic equations, as an + # initial guess for a root-finding algorithm which calculates consistent + # initial conditions + self.initial_conditions[phi_s_n] = param.U_n( + param.c_n_init(0), param.T_init + ) + else: + sigma_eff_p = param.sigma_p * (1 - eps_p) ** param.b_s_p + i_s_p = -sigma_eff_p * pybamm.grad(phi_s_p) + self.boundary_conditions[phi_s_p] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": ( + i_cell / pybamm.boundary_value(-sigma_eff_p, "right"), + "Neumann", + ), + } + self.algebraic[phi_s_p] = pybamm.div(i_s_p) + j_p + # Initial conditions must also be provided for algebraic equations, as an + # initial guess for a root-finding algorithm which calculates consistent + # initial conditions + self.initial_conditions[phi_s_p] = param.U_p( + param.c_p_init(1), param.T_init + ) + + ###################### + # Electrolyte concentration + ###################### + N_e = -tor * param.D_e(c_e, T) * pybamm.grad(c_e) + self.rhs[c_e] = (1 / eps) * ( + -pybamm.div(N_e) / param.C_e + (1 - param.t_plus(c_e)) * j / param.gamma_e + ) + dce_dx = ( + -(1 - param.t_plus(c_e)) + * i_cell + * param.C_e + / (tor * param.gamma_e * param.D_e(c_e, T)) + ) + + if working_electrode == "negative": + self.boundary_conditions[c_e] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": (pybamm.boundary_value(dce_dx, "right"), "Neumann"), + } + else: + self.boundary_conditions[c_e] = { + "left": (pybamm.boundary_value(dce_dx, "left"), "Neumann"), + "right": (pybamm.Scalar(0), "Neumann"), + } + + self.initial_conditions[c_e] = param.c_e_init + self.events.append( + pybamm.Event( + "Zero electrolyte concentration cut-off", pybamm.min(c_e) - 0.002 + ) + ) + + ###################### + # Current in the electrolyte + ###################### + i_e = (param.kappa_e(c_e, T) * tor * param.gamma_e / param.C_e) * ( + param.chi(c_e) * pybamm.grad(c_e) / c_e - pybamm.grad(phi_e) + ) + self.algebraic[phi_e] = pybamm.div(i_e) - j + + if working_electrode == "negative": + self.boundary_conditions[phi_e] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": (pybamm.Scalar(0), "Dirichlet"), + } + else: + self.boundary_conditions[phi_e] = { + "left": (pybamm.Scalar(0), "Dirichlet"), + "right": (pybamm.Scalar(0), "Neumann"), + } + + self.initial_conditions[phi_e] = pybamm.Scalar(0) + + ###################### + # (Some) variables + ###################### + L_Li = pybamm.Parameter("Lithium counter electrode thickness [m]") + sigma_Li = pybamm.Parameter("Lithium counter electrode conductivity [S.m-1]") + j_Li = pybamm.Parameter( + "Lithium counter electrode exchange-current density [A.m-2]" + ) + + pot = param.potential_scale + i_typ = param.current_scale + + if working_electrode == "negative": + voltage = pybamm.boundary_value(phi_s_n, "left") + voltage_dim = param.U_n_ref + pot * voltage + vdrop_Li = 2 * pybamm.arcsinh( + i_cell * i_typ / j_Li + ) + L_Li * i_typ * i_cell / (sigma_Li * pot) + vdrop_Li_dim = ( + 2 * pot * pybamm.arcsinh(i_cell * i_typ / j_Li) + + L_Li * i_typ * i_cell / sigma_Li + ) + else: + voltage = pybamm.boundary_value(phi_s_p, "right") + voltage_dim = param.U_p_ref + pot * voltage + vdrop_Li = -( + 2 * pybamm.arcsinh(i_cell * i_typ / j_Li) + + L_Li * i_typ * i_cell / (sigma_Li * pot) + ) + vdrop_Li_dim = -( + 2 * pot * pybamm.arcsinh(i_cell * i_typ / j_Li) + + L_Li * i_typ * i_cell / sigma_Li + ) + + c_s_surf_p_av = pybamm.x_average(c_s_surf_p) + c_s_surf_n_av = pybamm.x_average(c_s_surf_n) + + # The `variables` dictionary contains all variables that might be useful for + # visualising the solution of the model + self.variables = { + "Time [s]": param.timescale * pybamm.t, + "Negative particle surface concentration": c_s_surf_n, + "X-averaged negative particle surface concentration": c_s_surf_n_av, + "Negative particle concentration": c_s_n, + "Negative particle surface concentration [mol.m-3]": param.c_n_max + * c_s_surf_n, + "X-averaged negative particle surface concentration [mol.m-3]": + param.c_n_max + * c_s_surf_n_av, + "Negative particle concentration [mol.m-3]": param.c_n_max * c_s_n, + "Electrolyte concentration": c_e, + "Electrolyte concentration [mol.m-3]": param.c_e_typ * c_e, + "Positive particle surface concentration": c_s_surf_p, + "X-averaged positive particle surface concentration": c_s_surf_p_av, + "Positive particle concentration": c_s_p, + "Positive particle surface concentration [mol.m-3]": param.c_p_max + * c_s_surf_p, + "X-averaged positive particle surface concentration [mol.m-3]": + param.c_p_max + * c_s_surf_p_av, + "Positive particle concentration [mol.m-3]": param.c_p_max * c_s_p, + "Current [A]": I, + "Negative electrode potential": phi_s_n, + "Negative electrode potential [V]": param.U_n_ref + pot * phi_s_n, + "Negative electrode open circuit potential": param.U_n(c_s_surf_n, T), + "Electrolyte potential": phi_e, + "Electrolyte potential [V]": pot * phi_e, + "Positive electrode potential": phi_s_p, + "Positive electrode potential [V]": param.U_p_ref + pot * phi_s_p, + "Positive electrode open circuit potential": param.U_p(c_s_surf_p, T), + "Voltage drop": voltage, + "Voltage drop [V]": voltage_dim, + "Terminal voltage": voltage + vdrop_Li, + "Terminal voltage [V]": voltage_dim + vdrop_Li_dim, + } + + def new_copy(self, build=False): + new_model = self.__class__(name=self.name, options=self.options) + new_model.use_jacobian = self.use_jacobian + new_model.use_simplify = self.use_simplify + new_model.convert_to_format = self.convert_to_format + new_model.timescale = self.timescale + new_model.length_scales = self.length_scales + return new_model diff --git a/pybamm/simulation.py b/pybamm/simulation.py index fdd6b6b082..32e4eac569 100644 --- a/pybamm/simulation.py +++ b/pybamm/simulation.py @@ -86,6 +86,11 @@ def __init__( ): self.parameter_values = parameter_values or model.default_parameter_values + if isinstance(model, pybamm.lithium_ion.BasicDFNHalfCell): + raise NotImplementedError( + "BasicDFNHalfCell is not compatible with Simulations yet." + ) + if experiment is None: # Check to see if the current is provided as data (i.e. drive cycle) current = self._parameter_values.get("Current function [A]") diff --git a/pybamm/solvers/base_solver.py b/pybamm/solvers/base_solver.py index 3bdda498ae..d6034155ed 100644 --- a/pybamm/solvers/base_solver.py +++ b/pybamm/solvers/base_solver.py @@ -117,7 +117,7 @@ def copy(self): new_solver.models_set_up = {} return new_solver - def set_up(self, model, inputs=None): + def set_up(self, model, inputs=None, t_eval=None): """Unpack model, perform checks, simplify and calculate jacobian. Parameters @@ -127,6 +127,8 @@ def set_up(self, model, inputs=None): initial_conditions inputs : dict, optional Any input parameters to pass to the model when solving + t_eval : numeric type, optional + The times (in seconds) at which to compute the solution """ @@ -279,8 +281,8 @@ def report(string): jac_call = None return func, func_call, jac_call - # Check for heaviside functions in rhs and algebraic and add discontinuity - # events if these exist. + # Check for heaviside and modulo functions in rhs and algebraic and add + # discontinuity events if these exist. # Note: only checks for the case of t < X, t <= X, X < t, or X <= t, but also # accounts for the fact that t might be dimensional # Only do this for DAE models as ODE models can deal with discontinuities fine @@ -315,6 +317,32 @@ def report(string): pybamm.EventType.DISCONTINUITY, ) ) + elif isinstance(symbol, pybamm.Modulo): + found_t = False + # Dimensionless + if symbol.left.id == pybamm.t.id: + expr = symbol.right + found_t = True + # Dimensional + elif symbol.left.id == (pybamm.t * model.timescale).id: + expr = symbol.right.new_copy() / symbol.left.right.new_copy() + found_t = True + + # Update the events if the modulo function depended on t + if found_t: + if t_eval is None: + N_events = 200 + else: + N_events = t_eval[-1] // expr.value + + for i in np.arange(N_events): + model.events.append( + pybamm.Event( + str(symbol), + expr.new_copy() * pybamm.Scalar(i + 1), + pybamm.EventType.DISCONTINUITY, + ) + ) # Process initial conditions initial_conditions = process( @@ -536,7 +564,7 @@ def solve(self, model, t_eval=None, external_variables=None, inputs=None): # Set up (if not done already) if model not in self.models_set_up: - self.set_up(model, ext_and_inputs) + self.set_up(model, ext_and_inputs, t_eval) set_up_time = timer.time() self.models_set_up.update( {model: {"initial conditions": model.concatenated_initial_conditions}} @@ -550,7 +578,7 @@ def solve(self, model, t_eval=None, external_variables=None, inputs=None): # If the new initial conditions are different, set up again # Doing the whole setup again might be slow, but no need to prematurely # optimize this - self.set_up(model, ext_and_inputs) + self.set_up(model, ext_and_inputs, t_eval) self.models_set_up[model][ "initial conditions" ] = model.concatenated_initial_conditions diff --git a/tests/unit/test_expression_tree/test_binary_operators.py b/tests/unit/test_expression_tree/test_binary_operators.py index 71d1689c33..5aa4e3ba6c 100644 --- a/tests/unit/test_expression_tree/test_binary_operators.py +++ b/tests/unit/test_expression_tree/test_binary_operators.py @@ -303,6 +303,17 @@ def test_heaviside(self): self.assertEqual(heav.evaluate(y=np.array([0])), 1) self.assertEqual(str(heav), "y[0:1] <= 1.0") + def test_modulo(self): + a = pybamm.StateVector(slice(0, 1)) + b = pybamm.Scalar(3) + mod = a % b + self.assertEqual(mod.evaluate(y=np.array([4]))[0, 0], 1) + self.assertEqual(mod.evaluate(y=np.array([3]))[0, 0], 0) + self.assertEqual(mod.evaluate(y=np.array([2]))[0, 0], 2) + self.assertAlmostEqual(mod.evaluate(y=np.array([4.3]))[0, 0], 1.3) + self.assertAlmostEqual(mod.evaluate(y=np.array([2.2]))[0, 0], 2.2) + self.assertEqual(str(mod), "y[0:1] mod 3.0") + def test_minimum_maximum(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) diff --git a/tests/unit/test_expression_tree/test_operations/test_convert_to_casadi.py b/tests/unit/test_expression_tree/test_operations/test_convert_to_casadi.py index f51c2fbdd4..ef28a429ad 100644 --- a/tests/unit/test_expression_tree/test_operations/test_convert_to_casadi.py +++ b/tests/unit/test_expression_tree/test_operations/test_convert_to_casadi.py @@ -20,6 +20,8 @@ def test_convert_scalar_symbols(self): b = pybamm.Scalar(1) c = pybamm.Scalar(-1) d = pybamm.Scalar(2) + e = pybamm.Scalar(3) + g = pybamm.Scalar(3.3) self.assertEqual(a.to_casadi(), casadi.MX(0)) self.assertEqual(d.to_casadi(), casadi.MX(2)) @@ -28,6 +30,10 @@ def test_convert_scalar_symbols(self): self.assertEqual((-b).to_casadi(), casadi.MX(-1)) # absolute value self.assertEqual(abs(c).to_casadi(), casadi.MX(1)) + # floor + self.assertEqual(pybamm.Floor(g).to_casadi(), casadi.MX(3)) + # ceiling + self.assertEqual(pybamm.Ceiling(g).to_casadi(), casadi.MX(4)) # function def square_plus_one(x): @@ -54,6 +60,9 @@ def myfunction(x, y): # division self.assertEqual(pybamm.Division(b, d).to_casadi(), casadi.MX(1 / 2)) + # modulo + self.assertEqual(pybamm.Modulo(e, d).to_casadi(), casadi.MX(1)) + # minimum and maximum self.assertEqual(pybamm.Minimum(a, b).to_casadi(), casadi.MX(0)) self.assertEqual(pybamm.Maximum(a, b).to_casadi(), casadi.MX(1)) @@ -95,9 +104,19 @@ def test_special_functions(self): self.assert_casadi_equal( pybamm.Function(np.abs, c).to_casadi(), casadi.MX(3), evalf=True ) - for np_fun in [np.sqrt, np.tanh, np.cosh, np.sinh, - np.exp, np.log, np.sign, np.sin, np.cos, - np.arccosh, np.arcsinh]: + for np_fun in [ + np.sqrt, + np.tanh, + np.cosh, + np.sinh, + np.exp, + np.log, + np.sign, + np.sin, + np.cos, + np.arccosh, + np.arcsinh, + ]: self.assert_casadi_equal( pybamm.Function(np_fun, c).to_casadi(), casadi.MX(np_fun(3)), evalf=True ) diff --git a/tests/unit/test_expression_tree/test_operations/test_jac.py b/tests/unit/test_expression_tree/test_operations/test_jac.py index d79929d720..9695c33ab7 100644 --- a/tests/unit/test_expression_tree/test_operations/test_jac.py +++ b/tests/unit/test_expression_tree/test_operations/test_jac.py @@ -305,6 +305,25 @@ def test_jac_of_heaviside(self): ((a < y) * y ** 2).jac(y).evaluate(y=-5 * np.ones(5)), 0 ) + def test_jac_of_modulo(self): + a = pybamm.Scalar(3) + y = pybamm.StateVector(slice(0, 5)) + np.testing.assert_array_equal( + (a % (3 * a)).jac(y).evaluate(y=5 * np.ones(5)), 0 + ) + np.testing.assert_array_equal( + ((y % a) * y ** 2).jac(y).evaluate(y=5 * np.ones(5)).toarray(), + 45 * np.eye(5), + ) + np.testing.assert_array_equal( + ((a % y) * y ** 2).jac(y).evaluate(y=5 * np.ones(5)).toarray(), + 30 * np.eye(5), + ) + np.testing.assert_array_equal( + (((y + 1) ** 2 % y) * y ** 2).jac(y).evaluate(y=5 * np.ones(5)).toarray(), + 135 * np.eye(5), + ) + def test_jac_of_minimum_maximum(self): y = pybamm.StateVector(slice(0, 10)) y_test = np.linspace(0, 2, 10) @@ -333,6 +352,20 @@ def test_jac_of_sign(self): y_test = np.linspace(-2, 2, 10) np.testing.assert_array_equal(np.diag(jac.evaluate(y=y_test)), np.sign(y_test)) + def test_jac_of_floor(self): + y = pybamm.StateVector(slice(0, 10)) + func = pybamm.Floor(y) * y + jac = func.jac(y) + y_test = np.linspace(-2, 2, 10) + np.testing.assert_array_equal(np.diag(jac.evaluate(y=y_test)), np.floor(y_test)) + + def test_jac_of_ceiling(self): + y = pybamm.StateVector(slice(0, 10)) + func = pybamm.Ceiling(y) * y + jac = func.jac(y) + y_test = np.linspace(-2, 2, 10) + np.testing.assert_array_equal(np.diag(jac.evaluate(y=y_test)), np.ceil(y_test)) + def test_jac_of_domain_concatenation(self): # create mesh mesh = get_mesh_for_testing() diff --git a/tests/unit/test_expression_tree/test_symbol.py b/tests/unit/test_expression_tree/test_symbol.py index 703ee7f009..2c23e09fc7 100644 --- a/tests/unit/test_expression_tree/test_symbol.py +++ b/tests/unit/test_expression_tree/test_symbol.py @@ -105,6 +105,7 @@ def test_symbol_methods(self): self.assertIsInstance(a <= b, pybamm.Heaviside) self.assertIsInstance(a > b, pybamm.Heaviside) self.assertIsInstance(a >= b, pybamm.Heaviside) + self.assertIsInstance(a % b, pybamm.Modulo) # binary - symbol and number self.assertIsInstance(a + 2, pybamm.Addition) diff --git a/tests/unit/test_expression_tree/test_symbolic_diff.py b/tests/unit/test_expression_tree/test_symbolic_diff.py index 25c6b76749..f559b8b743 100644 --- a/tests/unit/test_expression_tree/test_symbolic_diff.py +++ b/tests/unit/test_expression_tree/test_symbolic_diff.py @@ -74,6 +74,15 @@ def test_diff_heaviside(self): self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 2) self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 0) + def test_diff_modulo(self): + a = pybamm.Scalar(3) + b = pybamm.StateVector(slice(0, 1)) + + func = (a % b) * (b ** 2) + self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 0) + self.assertEqual(func.diff(b).evaluate(y=np.array([5])), 30) + self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 12) + def test_diff_maximum_minimum(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) diff --git a/tests/unit/test_expression_tree/test_unary_operators.py b/tests/unit/test_expression_tree/test_unary_operators.py index 89f1e89a1f..4b4e7c8ef0 100644 --- a/tests/unit/test_expression_tree/test_unary_operators.py +++ b/tests/unit/test_expression_tree/test_unary_operators.py @@ -51,6 +51,34 @@ def test_sign(self): np.diag(signb.evaluate().toarray()), [-1, -1, 0, 1, 1] ) + def test_floor(self): + a = pybamm.Symbol("a") + floora = pybamm.Floor(a) + self.assertEqual(floora.name, "floor") + self.assertEqual(floora.children[0].name, a.name) + + b = pybamm.Scalar(3.5) + floorb = pybamm.Floor(b) + self.assertEqual(floorb.evaluate(), 3) + + c = pybamm.Scalar(-3.2) + floorc = pybamm.Floor(c) + self.assertEqual(floorc.evaluate(), -4) + + def test_ceiling(self): + a = pybamm.Symbol("a") + ceila = pybamm.Ceiling(a) + self.assertEqual(ceila.name, "ceil") + self.assertEqual(ceila.children[0].name, a.name) + + b = pybamm.Scalar(3.5) + ceilb = pybamm.Ceiling(b) + self.assertEqual(ceilb.evaluate(), 4) + + c = pybamm.Scalar(-3.2) + ceilc = pybamm.Ceiling(c) + self.assertEqual(ceilc.evaluate(), -3) + def test_gradient(self): # gradient of scalar symbol should fail a = pybamm.Symbol("a") @@ -256,6 +284,12 @@ def test_diff(self): # sign self.assertEqual((pybamm.sign(a)).diff(a).evaluate(y=y), 0) + # floor + self.assertEqual((pybamm.Floor(a)).diff(a).evaluate(y=y), 0) + + # ceil + self.assertEqual((pybamm.Ceiling(a)).diff(a).evaluate(y=y), 0) + # spatial operator (not implemented) spatial_a = pybamm.SpatialOperator("name", a) with self.assertRaises(NotImplementedError): diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_basic_models.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_basic_models.py index 97998b066e..2bb6d1cd81 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_basic_models.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_basic_models.py @@ -20,6 +20,29 @@ def test_spm_well_posed(self): copy = model.new_copy() copy.check_well_posedness() + def test_dfn_half_cell_well_posed(self): + options = {"working electrode": "positive"} + model = pybamm.lithium_ion.BasicDFNHalfCell(options=options) + model.check_well_posedness() + + copy = model.new_copy() + copy.check_well_posedness() + + options = {"working electrode": "negative"} + model = pybamm.lithium_ion.BasicDFNHalfCell(options=options) + model.check_well_posedness() + + copy = model.new_copy() + copy.check_well_posedness() + + def test_dfn_half_cell_simulation_error(self): + options = {"working electrode": "negative"} + model = pybamm.lithium_ion.BasicDFNHalfCell(options=options) + with self.assertRaisesRegex( + NotImplementedError, "not compatible with Simulations yet." + ): + pybamm.Simulation(model) + if __name__ == "__main__": print("Add -v for more debug output") diff --git a/tests/unit/test_solvers/test_scikits_solvers.py b/tests/unit/test_solvers/test_scikits_solvers.py index 6ab8fe2dcb..bc8d5a22c1 100644 --- a/tests/unit/test_solvers/test_scikits_solvers.py +++ b/tests/unit/test_solvers/test_scikits_solvers.py @@ -335,6 +335,69 @@ def nonsmooth_mult(t): np.testing.assert_allclose(solution.y[0], var1_soln, rtol=1e-06) np.testing.assert_allclose(solution.y[-1], var2_soln, rtol=1e-06) + def test_model_solver_dae_multiple_nonsmooth_python(self): + model = pybamm.BaseModel() + model.convert_to_format = "python" + whole_cell = ["negative electrode", "separator", "positive electrode"] + var1 = pybamm.Variable("var1", domain=whole_cell) + var2 = pybamm.Variable("var2", domain=whole_cell) + a = 0.6 + discontinuities = (np.arange(3) + 1) * a + + model.rhs = {var1: pybamm.Modulo(pybamm.t, a)} + model.algebraic = {var2: 2 * var1 - var2} + model.initial_conditions = {var1: 0, var2: 0} + model.events = [ + pybamm.Event("var1 = 0.55", pybamm.min(var1 - 0.55)), + pybamm.Event("var2 = 1.2", pybamm.min(var2 - 1.2)), + ] + for discontinuity in discontinuities: + model.events.append( + pybamm.Event( + "nonsmooth rate", + pybamm.Scalar(discontinuity), + ) + ) + disc = get_discretisation_for_testing() + disc.process_model(model) + + # Solve + solver = pybamm.ScikitsDaeSolver(rtol=1e-8, atol=1e-8, root_method="lm") + + # create two time series, one without a time point on the discontinuity, + # and one with + t_eval1 = np.linspace(0, 2, 10) + t_eval2 = np.insert( + t_eval1, np.searchsorted(t_eval1, discontinuities), discontinuities + ) + solution1 = solver.solve(model, t_eval1) + solution2 = solver.solve(model, t_eval2) + + # check time vectors + for solution in [solution1, solution2]: + # time vectors are ordered + self.assertTrue(np.all(solution.t[:-1] <= solution.t[1:])) + + # time value before and after discontinuity is an epsilon away + for discontinuity in discontinuities: + dindex = np.searchsorted(solution.t, discontinuity) + value_before = solution.t[dindex - 1] + value_after = solution.t[dindex] + self.assertEqual(value_before + sys.float_info.epsilon, discontinuity) + self.assertEqual(value_after - sys.float_info.epsilon, discontinuity) + + # both solution time vectors should have same number of points + self.assertEqual(len(solution1.t), len(solution2.t)) + + # check solution + for solution in [solution1, solution2]: + np.testing.assert_array_less(solution.y[0], 0.55) + np.testing.assert_array_less(solution.y[-1], 1.2) + var1_soln = (solution.t % a) ** 2 / 2 + a ** 2 / 2 * (solution.t // a) + var2_soln = 2 * var1_soln + np.testing.assert_allclose(solution.y[0], var1_soln, rtol=1e-06) + np.testing.assert_allclose(solution.y[-1], var2_soln, rtol=1e-06) + def test_model_solver_dae_no_nonsmooth_python(self): model = pybamm.BaseModel() model.convert_to_format = "python" @@ -679,6 +742,52 @@ def test_model_step_events(self): step_solution.y[-1], 2 * np.exp(0.1 * step_solution.t), decimal=5 ) + def test_model_step_nonsmooth_events(self): + # Create model + model = pybamm.BaseModel() + model.timescale = pybamm.Scalar(1) + var1 = pybamm.Variable("var1") + var2 = pybamm.Variable("var2") + a = 0.6 + discontinuities = (np.arange(3) + 1) * a + + model.rhs = {var1: pybamm.Modulo(pybamm.t * model.timescale, a)} + model.algebraic = {var2: 2 * var1 - var2} + model.initial_conditions = {var1: 0, var2: 0} + model.events = [ + pybamm.Event("var1 = 0.55", pybamm.min(var1 - 0.55)), + pybamm.Event("var2 = 1.2", pybamm.min(var2 - 1.2)), + ] + for discontinuity in discontinuities: + model.events.append( + pybamm.Event( + "nonsmooth rate", + pybamm.Scalar(discontinuity), + ) + ) + disc = get_discretisation_for_testing() + disc.process_model(model) + + # Solve + step_solver = pybamm.ScikitsDaeSolver(rtol=1e-8, atol=1e-8) + dt = 0.05 + time = 0 + end_time = 3 + step_solution = None + while time < end_time: + step_solution = step_solver.step(step_solution, model, dt=dt, npts=10) + time += dt + np.testing.assert_array_less(step_solution.y[0], 0.55) + np.testing.assert_array_less(step_solution.y[-1], 1.2) + var1_soln = (step_solution.t % a) ** 2 / 2 + a ** 2 / 2 * (step_solution.t // a) + var2_soln = 2 * var1_soln + np.testing.assert_array_almost_equal( + step_solution.y[0], var1_soln, decimal=5 + ) + np.testing.assert_array_almost_equal( + step_solution.y[-1], var2_soln, decimal=5 + ) + def test_model_solver_dae_nonsmooth(self): whole_cell = ["negative electrode", "separator", "positive electrode"] var1 = pybamm.Variable("var1", domain=whole_cell)