Source code for pybamm.spatial_methods.finite_volume

#
# Finite Volume discretisation class
#
import pybamm

from scipy.sparse import (
    diags,
    spdiags,
    eye,
    kron,
    csr_matrix,
    vstack,
    hstack,
    lil_matrix,
    coo_matrix,
)
import numpy as np


[docs] class FiniteVolume(pybamm.SpatialMethod): """ A class which implements the steps specific to the finite volume method during discretisation. For broadcast and mass_matrix, we follow the default behaviour from SpatialMethod. Parameters ---------- mesh : :class:`pybamm.Mesh` Contains all the submeshes for discretisation """ def __init__(self, options=None): super().__init__(options) def build(self, mesh): super().build(mesh) # add npts_for_broadcast to mesh domains for this particular discretisation for dom in mesh.keys(): mesh[dom].npts_for_broadcast_to_nodes = mesh[dom].npts
[docs] def spatial_variable(self, symbol): """ Creates a discretised spatial variable compatible with the FiniteVolume method. Parameters ---------- symbol : :class:`pybamm.SpatialVariable` The spatial variable to be discretised. Returns ------- :class:`pybamm.Vector` Contains the discretised spatial variable """ symbol_mesh = self.mesh[symbol.domain] repeats = self._get_auxiliary_domain_repeats(symbol.domains) if symbol.evaluates_on_edges("primary"): entries = np.tile(symbol_mesh.edges, repeats) else: entries = np.tile(symbol_mesh.nodes, repeats) return pybamm.Vector(entries, domains=symbol.domains)
[docs] def gradient(self, symbol, discretised_symbol, boundary_conditions): """Matrix-vector multiplication to implement the gradient operator. See :meth:`pybamm.SpatialMethod.gradient` """ # Discretise symbol domain = symbol.domain # Add Dirichlet boundary conditions, if defined if symbol in boundary_conditions: bcs = boundary_conditions[symbol] if any(bc[1] == "Dirichlet" for bc in bcs.values()): # add ghost nodes and update domain discretised_symbol, domain = self.add_ghost_nodes( symbol, discretised_symbol, bcs ) # note in 1D cartesian, cylindrical and spherical grad are the same gradient_matrix = self.gradient_matrix(domain, symbol.domains) # Multiply by gradient matrix out = gradient_matrix @ discretised_symbol # Add Neumann boundary conditions, if defined if symbol in boundary_conditions: bcs = boundary_conditions[symbol] if any(bc[1] == "Neumann" for bc in bcs.values()): out = self.add_neumann_values(symbol, out, bcs, domain) return out
[docs] def gradient_matrix(self, domain, domains): """ Gradient matrix for finite volumes in the appropriate domain. Equivalent to grad(y) = (y[1:] - y[:-1])/dx Parameters ---------- domains : list The domain in which to compute the gradient matrix, including ghost nodes Returns ------- :class:`pybamm.Matrix` The (sparse) finite volume gradient matrix for the domain """ # Create appropriate submesh by combining submeshes in primary domain submesh = self.mesh[domain] # Create 1D matrix using submesh n = submesh.npts e = 1 / submesh.d_nodes sub_matrix = diags([-e, e], [0, 1], shape=(n - 1, n)) # number of repeats second_dim_repeats = self._get_auxiliary_domain_repeats(domains) # generate full matrix from the submatrix # Convert to csr_matrix so that we can take the index (row-slicing), which is # not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue matrix = csr_matrix(kron(eye(second_dim_repeats), sub_matrix)) return pybamm.Matrix(matrix)
[docs] def divergence(self, symbol, discretised_symbol, boundary_conditions): """Matrix-vector multiplication to implement the divergence operator. See :meth:`pybamm.SpatialMethod.divergence` """ submesh = self.mesh[symbol.domain] divergence_matrix = self.divergence_matrix(symbol.domains) # check coordinate system if submesh.coord_sys in ["cylindrical polar", "spherical polar"]: second_dim_repeats = self._get_auxiliary_domain_repeats(symbol.domains) # create np.array of repeated submesh.edges r_edges_numpy = np.kron(np.ones(second_dim_repeats), submesh.edges) r_edges = pybamm.Vector(r_edges_numpy) if submesh.coord_sys == "spherical polar": out = divergence_matrix @ ((r_edges**2) * discretised_symbol) elif submesh.coord_sys == "cylindrical polar": out = divergence_matrix @ (r_edges * discretised_symbol) else: out = divergence_matrix @ discretised_symbol return out
[docs] def divergence_matrix(self, domains): """ Divergence matrix for finite volumes in the appropriate domain. Equivalent to div(N) = (N[1:] - N[:-1])/dx Parameters ---------- domains : dict The domain(s) and auxiliary domain in which to compute the divergence matrix Returns ------- :class:`pybamm.Matrix` The (sparse) finite volume divergence matrix for the domain """ # Create appropriate submesh by combining submeshes in domain submesh = self.mesh[domains["primary"]] # check coordinate system if submesh.coord_sys in ["cylindrical polar", "spherical polar"]: r_edges_left = submesh.edges[:-1] r_edges_right = submesh.edges[1:] if submesh.coord_sys == "spherical polar": d_edges = (r_edges_right**3 - r_edges_left**3) / 3 elif submesh.coord_sys == "cylindrical polar": d_edges = (r_edges_right**2 - r_edges_left**2) / 2 else: d_edges = submesh.d_edges e = 1 / d_edges # Create matrix using submesh n = submesh.npts + 1 sub_matrix = diags([-e, e], [0, 1], shape=(n - 1, n)) # repeat matrix for each node in secondary dimensions second_dim_repeats = self._get_auxiliary_domain_repeats(domains) # generate full matrix from the submatrix # Convert to csr_matrix so that we can take the index (row-slicing), which is # not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue matrix = csr_matrix(kron(eye(second_dim_repeats), sub_matrix)) return pybamm.Matrix(matrix)
[docs] def laplacian(self, symbol, discretised_symbol, boundary_conditions): """ Laplacian operator, implemented as div(grad(.)) See :meth:`pybamm.SpatialMethod.laplacian` """ grad = self.gradient(symbol, discretised_symbol, boundary_conditions) return self.divergence(grad, grad, boundary_conditions)
[docs] def integral(self, child, discretised_child, integration_dimension): """Vector-vector dot product to implement the integral operator.""" integration_vector = self.definite_integral_matrix( child, integration_dimension=integration_dimension ) out = integration_vector @ discretised_child return out
[docs] def definite_integral_matrix( self, child, vector_type="row", integration_dimension="primary" ): """ Matrix for finite-volume implementation of the definite integral in the primary dimension .. math:: I = \\int_{a}^{b}\\!f(s)\\,ds for where :math:`a` and :math:`b` are the left-hand and right-hand boundaries of the domain respectively Parameters ---------- child : :class:`pybamm.Symbol` The symbol being integrated vector_type : str, optional Whether to return a row or column vector in the primary dimension (default is row) integration_dimension : str, optional The dimension in which to integrate (default is "primary") Returns ------- :class:`pybamm.Matrix` The finite volume integral matrix for the domain """ domains = child.domains if vector_type != "row" and integration_dimension == "secondary": raise NotImplementedError( "Integral in secondary vector only implemented in 'row' form" ) domain = child.domains[integration_dimension] submesh = self.mesh[domain] # check coordinate system if submesh.coord_sys in ["cylindrical polar", "spherical polar"]: r_edges_left = submesh.edges[:-1] r_edges_right = submesh.edges[1:] if submesh.coord_sys == "spherical polar": d_edges = 4 * np.pi * (r_edges_right**3 - r_edges_left**3) / 3 elif submesh.coord_sys == "cylindrical polar": d_edges = 2 * np.pi * (r_edges_right**2 - r_edges_left**2) / 2 else: d_edges = submesh.d_edges if integration_dimension == "primary": # Create appropriate submesh by combining submeshes in domain submesh = self.mesh[domains["primary"]] # Create vector of ones for primary domain submesh if vector_type == "row": d_edges = d_edges[np.newaxis, :] elif vector_type == "column": d_edges = d_edges[:, np.newaxis] # repeat matrix for each node in secondary dimensions second_dim_repeats = self._get_auxiliary_domain_repeats(domains) # generate full matrix from the submatrix matrix = kron(eye(second_dim_repeats), d_edges) elif integration_dimension == "secondary": # Create appropriate submesh by combining submeshes in domain primary_submesh = self.mesh[domains["primary"]] # Create matrix which integrates in the secondary dimension # Different number of edges depending on whether child evaluates on edges # in the primary dimensions if child.evaluates_on_edges("primary"): n_primary_pts = primary_submesh.npts + 1 else: n_primary_pts = primary_submesh.npts int_matrix = hstack([d_edge * eye(n_primary_pts) for d_edge in d_edges]) # repeat matrix for each node in higher dimensions third_dim_repeats = self._get_auxiliary_domain_repeats( { k: v for k, v in domains.items() if (k == "tertiary" or k == "quaternary") } ) # generate full matrix from the submatrix matrix = kron(eye(third_dim_repeats), int_matrix) # generate full matrix from the submatrix # Convert to csr_matrix so that we can take the index (row-slicing), which is # not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue return pybamm.Matrix(csr_matrix(matrix))
[docs] def indefinite_integral(self, child, discretised_child, direction): """Implementation of the indefinite integral operator.""" # Different integral matrix depending on whether the integrand evaluates on # edges or nodes if child.evaluates_on_edges("primary"): integration_matrix = self.indefinite_integral_matrix_edges( child.domains, direction ) else: # Check coordinate system is not cylindrical or spherical polar for # the case where child evaluates on edges # If it becomes necessary to implement this, will need to think about what # the cylindrical/spherical polar indefinite integral should be submesh = self.mesh[child.domain] if submesh.coord_sys in ["cylindrical polar", "spherical polar"]: raise NotImplementedError( f"Indefinite integral on a {submesh.coord_sys} domain is not " "implemented" ) integration_matrix = self.indefinite_integral_matrix_nodes( child.domains, direction ) # Don't need to check for cylindrical/spherical domains as we have ruled # these out in the case that involves integrating a divergence # (child evaluates on nodes) out = integration_matrix @ discretised_child out.copy_domains(child) return out
[docs] def indefinite_integral_matrix_edges(self, domains, direction): """ Matrix for finite-volume implementation of the indefinite integral where the integrand is evaluated on mesh edges (shape (n+1, 1)). The integral will then be evaluated on mesh nodes (shape (n, 1)). Parameters ---------- domains : dict The domain(s) and auxiliary domains of integration direction : str The direction of integration (forward or backward). See notes. Returns ------- :class:`pybamm.Matrix` The finite volume integral matrix for the domain Notes ----- **Forward integral** .. math:: F(x) = \\int_0^x\\!f(u)\\,du The indefinite integral must satisfy the following conditions: - :math:`F(0) = 0` - :math:`f(x) = \\frac{dF}{dx}` or, in discrete form, - `BoundaryValue(F, "left") = 0`, i.e. :math:`3*F_0 - F_1 = 0` - :math:`f_{i+1/2} = (F_{i+1} - F_i) / dx_{i+1/2}` Hence we must have - :math:`F_0 = du_{1/2} * f_{1/2} / 2` - :math:`F_{i+1} = F_i + du_{i+1/2} * f_{i+1/2}` Note that :math:`f_{-1/2}` and :math:`f_{end+1/2}` are included in the discrete integrand vector `f`, so we add a column of zeros at each end of the indefinite integral matrix to ignore these. **Backward integral** .. math:: F(x) = \\int_x^{end}\\!f(u)\\,du The indefinite integral must satisfy the following conditions: - :math:`F(end) = 0` - :math:`f(x) = -\\frac{dF}{dx}` or, in discrete form, - `BoundaryValue(F, "right") = 0`, i.e. :math:`3*F_{end} - F_{end-1} = 0` - :math:`f_{i+1/2} = -(F_{i+1} - F_i) / dx_{i+1/2}` Hence we must have - :math:`F_{end} = du_{end+1/2} * f_{end-1/2} / 2` - :math:`F_{i-1} = F_i + du_{i-1/2} * f_{i-1/2}` Note that :math:`f_{-1/2}` and :math:`f_{end+1/2}` are included in the discrete integrand vector `f`, so we add a column of zeros at each end of the indefinite integral matrix to ignore these. """ # Create appropriate submesh by combining submeshes in domain submesh = self.mesh[domains["primary"]] n = submesh.npts second_dim_repeats = self._get_auxiliary_domain_repeats(domains) du_n = submesh.d_nodes if direction == "forward": du_entries = [du_n] * (n - 1) offset = -np.arange(1, n, 1) main_integral_matrix = spdiags(du_entries, offset, n, n - 1) bc_offset_matrix = lil_matrix((n, n - 1)) bc_offset_matrix[:, 0] = du_n[0] / 2 elif direction == "backward": du_entries = [du_n] * (n + 1) offset = np.arange(n, -1, -1) main_integral_matrix = spdiags(du_entries, offset, n, n - 1) bc_offset_matrix = lil_matrix((n, n - 1)) bc_offset_matrix[:, -1] = du_n[-1] / 2 sub_matrix = main_integral_matrix + bc_offset_matrix # add a column of zeros at each end zero_col = csr_matrix((n, 1)) sub_matrix = hstack([zero_col, sub_matrix, zero_col]) # Convert to csr_matrix so that we can take the index (row-slicing), which is # not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue matrix = csr_matrix(kron(eye(second_dim_repeats), sub_matrix)) return pybamm.Matrix(matrix)
[docs] def indefinite_integral_matrix_nodes(self, domains, direction): """ Matrix for finite-volume implementation of the (backward) indefinite integral where the integrand is evaluated on mesh nodes (shape (n, 1)). The integral will then be evaluated on mesh edges (shape (n+1, 1)). This is just a straightforward (backward) cumulative sum of the integrand Parameters ---------- domains : dict The domain(s) and auxiliary domains of integration direction : str The direction of integration (forward or backward) Returns ------- :class:`pybamm.Matrix` The finite volume integral matrix for the domain """ # Create appropriate submesh by combining submeshes in domain submesh = self.mesh[domains["primary"]] n = submesh.npts second_dim_repeats = self._get_auxiliary_domain_repeats(domains) du_n = submesh.d_edges du_entries = [du_n] * n if direction == "forward": offset = -np.arange(1, n + 1, 1) # from -1 down to -n elif direction == "backward": offset = np.arange(n - 1, -1, -1) # from n-1 down to 0 sub_matrix = spdiags(du_entries, offset, n + 1, n) # Convert to csr_matrix so that we can take the index (row-slicing), which is # not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue matrix = csr_matrix(kron(eye(second_dim_repeats), sub_matrix)) return pybamm.Matrix(matrix)
[docs] def delta_function(self, symbol, discretised_symbol): """ Delta function. Implemented as a vector whose only non-zero element is the first (if symbol.side = "left") or last (if symbol.side = "right"), with appropriate value so that the integral of the delta function across the whole domain is the same as the integral of the discretised symbol across the whole domain. See :meth:`pybamm.SpatialMethod.delta_function` """ # Find the number of submeshes submesh = self.mesh[symbol.domain] prim_pts = submesh.npts second_dim_repeats = self._get_auxiliary_domain_repeats(symbol.domains) # Create submatrix to compute delta function as a flux if symbol.side == "left": dx = submesh.d_nodes[0] sub_matrix = csr_matrix(([1], ([0], [0])), shape=(prim_pts, 1)) elif symbol.side == "right": dx = submesh.d_nodes[-1] sub_matrix = csr_matrix(([1], ([prim_pts - 1], [0])), shape=(prim_pts, 1)) # Calculate domain width, to make sure that the integral of the delta function # is the same as the integral of the child domain_width = submesh.edges[-1] - submesh.edges[0] # Generate full matrix from the submatrix # Convert to csr_matrix so that we can take the index (row-slicing), which is # not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue matrix = kron(eye(second_dim_repeats), sub_matrix).toarray() # Return delta function, keep domains delta_fn = pybamm.Matrix(domain_width / dx * matrix) * discretised_symbol delta_fn.copy_domains(symbol) return delta_fn
[docs] def internal_neumann_condition( self, left_symbol_disc, right_symbol_disc, left_mesh, right_mesh ): """ A method to find the internal Neumann conditions between two symbols on adjacent subdomains. Parameters ---------- left_symbol_disc : :class:`pybamm.Symbol` The discretised symbol on the left subdomain right_symbol_disc : :class:`pybamm.Symbol` The discretised symbol on the right subdomain left_mesh : list The mesh on the left subdomain right_mesh : list The mesh on the right subdomain """ left_npts = left_mesh.npts right_npts = right_mesh.npts second_dim_repeats = self._get_auxiliary_domain_repeats( left_symbol_disc.domains ) if second_dim_repeats != self._get_auxiliary_domain_repeats( right_symbol_disc.domains ): raise pybamm.DomainError( """Number of secondary points in subdomains do not match""" ) left_sub_matrix = np.zeros((1, left_npts)) left_sub_matrix[0][left_npts - 1] = 1 left_matrix = pybamm.Matrix( csr_matrix(kron(eye(second_dim_repeats), left_sub_matrix)) ) right_sub_matrix = np.zeros((1, right_npts)) right_sub_matrix[0][0] = 1 right_matrix = pybamm.Matrix( csr_matrix(kron(eye(second_dim_repeats), right_sub_matrix)) ) # Finite volume derivative # Remove domains to avoid clash dx = right_mesh.nodes[0] - left_mesh.nodes[-1] dy_r = (right_matrix / dx) @ right_symbol_disc dy_r.clear_domains() dy_l = (left_matrix / dx) @ left_symbol_disc dy_l.clear_domains() return dy_r - dy_l
[docs] def add_ghost_nodes(self, symbol, discretised_symbol, bcs): """ Add ghost nodes to a symbol. For Dirichlet bcs, for a boundary condition "y = a at the left-hand boundary", we concatenate a ghost node to the start of the vector y with value "2*a - y1" where y1 is the value of the first node. Similarly for the right-hand boundary condition. For Neumann bcs no ghost nodes are added. Instead, the exact value provided by the boundary condition is used at the cell edge when calculating the gradient (see :meth:`pybamm.FiniteVolume.add_neumann_values`). Parameters ---------- symbol : :class:`pybamm.SpatialVariable` The variable to be discretised discretised_symbol : :class:`pybamm.Vector` Contains the discretised variable bcs : dict of tuples (:class:`pybamm.Scalar`, str) Dictionary (with keys "left" and "right") of boundary conditions. Each boundary condition consists of a value and a flag indicating its type (e.g. "Dirichlet") Returns ------- :class:`pybamm.Symbol` `Matrix @ discretised_symbol + bcs_vector`. When evaluated, this gives the discretised_symbol, with appropriate ghost nodes concatenated at each end. """ # get relevant grid points domain = symbol.domain submesh = self.mesh[domain] # Prepare sizes and empty bcs_vector n = submesh.npts second_dim_repeats = self._get_auxiliary_domain_repeats(symbol.domains) # Catch if no boundary conditions are defined if "left" not in bcs.keys() and "right" not in bcs.keys(): raise ValueError(f"No boundary conditions have been provided for {symbol}") # Allow to only pass one boundary condition (for upwind/downwind) lbc_value, lbc_type = bcs.get("left", (None, None)) rbc_value, rbc_type = bcs.get("right", (None, None)) # Add ghost node(s) to domain where necessary and count number of # Dirichlet boundary conditions n_bcs = 0 if lbc_type == "Dirichlet": domain = [domain[0] + "_left ghost cell", *domain] n_bcs += 1 if rbc_type == "Dirichlet": domain = [*domain, domain[-1] + "_right ghost cell"] n_bcs += 1 # Calculate values for ghost nodes for any Dirichlet boundary conditions if lbc_type == "Dirichlet": lbc_sub_matrix = coo_matrix(([1], ([0], [0])), shape=(n + n_bcs, 1)) lbc_matrix = csr_matrix(kron(eye(second_dim_repeats), lbc_sub_matrix)) if lbc_value.evaluates_to_number(): left_ghost_constant = ( 2 * lbc_value * pybamm.Vector(np.ones(second_dim_repeats)) ) else: left_ghost_constant = 2 * lbc_value lbc_vector = pybamm.Matrix(lbc_matrix) @ left_ghost_constant elif lbc_type in ["Neumann", None]: lbc_vector = pybamm.Vector(np.zeros((n + n_bcs) * second_dim_repeats)) else: raise ValueError( f"boundary condition must be Dirichlet or Neumann, not '{lbc_type}'" ) if rbc_type == "Dirichlet": rbc_sub_matrix = coo_matrix( ([1], ([n + n_bcs - 1], [0])), shape=(n + n_bcs, 1) ) rbc_matrix = csr_matrix(kron(eye(second_dim_repeats), rbc_sub_matrix)) if rbc_value.evaluates_to_number(): right_ghost_constant = ( 2 * rbc_value * pybamm.Vector(np.ones(second_dim_repeats)) ) else: right_ghost_constant = 2 * rbc_value rbc_vector = pybamm.Matrix(rbc_matrix) @ right_ghost_constant elif rbc_type in ["Neumann", None]: rbc_vector = pybamm.Vector(np.zeros((n + n_bcs) * second_dim_repeats)) else: raise ValueError( f"boundary condition must be Dirichlet or Neumann, not '{rbc_type}'" ) bcs_vector = lbc_vector + rbc_vector # Need to match the domain. E.g. in the case of the boundary condition # on the particle, the gradient has domain particle but the bcs_vector # has domain electrode, since it is a function of the macroscopic variables bcs_vector.copy_domains(discretised_symbol) # Make matrix to calculate ghost nodes # coo_matrix takes inputs (data, (row, col)) and puts data[i] at the point # (row[i], col[i]) for each index of data. if lbc_type == "Dirichlet": left_ghost_vector = coo_matrix(([-1], ([0], [0])), shape=(1, n)) else: left_ghost_vector = None if rbc_type == "Dirichlet": right_ghost_vector = coo_matrix(([-1], ([0], [n - 1])), shape=(1, n)) else: right_ghost_vector = None sub_matrix = vstack([left_ghost_vector, eye(n), right_ghost_vector]) # repeat matrix for secondary dimensions # Convert to csr_matrix so that we can take the index (row-slicing), which is # not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue matrix = csr_matrix(kron(eye(second_dim_repeats), sub_matrix)) new_symbol = pybamm.Matrix(matrix) @ discretised_symbol + bcs_vector return new_symbol, domain
[docs] def add_neumann_values(self, symbol, discretised_gradient, bcs, domain): """ Add the known values of the gradient from Neumann boundary conditions to the discretised gradient. Dirichlet bcs are implemented using ghost nodes, see :meth:`pybamm.FiniteVolume.add_ghost_nodes`. Parameters ---------- symbol : :class:`pybamm.SpatialVariable` The variable to be discretised discretised_gradient : :class:`pybamm.Vector` Contains the discretised gradient of symbol bcs : dict of tuples (:class:`pybamm.Scalar`, str) Dictionary (with keys "left" and "right") of boundary conditions. Each boundary condition consists of a value and a flag indicating its type (e.g. "Dirichlet") domain : list of strings The domain of the gradient of the symbol (may include ghost nodes) Returns ------- :class:`pybamm.Symbol` `Matrix @ discretised_gradient + bcs_vector`. When evaluated, this gives the discretised_gradient, with the values of the Neumann boundary conditions concatenated at each end (if given). """ # get relevant grid points submesh = self.mesh[domain] # Prepare sizes and empty bcs_vector n = submesh.npts - 1 second_dim_repeats = self._get_auxiliary_domain_repeats(symbol.domains) lbc_value, lbc_type = bcs["left"] rbc_value, rbc_type = bcs["right"] # Count number of Neumann boundary conditions n_bcs = 0 if lbc_type == "Neumann": n_bcs += 1 if rbc_type == "Neumann": n_bcs += 1 # Add any values from Neumann boundary conditions to the bcs vector if lbc_type == "Neumann" and lbc_value != 0: lbc_sub_matrix = coo_matrix(([1], ([0], [0])), shape=(n + n_bcs, 1)) lbc_matrix = csr_matrix(kron(eye(second_dim_repeats), lbc_sub_matrix)) if lbc_value.evaluates_to_number(): left_bc = lbc_value * pybamm.Vector(np.ones(second_dim_repeats)) else: left_bc = lbc_value lbc_vector = pybamm.Matrix(lbc_matrix) @ left_bc elif lbc_type == "Dirichlet" or (lbc_type == "Neumann" and lbc_value == 0): lbc_vector = pybamm.Vector(np.zeros((n + n_bcs) * second_dim_repeats)) else: raise ValueError( f"boundary condition must be Dirichlet or Neumann, not '{rbc_type}'" ) if rbc_type == "Neumann" and rbc_value != 0: rbc_sub_matrix = coo_matrix( ([1], ([n + n_bcs - 1], [0])), shape=(n + n_bcs, 1) ) rbc_matrix = csr_matrix(kron(eye(second_dim_repeats), rbc_sub_matrix)) if rbc_value.evaluates_to_number(): right_bc = rbc_value * pybamm.Vector(np.ones(second_dim_repeats)) else: right_bc = rbc_value rbc_vector = pybamm.Matrix(rbc_matrix) @ right_bc elif rbc_type == "Dirichlet" or (rbc_type == "Neumann" and rbc_value == 0): rbc_vector = pybamm.Vector(np.zeros((n + n_bcs) * second_dim_repeats)) else: raise ValueError( f"boundary condition must be Dirichlet or Neumann, not '{rbc_type}'" ) bcs_vector = lbc_vector + rbc_vector # Need to match the domain. E.g. in the case of the boundary condition # on the particle, the gradient has domain particle but the bcs_vector # has domain electrode, since it is a function of the macroscopic variables bcs_vector.copy_domains(discretised_gradient) # Make matrix which makes "gaps" in the the discretised gradient into # which the known Neumann values will be added. E.g. in 1D if the left # boundary condition is Dirichlet and the right Neumann, this matrix will # act to append a zero to the end of the discretised gradient if lbc_type == "Neumann": left_vector = csr_matrix((1, n)) else: left_vector = None if rbc_type == "Neumann": right_vector = csr_matrix((1, n)) else: right_vector = None sub_matrix = vstack([left_vector, eye(n), right_vector]) # repeat matrix for secondary dimensions # Convert to csr_matrix so that we can take the index (row-slicing), which is # not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue matrix = csr_matrix(kron(eye(second_dim_repeats), sub_matrix)) new_gradient = pybamm.Matrix(matrix) @ discretised_gradient + bcs_vector return new_gradient
[docs] def boundary_value_or_flux(self, symbol, discretised_child, bcs=None): """ Uses extrapolation to get the boundary value or flux of a variable in the Finite Volume Method. See :meth:`pybamm.SpatialMethod.boundary_value` """ # Find the number of submeshes submesh = self.mesh[discretised_child.domain] prim_pts = submesh.npts repeats = self._get_auxiliary_domain_repeats(discretised_child.domains) if bcs is None: bcs = {} extrap_order = self.options["extrapolation"]["order"] use_bcs = self.options["extrapolation"]["use bcs"] nodes = submesh.nodes edges = submesh.edges dx0 = nodes[0] - edges[0] dx1 = submesh.d_nodes[0] dx2 = submesh.d_nodes[1] dxN = edges[-1] - nodes[-1] dxNm1 = submesh.d_nodes[-1] dxNm2 = submesh.d_nodes[-2] child = symbol.child # Create submatrix to compute boundary values or fluxes # Derivation of extrapolation formula can be found at: # https://github.com/Scottmar93/extrapolation-coefficents/tree/master if isinstance(symbol, pybamm.BoundaryValue): if use_bcs and pybamm.has_bc_of_form(child, symbol.side, bcs, "Dirichlet"): # just use the value from the bc: f(x*) sub_matrix = csr_matrix((1, prim_pts)) additive = bcs[child][symbol.side][0] elif symbol.side == "left": if extrap_order == "linear": # to find value at x* use formula: # f(x*) = f_1 - (dx0 / dx1) (f_2 - f_1) if use_bcs and pybamm.has_bc_of_form( child, symbol.side, bcs, "Neumann" ): sub_matrix = csr_matrix(([1], ([0], [0])), shape=(1, prim_pts)) additive = -dx0 * bcs[child][symbol.side][0] else: sub_matrix = csr_matrix( ([1 + (dx0 / dx1), -(dx0 / dx1)], ([0, 0], [0, 1])), shape=(1, prim_pts), ) additive = pybamm.Scalar(0) elif extrap_order == "quadratic": if use_bcs and pybamm.has_bc_of_form( child, symbol.side, bcs, "Neumann" ): a = (dx0 + dx1) ** 2 / (dx1 * (2 * dx0 + dx1)) b = -(dx0**2) / (2 * dx0 * dx1 + dx1**2) alpha = -(dx0 * (dx0 + dx1)) / (2 * dx0 + dx1) sub_matrix = csr_matrix( ([a, b], ([0, 0], [0, 1])), shape=(1, prim_pts) ) additive = alpha * bcs[child][symbol.side][0] else: a = (dx0 + dx1) * (dx0 + dx1 + dx2) / (dx1 * (dx1 + dx2)) b = -dx0 * (dx0 + dx1 + dx2) / (dx1 * dx2) c = dx0 * (dx0 + dx1) / (dx2 * (dx1 + dx2)) sub_matrix = csr_matrix( ([a, b, c], ([0, 0, 0], [0, 1, 2])), shape=(1, prim_pts) ) additive = pybamm.Scalar(0) else: raise NotImplementedError elif symbol.side == "right": if extrap_order == "linear": if use_bcs and pybamm.has_bc_of_form( child, symbol.side, bcs, "Neumann" ): # use formula: # f(x*) = fN + dxN * f'(x*) sub_matrix = csr_matrix( ([1], ([0], [prim_pts - 1])), shape=(1, prim_pts) ) additive = dxN * bcs[child][symbol.side][0] else: # to find value at x* use formula: # f(x*) = f_N - (dxN / dxNm1) (f_N - f_Nm1) sub_matrix = csr_matrix( ( [-(dxN / dxNm1), 1 + (dxN / dxNm1)], ([0, 0], [prim_pts - 2, prim_pts - 1]), ), shape=(1, prim_pts), ) additive = pybamm.Scalar(0) elif extrap_order == "quadratic": if use_bcs and pybamm.has_bc_of_form( child, symbol.side, bcs, "Neumann" ): a = (dxN + dxNm1) ** 2 / (dxNm1 * (2 * dxN + dxNm1)) b = -(dxN**2) / (2 * dxN * dxNm1 + dxNm1**2) alpha = dxN * (dxN + dxNm1) / (2 * dxN + dxNm1) sub_matrix = csr_matrix( ([b, a], ([0, 0], [prim_pts - 2, prim_pts - 1])), shape=(1, prim_pts), ) additive = alpha * bcs[child][symbol.side][0] else: a = ( (dxN + dxNm1) * (dxN + dxNm1 + dxNm2) / (dxNm1 * (dxNm1 + dxNm2)) ) b = -dxN * (dxN + dxNm1 + dxNm2) / (dxNm1 * dxNm2) c = dxN * (dxN + dxNm1) / (dxNm2 * (dxNm1 + dxNm2)) sub_matrix = csr_matrix( ( [c, b, a], ([0, 0, 0], [prim_pts - 3, prim_pts - 2, prim_pts - 1]), ), shape=(1, prim_pts), ) additive = pybamm.Scalar(0) else: raise NotImplementedError elif isinstance(symbol, pybamm.BoundaryGradient): if use_bcs and pybamm.has_bc_of_form(child, symbol.side, bcs, "Neumann"): # just use the value from the bc: f'(x*) sub_matrix = csr_matrix((1, prim_pts)) additive = bcs[child][symbol.side][0] elif symbol.side == "left": if extrap_order == "linear": # f'(x*) = (f_2 - f_1) / dx1 sub_matrix = (1 / dx1) * csr_matrix( ([-1, 1], ([0, 0], [0, 1])), shape=(1, prim_pts) ) additive = pybamm.Scalar(0) elif extrap_order == "quadratic": a = -(2 * dx0 + 2 * dx1 + dx2) / (dx1**2 + dx1 * dx2) b = (2 * dx0 + dx1 + dx2) / (dx1 * dx2) c = -(2 * dx0 + dx1) / (dx1 * dx2 + dx2**2) sub_matrix = csr_matrix( ([a, b, c], ([0, 0, 0], [0, 1, 2])), shape=(1, prim_pts) ) additive = pybamm.Scalar(0) else: raise NotImplementedError elif symbol.side == "right": if extrap_order == "linear": # use formula: # f'(x*) = (f_N - f_Nm1) / dxNm1 sub_matrix = (1 / dxNm1) * csr_matrix( ([-1, 1], ([0, 0], [prim_pts - 2, prim_pts - 1])), shape=(1, prim_pts), ) additive = pybamm.Scalar(0) elif extrap_order == "quadratic": a = (2 * dxN + 2 * dxNm1 + dxNm2) / (dxNm1**2 + dxNm1 * dxNm2) b = -(2 * dxN + dxNm1 + dxNm2) / (dxNm1 * dxNm2) c = (2 * dxN + dxNm1) / (dxNm1 * dxNm2 + dxNm2**2) sub_matrix = csr_matrix( ( [c, b, a], ([0, 0, 0], [prim_pts - 3, prim_pts - 2, prim_pts - 1]), ), shape=(1, prim_pts), ) additive = pybamm.Scalar(0) else: raise NotImplementedError # Generate full matrix from the submatrix # Convert to csr_matrix so that we can take the index (row-slicing), which is # not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue matrix = csr_matrix(kron(eye(repeats), sub_matrix)) # Return boundary value with domain given by symbol boundary_value = pybamm.Matrix(matrix) @ discretised_child boundary_value.copy_domains(symbol) additive.copy_domains(symbol) boundary_value += additive return boundary_value
[docs] def evaluate_at(self, symbol, discretised_child, position): """ Returns the symbol evaluated at a given position in space. Parameters ---------- symbol: :class:`pybamm.Symbol` The boundary value or flux symbol discretised_child : :class:`pybamm.StateVector` The discretised variable from which to calculate the boundary value position : :class:`pybamm.Scalar` The point in one-dimensional space at which to evaluate the symbol. Returns ------- :class:`pybamm.MatrixMultiplication` The variable representing the value at the given point. """ # Get mesh nodes domain = discretised_child.domain mesh = self.mesh[domain] nodes = mesh.nodes repeats = self._get_auxiliary_domain_repeats(discretised_child.domains) # Find the index of the node closest to the value index = np.argmin(np.abs(nodes - position.value)) # Create a sparse matrix with a 1 at the index sub_matrix = csr_matrix(([1], ([0], [index])), shape=(1, mesh.npts)) # repeat across auxiliary domains matrix = csr_matrix(kron(eye(repeats), sub_matrix)) # Index into the discretised child out = pybamm.Matrix(matrix) @ discretised_child # `EvaluateAt` removes domain out.clear_domains() return out
[docs] def process_binary_operators(self, bin_op, left, right, disc_left, disc_right): """Discretise binary operators in model equations. Performs appropriate averaging of diffusivities if one of the children is a gradient operator, so that discretised sizes match up. For this averaging we use the harmonic mean [1]. [1] Recktenwald, Gerald. "The control-volume finite-difference approximation to the diffusion equation." (2012). Parameters ---------- bin_op : :class:`pybamm.BinaryOperator` Binary operator to discretise left : :class:`pybamm.Symbol` The left child of `bin_op` right : :class:`pybamm.Symbol` The right child of `bin_op` disc_left : :class:`pybamm.Symbol` The discretised left child of `bin_op` disc_right : :class:`pybamm.Symbol` The discretised right child of `bin_op` Returns ------- :class:`pybamm.BinaryOperator` Discretised binary operator """ # Post-processing to make sure discretised dimensions match left_evaluates_on_edges = left.evaluates_on_edges("primary") right_evaluates_on_edges = right.evaluates_on_edges("primary") # inner product takes fluxes from edges to nodes if isinstance(bin_op, pybamm.Inner): if left_evaluates_on_edges: disc_left = self.edge_to_node(disc_left) if right_evaluates_on_edges: disc_right = self.edge_to_node(disc_right) # If neither child evaluates on edges, or both children have gradients, # no need to do any averaging elif left_evaluates_on_edges == right_evaluates_on_edges: pass # If only left child evaluates on edges, map right child onto edges # using the harmonic mean if the left child is a gradient (i.e. this # binary operator represents a flux) elif left_evaluates_on_edges and not right_evaluates_on_edges: if isinstance(left, pybamm.Gradient): method = "harmonic" else: method = "arithmetic" disc_right = self.node_to_edge(disc_right, method=method) # If only right child evaluates on edges, map left child onto edges # using the harmonic mean if the right child is a gradient (i.e. this # binary operator represents a flux) elif right_evaluates_on_edges and not left_evaluates_on_edges: if isinstance(right, pybamm.Gradient): method = "harmonic" else: method = "arithmetic" disc_left = self.node_to_edge(disc_left, method=method) # Return new binary operator with appropriate class out = pybamm.simplify_if_constant( bin_op._binary_new_copy(disc_left, disc_right) ) return out
[docs] def concatenation(self, disc_children): """Discrete concatenation, taking `edge_to_node` for children that evaluate on edges. See :meth:`pybamm.SpatialMethod.concatenation` """ for idx, child in enumerate(disc_children): submesh = self.mesh[child.domain] repeats = self._get_auxiliary_domain_repeats(child.domains) n_nodes = len(submesh.nodes) * repeats n_edges = len(submesh.edges) * repeats child_size = child.size if child_size != n_nodes: # Average any children that evaluate on the edges (size n_edges) to # evaluate on nodes instead, so that concatenation works properly if child_size == n_edges: disc_children[idx] = self.edge_to_node(child) else: raise pybamm.ShapeError( "child must have size n_nodes (number of nodes in the mesh) " "or n_edges (number of edges in the mesh)" ) return pybamm.domain_concatenation(disc_children, self.mesh)
[docs] def edge_to_node(self, discretised_symbol, method="arithmetic"): """ Convert a discretised symbol evaluated on the cell edges to a discretised symbol evaluated on the cell nodes. See :meth:`pybamm.FiniteVolume.shift` """ return self.shift(discretised_symbol, "edge to node", method)
[docs] def node_to_edge(self, discretised_symbol, method="arithmetic"): """ Convert a discretised symbol evaluated on the cell nodes to a discretised symbol evaluated on the cell edges. See :meth:`pybamm.FiniteVolume.shift` """ return self.shift(discretised_symbol, "node to edge", method)
[docs] def shift(self, discretised_symbol, shift_key, method): """ Convert a discretised symbol evaluated at edges/nodes, to a discretised symbol evaluated at nodes/edges. Can be the arithmetic mean or the harmonic mean. Note: when computing fluxes at cell edges it is better to take the harmonic mean based on [1]. [1] Recktenwald, Gerald. "The control-volume finite-difference approximation to the diffusion equation." (2012). Parameters ---------- discretised_symbol : :class:`pybamm.Symbol` Symbol to be averaged. When evaluated, this symbol returns either a scalar or an array of shape (n,) or (n+1,), where n is the number of points in the mesh for the symbol's domain (n = self.mesh[symbol.domain].npts) shift_key : str Whether to shift from nodes to edges ("node to edge"), or from edges to nodes ("edge to node") method : str Whether to use the "arithmetic" or "harmonic" mean Returns ------- :class:`pybamm.Symbol` Averaged symbol. When evaluated, this returns either a scalar or an array of shape (n+1,) (if `shift_key = "node to edge"`) or (n,) (if `shift_key = "edge to node"`) """ def arithmetic_mean(array): """Calculate the arithmetic mean of an array using matrix multiplication""" # Create appropriate submesh by combining submeshes in domain submesh = self.mesh[array.domain] # Create 1D matrix using submesh n = submesh.npts if shift_key == "node to edge": sub_matrix_left = csr_matrix( ([1.5, -0.5], ([0, 0], [0, 1])), shape=(1, n) ) sub_matrix_center = diags([0.5, 0.5], [0, 1], shape=(n - 1, n)) sub_matrix_right = csr_matrix( ([-0.5, 1.5], ([0, 0], [n - 2, n - 1])), shape=(1, n) ) sub_matrix = vstack( [sub_matrix_left, sub_matrix_center, sub_matrix_right] ) elif shift_key == "edge to node": sub_matrix = diags([0.5, 0.5], [0, 1], shape=(n, n + 1)) else: raise ValueError(f"shift key '{shift_key}' not recognised") # Second dimension length second_dim_repeats = self._get_auxiliary_domain_repeats( discretised_symbol.domains ) # Generate full matrix from the submatrix # Convert to csr_matrix so that we can take the index (row-slicing), which # is not supported by the default kron format # Note that this makes column-slicing inefficient, but this should not be an # issue matrix = csr_matrix(kron(eye(second_dim_repeats), sub_matrix)) return pybamm.Matrix(matrix) @ array def harmonic_mean(array): """ Calculate the harmonic mean of an array using matrix multiplication. The harmonic mean is computed as .. math:: D_{eff} = \\frac{D_1 D_2}{\\beta D_2 + (1 - \\beta) D_1}, where .. math:: \\beta = \\frac{\\Delta x_1}{\\Delta x_2 + \\Delta x_1} accounts for the difference in the control volume widths. This is the definiton from [1], which is the same as that in [2] but with slightly different notation. [1] Torchio, M et al. "LIONSIMBA: A Matlab Framework Based on a Finite Volume Model Suitable for Li-Ion Battery Design, Simulation, and Control." (2016). [2] Recktenwald, Gerald. "The control-volume finite-difference approximation to the diffusion equation." (2012). """ # Create appropriate submesh by combining submeshes in domain submesh = self.mesh[array.domain] # Get second dimension length for use later second_dim_repeats = self._get_auxiliary_domain_repeats( discretised_symbol.domains ) # Create 1D matrix using submesh n = submesh.npts if shift_key == "node to edge": # Matrix to compute values at the exterior edges edges_sub_matrix_left = csr_matrix( ([1.5, -0.5], ([0, 0], [0, 1])), shape=(1, n) ) edges_sub_matrix_center = csr_matrix((n - 1, n)) edges_sub_matrix_right = csr_matrix( ([-0.5, 1.5], ([0, 0], [n - 2, n - 1])), shape=(1, n) ) edges_sub_matrix = vstack( [ edges_sub_matrix_left, edges_sub_matrix_center, edges_sub_matrix_right, ] ) # Generate full matrix from the submatrix # Convert to csr_matrix so that we can take the index (row-slicing), # which is not supported by the default kron format # Note that this makes column-slicing inefficient, but this should # not be an issue edges_matrix = csr_matrix( kron(eye(second_dim_repeats), edges_sub_matrix) ) # Matrix to extract the node values running from the first node # to the penultimate node in the primary dimension (D_1 in the # definiton of the harmonic mean) sub_matrix_D1 = hstack([eye(n - 1), csr_matrix((n - 1, 1))]) matrix_D1 = csr_matrix(kron(eye(second_dim_repeats), sub_matrix_D1)) D1 = pybamm.Matrix(matrix_D1) @ array # Matrix to extract the node values running from the second node # to the final node in the primary dimension (D_2 in the # definiton of the harmonic mean) sub_matrix_D2 = hstack([csr_matrix((n - 1, 1)), eye(n - 1)]) matrix_D2 = csr_matrix(kron(eye(second_dim_repeats), sub_matrix_D2)) D2 = pybamm.Matrix(matrix_D2) @ array # Compute weight beta dx = submesh.d_edges sub_beta = (dx[:-1] / (dx[1:] + dx[:-1]))[:, np.newaxis] beta = pybamm.Array(np.kron(np.ones((second_dim_repeats, 1)), sub_beta)) # Compute harmonic mean on internal edges # Note: add small number to denominator to regularise D_eff D_eff = D1 * D2 / (D2 * beta + D1 * (1 - beta) + 1e-16) # Matrix to pad zeros at the beginning and end of the array where # the exterior edge values will be added sub_matrix = vstack( [csr_matrix((1, n - 1)), eye(n - 1), csr_matrix((1, n - 1))] ) # Generate full matrix from the submatrix # Convert to csr_matrix so that we can take the index (row-slicing), # which is not supported by the default kron format # Note that this makes column-slicing inefficient, but this should # not be an issue matrix = csr_matrix(kron(eye(second_dim_repeats), sub_matrix)) return ( pybamm.Matrix(edges_matrix) @ array + pybamm.Matrix(matrix) @ D_eff ) elif shift_key == "edge to node": # Matrix to extract the edge values running from the first edge # to the penultimate edge in the primary dimension (D_1 in the # definiton of the harmonic mean) sub_matrix_D1 = hstack([eye(n), csr_matrix((n, 1))]) matrix_D1 = csr_matrix(kron(eye(second_dim_repeats), sub_matrix_D1)) D1 = pybamm.Matrix(matrix_D1) @ array # Matrix to extract the edge values running from the second edge # to the final edge in the primary dimension (D_2 in the # definiton of the harmonic mean) sub_matrix_D2 = hstack([csr_matrix((n, 1)), eye(n)]) matrix_D2 = csr_matrix(kron(eye(second_dim_repeats), sub_matrix_D2)) D2 = pybamm.Matrix(matrix_D2) @ array # Compute weight beta dx0 = submesh.nodes[0] - submesh.edges[0] # first edge to node dxN = submesh.edges[-1] - submesh.nodes[-1] # last node to edge dx = np.concatenate(([dx0], submesh.d_nodes, [dxN])) sub_beta = (dx[:-1] / (dx[1:] + dx[:-1]))[:, np.newaxis] beta = pybamm.Array(np.kron(np.ones((second_dim_repeats, 1)), sub_beta)) # Compute harmonic mean on nodes # Note: add small number to denominator to regularise D_eff D_eff = D1 * D2 / (D2 * beta + D1 * (1 - beta) + 1e-16) return D_eff else: raise ValueError(f"shift key '{shift_key}' not recognised") # If discretised_symbol evaluates to number there is no need to average if discretised_symbol.size == 1: out = discretised_symbol elif method == "arithmetic": out = arithmetic_mean(discretised_symbol) elif method == "harmonic": out = harmonic_mean(discretised_symbol) else: raise ValueError(f"method '{method}' not recognised") return out
[docs] def upwind_or_downwind(self, symbol, discretised_symbol, bcs, direction): """ Implement an upwinding operator. Currently, this requires the symbol to have a Dirichlet boundary condition on the left side (for upwinding) or right side (for downwinding). Parameters ---------- symbol : :class:`pybamm.SpatialVariable` The variable to be discretised discretised_gradient : :class:`pybamm.Vector` Contains the discretised gradient of symbol bcs : dict of tuples (:class:`pybamm.Scalar`, str) Dictionary (with keys "left" and "right") of boundary conditions. Each boundary condition consists of a value and a flag indicating its type (e.g. "Dirichlet") direction : str Direction in which to apply the operator (upwind or downwind) """ if symbol not in bcs: raise pybamm.ModelError( "Boundary conditions must be provided for " f"{direction}ing '{symbol}'" ) if direction == "upwind": bc_side = "left" elif direction == "downwind": bc_side = "right" if bcs[symbol][bc_side][1] != "Dirichlet": raise pybamm.ModelError( "Dirichlet boundary conditions must be provided for " f"{direction}ing '{symbol}'" ) # Extract only the relevant boundary condition as the model might have both bc_subset = {bc_side: bcs[symbol][bc_side]} symbol_out, _ = self.add_ghost_nodes(symbol, discretised_symbol, bc_subset) return symbol_out