#
# Function classes and methods
#
from __future__ import annotations
from collections.abc import Callable, Sequence
import casadi
import numpy as np
import numpy.typing as npt
import sympy
from scipy import special
from typing_extensions import TypeVar
import pybamm
def _is_constant_value(x, value: float) -> bool:
# True iff ``x`` is a scalar constant equal to ``value``. Conservative on
# symbolic or multi-element inputs.
if isinstance(x, casadi.MX) and np.prod(x.size(), initial=1) > 2:
return False
if isinstance(x, np.ndarray):
return bool(np.all(x == value))
try:
return bool(x == value)
except (TypeError, ValueError, RuntimeError):
return False
[docs]
class Function(pybamm.Symbol):
"""
A node in the expression tree representing an arbitrary function.
Parameters
----------
function : method
A function can have 0 or many inputs. If no inputs are given, self.evaluate()
simply returns func(). Otherwise, self.evaluate(t, y, u) returns
func(child0.evaluate(t, y, u), child1.evaluate(t, y, u), etc).
children : :class:`pybamm.Symbol`
The children nodes to apply the function to
differentiated_function : method, optional
The function which was differentiated to obtain this one. Default is None.
"""
def __init__(
self,
function: Callable,
*children: pybamm.Symbol,
name: str | None = None,
differentiated_function: Callable | None = None,
):
# Turn numbers into scalars
children = list(children)
for idx, child in enumerate(children):
if isinstance(child, float | int | np.number):
children[idx] = pybamm.Scalar(child)
if name is not None:
self.name = name
else:
try:
name = f"function ({function.__name__})"
except AttributeError:
name = f"function ({function.__class__})"
domains = self.get_children_domains(children)
self.function = function
self.differentiated_function = differentiated_function
super().__init__(name, children=children, domains=domains)
def __str__(self):
"""See :meth:`pybamm.Symbol.__str__()`."""
out = f"{self.name[10:-1]}("
for child in self.children:
out += f"{child!s}, "
out = out[:-2] + ")"
return out
[docs]
def diff(self, variable: pybamm.Symbol):
"""See :meth:`pybamm.Symbol.diff()`."""
if variable == self:
return pybamm.Scalar(1)
else:
children = self.orphans
partial_derivatives: list[None | pybamm.Symbol] = [None] * len(children)
for i, child in enumerate(self.children):
# if variable appears in the function, differentiate
# function, and apply chain rule
if variable in child.pre_order():
partial_derivatives[i] = self._function_diff(
children, i
) * child.diff(variable)
# remove None entries
partial_derivatives = [x for x in partial_derivatives if x is not None]
derivative = sum(partial_derivatives)
if derivative == 0:
return pybamm.Scalar(0)
return derivative
def _function_diff(self, children: Sequence[pybamm.Symbol], idx: float):
"""
Derivative with respect to child number 'idx'.
See :meth:`pybamm.Symbol._diff()`.
"""
raise NotImplementedError(
"Derivative of base Function class is not implemented. "
"Please implement in child class."
)
def _function_jac(self, children_jacs):
"""Calculate the Jacobian of a function."""
if all(child.evaluates_to_constant_number() for child in self.children):
jacobian = pybamm.Scalar(0)
else:
# if at least one child contains variable dependence, then
# calculate the required partial Jacobians and add them
jacobian = None
children = self.orphans
for i, child in enumerate(children):
if not child.evaluates_to_constant_number():
jac_fun = self._function_diff(children, i) * children_jacs[i]
jac_fun.clear_domains()
if jacobian is None:
jacobian = jac_fun
else:
jacobian += jac_fun
return jacobian
[docs]
def evaluate(
self,
t: float | None = None,
y: npt.NDArray[np.float64] | None = None,
y_dot: npt.NDArray[np.float64] | None = None,
inputs: dict | str | None = None,
):
"""See :meth:`pybamm.Symbol.evaluate()`."""
evaluated_children = [
child.evaluate(t, y, y_dot, inputs) for child in self.children
]
return self._function_evaluate(evaluated_children)
def _evaluates_on_edges(self, dimension: str) -> bool:
"""See :meth:`pybamm.Symbol._evaluates_on_edges()`."""
return any(child.evaluates_on_edges(dimension) for child in self.children)
[docs]
def is_constant(self):
"""See :meth:`pybamm.Symbol.is_constant()`."""
return all(child.is_constant() for child in self.children)
def _evaluate_for_shape(self):
"""
Default behaviour: has same shape as all child
See :meth:`pybamm.Symbol.evaluate_for_shape()`
"""
evaluated_children = [child.evaluate_for_shape() for child in self.children]
return self._function_evaluate(evaluated_children)
def _function_evaluate(self, evaluated_children):
return self.function(*evaluated_children)
def _casadi_evaluate(self, *converted_children):
"""CasADi analog of :meth:`_function_evaluate`. Override in subclasses where the
CasADi function differs from the numpy one."""
return self._function_evaluate(converted_children)
def _to_casadi(self, t, y, y_dot, inputs, casadi_symbols):
"""See :meth:`pybamm.Symbol._to_casadi()`."""
converted_children = super()._children_to_casadi(
t, y, y_dot, inputs, casadi_symbols
)
return self._casadi_evaluate(*converted_children)
[docs]
def create_copy(
self,
new_children: list[pybamm.Symbol] | None = None,
perform_simplifications: bool = True,
):
"""See :meth:`pybamm.Symbol.new_copy()`."""
children = self._children_for_copying(new_children)
if not perform_simplifications:
return pybamm.Function(
self.function,
*children,
name=self.name,
differentiated_function=self.differentiated_function,
)
else:
# performs additional simplifications, rather than just calling the
# constructor
return self._function_new_copy(children)
def _function_new_copy(self, children: list) -> Function:
"""
Returns a new copy of the function.
Inputs
------
children : : list
A list of the children of the function
Returns
-------
: :pybamm.Function
A new copy of the function
"""
return pybamm.simplify_if_constant(
pybamm.Function(
self.function,
*children,
name=self.name,
differentiated_function=self.differentiated_function,
)
)
def _sympy_operator(self, child):
"""Apply appropriate SymPy operators."""
return child
[docs]
def to_equation(self):
"""Convert the node and its subtree into a SymPy equation."""
if self.print_name is not None:
return sympy.Symbol(self.print_name)
else:
eq_list = []
for child in self.children:
eq = child.to_equation()
eq_list.append(eq)
return self._sympy_operator(*eq_list)
[docs]
def to_json(self):
raise NotImplementedError(
"pybamm.Function: Serialisation is only implemented for discretised models."
)
@classmethod
def _from_json(cls, snippet):
raise NotImplementedError(
"pybamm.Function: Please use a discretised model when reading in from JSON."
)
[docs]
class SpecificFunction(Function):
"""
Parent class for the specific functions, which implement their own `diff`
operators directly.
Parameters
----------
function : method
Function to be applied to child
child : :class:`pybamm.Symbol`
The child to apply the function to
"""
def __init__(self, function: Callable, child: pybamm.Symbol):
super().__init__(function, child)
@classmethod
def _from_json(cls, snippet: dict):
"""
Reconstructs a SpecificFunction instance during deserialisation of a JSON file.
Parameters
----------
function : method
Function to be applied to child
snippet: dict
Contains the child to apply the function to
"""
instance = cls.__new__(cls)
super(SpecificFunction, instance).__init__(
snippet["function"], snippet["children"][0]
)
return instance
def _function_new_copy(self, children):
"""See :meth:`pybamm.Function._function_new_copy()`"""
return pybamm.simplify_if_constant(self.__class__(*children))
def _sympy_operator(self, child):
"""Apply appropriate SymPy operators."""
class_name = self.__class__.__name__.lower()
sympy_function = getattr(sympy, class_name)
return sympy_function(child)
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.Function._casadi_evaluate()`. Subclasses must override."""
raise NotImplementedError(
f"{self.__class__} does not implement _casadi_evaluate."
)
[docs]
def to_json(self):
"""
Method to serialise a SpecificFunction object into JSON.
"""
json_dict = {
"name": self.name,
"id": self.id,
"function": self.function.__name__,
}
return json_dict
SF = TypeVar("SF", bound=SpecificFunction)
def simplified_function(func_class: type[SF], child: pybamm.Symbol):
"""
Simplifications implemented before applying the function.
Currently only implemented for one-child functions.
"""
if isinstance(child, pybamm.Broadcast):
# Move the function inside the broadcast
# Apply recursively
func_child_not_broad = pybamm.simplify_if_constant(
simplified_function(func_class, child.orphans[0])
)
return child._unary_new_copy(func_child_not_broad)
else:
return pybamm.simplify_if_constant(func_class(child)) # type: ignore[call-arg, arg-type]
[docs]
class Arcsinh(SpecificFunction):
"""Arcsinh function."""
def __init__(self, child):
super().__init__(np.arcsinh, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.arcsinh
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.arcsinh(child)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Symbol._function_diff()`."""
return 1 / sqrt(children[0] ** 2 + 1)
def _sympy_operator(self, child):
"""Override :meth:`pybamm.Function._sympy_operator`"""
return sympy.asinh(child)
[docs]
def arcsinh(child: pybamm.Symbol):
"""Returns arcsinh function of child."""
return simplified_function(Arcsinh, child)
class Arcsinh2(Function):
"""
Two-argument arcsinh function for arcsinh(a/b) that avoids division by zero
by adding a small regularisation term to the denominator.
Computes arcsinh(a / b_eff) where b_eff = sign(b) * hypot(b, eps).
Note: the sign(b) function treats sign(0) as 1 for numerical stability.
Parameters
----------
a : pybamm.Symbol or float
The numerator argument
b : pybamm.Symbol or float
The denominator argument
eps : float, optional
Small regularisation parameter. Defaults to
pybamm.settings.tolerances["reg_arcsinh2"]
Returns
-------
pybamm.Symbol
The regularised arcsinh(a/b) value
"""
def __init__(
self,
a: pybamm.Symbol,
b: pybamm.Symbol,
eps: float | None = None,
):
if eps is None:
eps = pybamm.settings.tolerances["reg_arcsinh2"]
self.eps = eps
super().__init__(self._arcsinh2_evaluate, a, b, name="arcsinh2")
@staticmethod
def _arcsinh2_evaluate(a, b, eps):
"""Evaluate arcsinh2.
Computes arcsinh(a/b) with regularization to avoid division by zero.
Uses arcsinh(a / b_eff) where b_eff = sign(b) * hypot(b, eps).
This formula has the correct derivative 1/b_eff at a=0.
Works with both numpy arrays and JAX traced arrays.
"""
# Use jax.numpy when tracing JAX arrays, otherwise numpy
if pybamm.has_jax():
import jax.numpy as jnp
if isinstance(a, jnp.ndarray) or isinstance(b, jnp.ndarray):
sign_b = jnp.where(b >= 0, 1.0, -1.0)
b_eff = sign_b * jnp.sqrt(b**2 + eps**2)
return jnp.arcsinh(a / b_eff)
# sign(b) but treat sign(0) as non-zero
sign_b = np.where(b >= 0, 1.0, -1.0)
b_eff = sign_b * np.hypot(b, eps)
return np.arcsinh(a / b_eff)
def _function_evaluate(self, evaluated_children):
"""See :meth:`pybamm.Function._function_evaluate()`."""
return self._arcsinh2_evaluate(
evaluated_children[0], evaluated_children[1], self.eps
)
def _function_diff(self, children, idx):
"""
Derivative with respect to child number 'idx'.
For f(a, b) = arcsinh(a / b_eff) where b_eff = sign(b) * hypot(b, eps):
df/da = sign(b) / hypot(a, hypot(b, eps))
df/db = -a * |b| / (hypot(a, hypot(b, eps)) * hypot(b, eps)^2)
Note: sign(b) = 1 when b >= 0.
"""
a, b = children
b_eff = pybamm.hypot(b, self.eps) # |b_eff| = hypot(b, eps), always positive
h = pybamm.hypot(a, b_eff)
# sign(b) but treat sign(0) as non-zero
sign_b = 2 * (b >= 0) - 1
if idx == 0:
# df/da = sign(b) / hypot(a, |b_eff|)
return sign_b / h
elif idx == 1:
# df/db = -a * |b| / (h * |b_eff|^2)
return -a * abs(b) / (h * b_eff**2)
else:
raise IndexError("Arcsinh2 only has two children (a, b)")
def _function_new_copy(self, children):
"""See :meth:`pybamm.Function._function_new_copy()`"""
return Arcsinh2(*children, eps=self.eps)
def _casadi_evaluate(self, a, b):
"""See :meth:`pybamm.Function._casadi_evaluate()`."""
sign_b = 2 * (b >= 0) - 1
b_eff = sign_b * casadi.hypot(b, self.eps)
return casadi.arcsinh(a / b_eff)
def _sympy_operator(self, a, b):
"""Convert to SymPy expression."""
# sign(b) but treat sign(0) as non-zero
sign_b = sympy.Piecewise((1, b >= 0), (-1, True))
b_eff = sign_b * sympy.sqrt(b**2 + self.eps**2)
return sympy.asinh(a / b_eff)
def to_json(self):
"""Method to serialise a Arcsinh2 object into JSON."""
return {
"name": self.name,
"id": self.id,
"function": "arcsinh2",
"eps": self.eps,
}
@classmethod
def _from_json(cls, snippet: dict):
"""Reconstruct a Arcsinh2 instance from JSON."""
instance = cls.__new__(cls)
instance.eps = snippet.get("eps", pybamm.settings.tolerances["reg_arcsinh2"])
# The parent Function.__init__ will be called with children from snippet
super(Arcsinh2, instance).__init__(
instance._arcsinh2_evaluate,
*snippet["children"],
name="arcsinh2",
)
return instance
def arcsinh2(
a: pybamm.Symbol | float,
b: pybamm.Symbol | float,
eps: float | None = None,
) -> pybamm.Symbol:
"""
Two-argument arcsinh function for arcsinh(a/b) that avoids division by zero
by adding a small regularisation term to the denominator.
Computes arcsinh(a / b_eff), where b_eff = sign(b) * hypot(b, eps).
Parameters
----------
a : pybamm.Symbol or float
The numerator argument
b : pybamm.Symbol or float
The denominator argument
eps : float, optional
Small regularisation parameter. Defaults to
pybamm.settings.tolerances["reg_arcsinh2"]
Returns
-------
pybamm.Symbol
The regularised arcsinh(a/b) value
"""
# Convert scalars to pybamm types
a = pybamm.convert_to_symbol(a)
b = pybamm.convert_to_symbol(b)
return Arcsinh2(a, b, eps=eps)
[docs]
class Arctan(SpecificFunction):
"""Arctan function."""
def __init__(self, child):
super().__init__(np.arctan, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.arctan
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.arctan(child)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Function._function_diff()`."""
return 1 / (children[0] ** 2 + 1)
def _sympy_operator(self, child):
"""Override :meth:`pybamm.Function._sympy_operator`"""
return sympy.atan(child)
[docs]
def arctan(child: pybamm.Symbol):
"""Returns hyperbolic tan function of child."""
return simplified_function(Arctan, child)
[docs]
class Cos(SpecificFunction):
"""Cosine function."""
def __init__(self, child):
super().__init__(np.cos, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.cos
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.cos(child)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Symbol._function_diff()`."""
return -sin(children[0])
[docs]
def cos(child: pybamm.Symbol):
"""Returns cosine function of child."""
return simplified_function(Cos, child)
[docs]
class Cosh(SpecificFunction):
"""Hyberbolic cosine function."""
def __init__(self, child):
super().__init__(np.cosh, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.cosh
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.cosh(child)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Function._function_diff()`."""
return sinh(children[0])
[docs]
def cosh(child: pybamm.Symbol):
"""Returns hyperbolic cosine function of child."""
return simplified_function(Cosh, child)
[docs]
class Erf(SpecificFunction):
"""Error function."""
def __init__(self, child):
super().__init__(special.erf, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = special.erf
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.erf(child)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Function._function_diff()`."""
return 2 / np.sqrt(np.pi) * exp(-(children[0] ** 2))
[docs]
def erf(child: pybamm.Symbol):
"""Returns error function of child."""
return simplified_function(Erf, child)
[docs]
def erfc(child: pybamm.Symbol):
"""Returns complementary error function of child."""
return 1 - simplified_function(Erf, child)
[docs]
class Exp(SpecificFunction):
"""Exponential function."""
def __init__(self, child):
super().__init__(np.exp, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.exp
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.exp(child)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Function._function_diff()`."""
return exp(children[0])
[docs]
def exp(child: pybamm.Symbol):
"""Returns exponential function of child."""
return simplified_function(Exp, child)
[docs]
class Log(SpecificFunction):
"""Logarithmic function."""
def __init__(self, child):
super().__init__(np.log, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.log
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.log(child)
def _function_evaluate(self, evaluated_children):
# don't raise RuntimeWarning for NaNs
with np.errstate(invalid="ignore"):
return np.log(*evaluated_children)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Function._function_diff()`."""
return 1 / children[0]
[docs]
def log(child, base="e"):
"""Returns logarithmic function of child (any base, default 'e')."""
log_child = simplified_function(Log, child)
if base == "e":
return log_child
else:
return log_child / np.log(base)
[docs]
def log10(child: pybamm.Symbol):
"""Returns logarithmic function of child, with base 10."""
return log(child, base=10)
[docs]
class Max(SpecificFunction):
"""Max function."""
def __init__(self, child):
super().__init__(np.max, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.max
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.mmax(child)
def _evaluate_for_shape(self):
"""See :meth:`pybamm.Symbol.evaluate_for_shape_using_domain()`"""
# Max will always return a scalar
return np.nan * np.ones((1, 1))
[docs]
def max(child: pybamm.Symbol):
"""
Returns max function of child. Not to be confused with :meth:`pybamm.maximum`, which
returns the larger of two objects.
"""
return pybamm.simplify_if_constant(Max(child))
[docs]
class Min(SpecificFunction):
"""Min function."""
def __init__(self, child):
super().__init__(np.min, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.min
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.mmin(child)
def _evaluate_for_shape(self):
"""See :meth:`pybamm.Symbol.evaluate_for_shape_using_domain()`"""
# Min will always return a scalar
return np.nan * np.ones((1, 1))
[docs]
def min(child: pybamm.Symbol):
"""
Returns min function of child. Not to be confused with :meth:`pybamm.minimum`, which
returns the smaller of two objects.
"""
return pybamm.simplify_if_constant(Min(child))
[docs]
def sech(child: pybamm.Symbol):
"""Returns hyperbolic sec function of child."""
return 1 / simplified_function(Cosh, child)
[docs]
class Sin(SpecificFunction):
"""Sine function."""
def __init__(self, child):
super().__init__(np.sin, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.sin
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.sin(child)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Function._function_diff()`."""
return cos(children[0])
[docs]
def sin(child: pybamm.Symbol):
"""Returns sine function of child."""
return simplified_function(Sin, child)
[docs]
class Sinh(SpecificFunction):
"""Hyperbolic sine function."""
def __init__(self, child):
super().__init__(np.sinh, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.sinh
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.sinh(child)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Function._function_diff()`."""
return cosh(children[0])
[docs]
def sinh(child: pybamm.Symbol):
"""Returns hyperbolic sine function of child."""
return simplified_function(Sinh, child)
[docs]
class Sqrt(SpecificFunction):
"""Square root function."""
def __init__(self, child):
super().__init__(np.sqrt, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.sqrt
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.sqrt(child)
def _function_evaluate(self, evaluated_children):
# don't raise RuntimeWarning for NaNs
with np.errstate(invalid="ignore"):
return np.sqrt(*evaluated_children)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Function._function_diff()`."""
return 1 / (2 * sqrt(children[0]))
[docs]
def sqrt(child: pybamm.Symbol):
"""Returns square root function of child."""
return simplified_function(Sqrt, child)
class RegPower(Function):
"""
Regularised power: |x|^a * sign(x) with finite derivative at x=0.
Approximates |x|^a * sign(x) using:
y = x * (x^2 + delta^2)^((a-1)/2)
When scale is set:
y = (x/scale) * ((x/scale)^2 + delta^2)^((a-1)/2) * scale^a
Behavior:
- For |x| >> delta: returns |x|^a * sign(x)
- For |x| << delta: returns x * delta^(a-1) (linear)
- Smooth transition in between
This is an anti-symmetric function: RegPower(-x, a) = -RegPower(x, a)
Parameters
----------
base : :class:`pybamm.Symbol`
Base expression (x)
exponent : :class:`pybamm.Symbol` or float
Exponent expression (a)
scale : :class:`pybamm.Symbol` or float, optional
Scale factor for the input. Defaults to 1 (no scaling).
References
----------
.. [1] Modelica.Fluid.Utilities.regPow
"""
def __init__(
self,
base: pybamm.Symbol | float,
exponent: pybamm.Symbol | float,
scale: pybamm.Symbol | float | None = None,
delta: float | None = None,
):
# Convert to symbols
base = pybamm.convert_to_symbol(base)
exponent = pybamm.convert_to_symbol(exponent)
if scale is None:
scale = pybamm.Scalar(1)
else:
scale = pybamm.convert_to_symbol(scale)
if delta is None:
delta = pybamm.settings.tolerances["reg_power"]
self.delta = delta
super().__init__(
self._reg_power_evaluate, base, exponent, scale, name="reg_power"
)
def _reg_power_evaluate(self, base, exponent, scale):
"""Evaluate reg_power using numpy."""
x = base / scale
x2_d2 = x**2 + self.delta**2
return x * (x2_d2 ** ((exponent - 1) / 2)) * (scale**exponent)
def _function_evaluate(self, evaluated_children):
"""See :meth:`pybamm.Function._function_evaluate()`."""
return self._reg_power_evaluate(*evaluated_children)
def _function_diff(self, children, idx):
"""
Derivative with respect to child number 'idx'.
Children are: [base, exponent, scale]
"""
base, exponent, scale = children
delta = self.delta
x = base / scale
x2_d2 = x**2 + delta**2
scale_factor = scale**exponent
if idx == 0:
# Derivative w.r.t. base
# d/dx [x * (x^2 + d^2)^((a-1)/2)] = (x^2 + d^2)^((a-3)/2) * (a*x^2 + d^2)
dreg_dx = (x2_d2 ** ((exponent - 3) / 2)) * (exponent * x**2 + delta**2)
# Chain rule: d/d(base) = dreg_dx * (1/scale) * scale^a = dreg_dx * scale^(a-1)
return dreg_dx * (scale ** (exponent - 1))
elif idx == 1:
# Derivative w.r.t. exponent
reg_val = x * (x2_d2 ** ((exponent - 1) / 2)) * scale_factor
# d/da = reg_val * (log(x^2 + d^2) / 2 + log(scale))
return reg_val * (pybamm.log(x2_d2) / 2 + pybamm.log(scale))
elif idx == 2:
# Derivative w.r.t. scale
# y = x * (x^2 + d^2)^((a-1)/2) * scale^a, where x = base/scale
# Using quotient rule and chain rule:
# dy/dscale = -base/scale^2 * (x^2+d^2)^((a-3)/2) * (a*x^2 + d^2) * scale^a
# + x * (x^2+d^2)^((a-1)/2) * a * scale^(a-1)
# Simplified:
reg_val = x * (x2_d2 ** ((exponent - 1) / 2)) * scale_factor
dreg_dx = (x2_d2 ** ((exponent - 3) / 2)) * (exponent * x**2 + delta**2)
# dy/dscale = reg_val * a / scale - dreg_dx * x * scale^(a-1)
# = reg_val * a / scale - dreg_dx * base / scale * scale^(a-1) / scale
# = (reg_val * a - base * dreg_dx * scale^(a-1)) / scale
return (
reg_val * exponent - base * dreg_dx * (scale ** (exponent - 1))
) / scale
else:
raise IndexError("RegPower has three children (base, exponent, scale)")
def _function_jac(self, children_jacs):
"""See :meth:`pybamm.Function._function_jac()`."""
children = self.orphans
base, exponent, scale = children
delta = self.delta
x = base / scale
x2_d2 = x**2 + delta**2
scale_factor = scale**exponent
jacobian = None
# Base Jacobian
if not base.evaluates_to_constant_number():
dreg_dx = (x2_d2 ** ((exponent - 3) / 2)) * (exponent * x**2 + delta**2)
dreg_dbase = dreg_dx * (scale ** (exponent - 1))
jac_term = dreg_dbase * children_jacs[0]
jac_term.clear_domains()
jacobian = jac_term
# Exponent Jacobian
if not exponent.evaluates_to_constant_number():
reg_val = x * (x2_d2 ** ((exponent - 1) / 2)) * scale_factor
dreg_da = reg_val * (pybamm.log(x2_d2) / 2 + pybamm.log(scale))
jac_term = dreg_da * children_jacs[1]
jac_term.clear_domains()
if jacobian is None:
jacobian = jac_term
else:
jacobian += jac_term
# Scale Jacobian
if not scale.evaluates_to_constant_number():
reg_val = x * (x2_d2 ** ((exponent - 1) / 2)) * scale_factor
dreg_dx = (x2_d2 ** ((exponent - 3) / 2)) * (exponent * x**2 + delta**2)
dreg_dscale = (
reg_val * exponent - base * dreg_dx * (scale ** (exponent - 1))
) / scale
jac_term = dreg_dscale * children_jacs[2]
jac_term.clear_domains()
if jacobian is None:
jacobian = jac_term
else:
jacobian += jac_term
if jacobian is None:
jacobian = pybamm.Scalar(0)
return jacobian
def _function_new_copy(self, children):
"""See :meth:`pybamm.Function._function_new_copy()`"""
base, exponent, scale = children
return pybamm.simplify_if_constant(
RegPower(base, exponent, scale=scale, delta=self.delta)
)
def _casadi_evaluate(self, base, exponent, scale):
"""See :meth:`pybamm.Function._casadi_evaluate()`.
Evaluates ``y = x * r^(a-1) * scale^a`` with ``x = base / scale`` and
``r = hypot(x, delta)``. The ``a == 0.5`` case (hot for Butler-Volmer)
is specialised to avoid a general ``power`` call.
"""
delta = self.delta
if delta == 0:
return casadi.power(base, exponent)
x = base / scale
r = casadi.hypot(x, delta)
if _is_constant_value(exponent, 0.5):
# ``exponent**0`` acts as a ``ones_like(exponent)`` that works
# across casadi.MX / numpy / float.
scale_factor = casadi.sqrt(scale) * exponent**0
inner_factor = 1 / casadi.sqrt(r)
else:
scale_factor = casadi.power(scale, exponent)
inner_factor = casadi.power(r, exponent - 1)
return x * inner_factor * scale_factor
def _sympy_operator(self, base, exponent, scale):
"""Convert to SymPy expression."""
x = base / scale
x2_d2 = x**2 + self.delta**2
return x * sympy.Pow(x2_d2, (exponent - 1) / 2) * sympy.Pow(scale, exponent)
def __str__(self):
"""See :meth:`pybamm.Symbol.__str__()`."""
base, exponent, scale = self.children
return f"reg_power({base!s}, {exponent!s}, scale={scale!s})"
def to_json(self):
"""Method to serialise a RegPower object into JSON."""
return {
"name": self.name,
"id": self.id,
"function": "reg_power",
"delta": self.delta,
}
@classmethod
def _from_json(cls, snippet: dict):
"""Reconstruct a RegPower instance from JSON."""
instance = cls.__new__(cls)
instance.delta = snippet.get("delta", pybamm.settings.tolerances["reg_power"])
super(RegPower, instance).__init__(
instance._reg_power_evaluate,
*snippet["children"],
name="reg_power",
)
return instance
def reg_power(
base: pybamm.Symbol | float,
exponent: pybamm.Symbol | float,
scale: pybamm.Symbol | float | None = None,
) -> pybamm.Symbol:
"""
Regularised power: |x|^a * sign(x) with finite derivative at x=0.
Convenience function that creates a :class:`RegPower` node.
Parameters
----------
base : :class:`pybamm.Symbol` or float
Input expression (x)
exponent : float
Power exponent (must be > 0)
scale : float, optional
Scale factor for the input. Defaults to 1 (no scaling).
Returns
-------
:class:`RegPower`
Regularised power node
References
----------
.. [1] Modelica.Fluid.Utilities.regPow
"""
return pybamm.simplify_if_constant(RegPower(base, exponent, scale=scale))
[docs]
class Tanh(SpecificFunction):
"""Hyperbolic tan function."""
def __init__(self, child):
super().__init__(np.tanh, child)
@classmethod
def _from_json(cls, snippet: dict):
"""See :meth:`pybamm.SpecificFunction._from_json()`."""
snippet["function"] = np.tanh
instance = super()._from_json(snippet)
return instance
def _casadi_evaluate(self, child):
"""See :meth:`pybamm.SpecificFunction._casadi_evaluate()`."""
return casadi.tanh(child)
def _function_diff(self, children, idx):
"""See :meth:`pybamm.Function._function_diff()`."""
return sech(children[0]) ** 2
[docs]
def tanh(child: pybamm.Symbol):
"""Returns hyperbolic tan function of child."""
return simplified_function(Tanh, child)
[docs]
def normal_pdf(
x: pybamm.Symbol, mu: pybamm.Symbol | float, sigma: pybamm.Symbol | float
):
"""
Returns the normal probability density function at x.
Parameters
----------
x : pybamm.Symbol
The value at which to evaluate the normal distribution
mu : pybamm.Symbol or float
The mean of the normal distribution
sigma : pybamm.Symbol or float
The standard deviation of the normal distribution
Returns
-------
pybamm.Symbol
The value of the normal distribution at x
"""
return 1 / (np.sqrt(2 * np.pi) * sigma) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
[docs]
def normal_cdf(
x: pybamm.Symbol, mu: pybamm.Symbol | float, sigma: pybamm.Symbol | float
):
"""
Returns the normal cumulative distribution function at x.
Parameters
----------
x : pybamm.Symbol
The value at which to evaluate the normal distribution
mu : pybamm.Symbol or float
The mean of the normal distribution
sigma : pybamm.Symbol or float
The standard deviation of the normal distribution
Returns
-------
pybamm.Symbol
The value of the normal distribution at x
"""
return 0.5 * (1 + special.erf((x - mu) / (sigma * np.sqrt(2))))