Source code for pybamm.spatial_methods.finite_volume_unstructured

"""
Finite Volume spatial method for unstructured simplex meshes (2D triangles / 3D tets).

Dimension-agnostic: the same code path handles both 2D and 3D, with
dimension inferred from the mesh.  All operators are assembled from
face-cell connectivity as sparse matrices.
"""

from __future__ import annotations

import itertools

import numpy as np
from scipy.sparse import coo_matrix, csr_matrix, diags, eye, kron
from scipy.spatial import cKDTree

import pybamm


[docs] class FiniteVolumeUnstructured(pybamm.SpatialMethod): """ Cell-centered finite volume method on unstructured meshes. Supports triangles and quadrilaterals (2D), tetrahedra and hexahedra (3D). Operators: * **Laplacian** – Two-Point Flux Approximation (TPFA) with an implicit non-orthogonal correction: the face normal is split as :math:`\\hat n = \\alpha \\hat e + \\mathbf{k}` along the unit centroid-to-centroid direction :math:`\\hat e`, so the normal derivative is :math:`\\alpha (u_j - u_i)/d + \\mathbf{k}\\cdot\\nabla u_f` with the cross term taken from the Green-Gauss gradient. On orthogonal meshes :math:`\\mathbf{k} = 0` and this is plain TPFA. * **Gradient** – Green-Gauss cell-centroid reconstruction * **Divergence** – face-flux summation (adjoint of gradient) * **Boundary conditions** – ghost-cell (Dirichlet) / direct injection (Neumann) Neumann boundary values on the named axis sides (``"left"``/``"right"``, ``"front"``/``"back"``, ``"bottom"``/``"top"``) are coordinate-direction derivatives (:math:`\\partial u/\\partial x`, etc.), matching :class:`pybamm.FiniteVolume`; e.g. ``u = x`` takes value ``+1`` on both ``"left"`` and ``"right"``. Values on any other face tag (Gmsh region names, ``"iface_*"``) are outward-normal derivatives :math:`\\partial u/\\partial n`. Parameters ---------- options : dict, optional Passed through to :class:`pybamm.SpatialMethod`. Additionally ``"non-orthogonal correction"`` selects the decomposition of the face normal: ``"over-relaxed"`` (default, :math:`\\alpha = 1/\\cos \\theta`, favouring diagonal dominance) or ``"minimum"`` (:math:`\\alpha = \\cos\\theta`, the smallest cross term). Both are exact on linear fields. """ _CORRECTIONS = ("over-relaxed", "minimum") # Common CFD mesh-quality limit; beyond it the scheme stays consistent # but conditioning degrades. _NON_ORTHOGONALITY_WARNING_DEG = 70.0 # Faces with |k| (about the angle in radians) below this are orthogonal: # centroid rounding on high-aspect-ratio cells reaches 1e-11 and must not # switch on the wide cross-term stencil. _ORTHOGONALITY_TOL = 1e-8 def __init__(self, options=None): super().__init__(options) self.options.setdefault("non-orthogonal correction", "over-relaxed") correction = self.options["non-orthogonal correction"] if correction not in self._CORRECTIONS: raise pybamm.OptionError( "'non-orthogonal correction' must be one of " f"{self._CORRECTIONS}, not {correction!r}" ) # ------------------------------------------------------------------ # build # ------------------------------------------------------------------
[docs] def build(self, mesh): """See :meth:`pybamm.SpatialMethod.build`.""" from pybamm.meshes.unstructured_submesh import UnstructuredSubMesh super().build(mesh) for dom in mesh: mesh[dom].npts_for_broadcast_to_nodes = mesh[dom].npts sm = mesh[dom] if not isinstance(sm, UnstructuredSubMesh): continue name = dom[0] if isinstance(dom, tuple) else dom max_angle = self._face_geometry(sm)["max_angle_deg"] if max_angle > self._NON_ORTHOGONALITY_WARNING_DEG: pybamm.logger.warning( f"Unstructured submesh for domain {name!r} has faces with " f"{max_angle:.1f} degrees of non-orthogonality (angle " "between the face normal and the centroid line). The " "discretisation remains consistent but the linear systems " "become poorly conditioned; consider improving the mesh." ) # Tags come from the generator, not the constructor: a hand-built # mesh with none gets no BCs and is invisible to interface # discovery, so surface that before it fails downstream. if not sm.boundary_faces and len(sm.face_owner) > sm._boundary_face_start: pybamm.logger.warning( f"Unstructured submesh for domain {name!r} has exterior " "faces but no boundary tags: boundary conditions cannot " "be applied and interface auto-discovery will not pair " "it with neighboring domains. Tag it (e.g. " "detect_box_boundaries() for axis-aligned boxes) or use " "a mesh generator that supplies tags." ) # Discover interfaces between all unstructured submesh pairs so # internal BCs work for arbitrary topology, not just 1D stacks. self._auto_compute_all_interfaces(mesh)
# ------------------------------------------------------------------ # interface auto-discovery (graph topology support) # ------------------------------------------------------------------ @staticmethod def _interface_face_match(a_mesh, b_mesh, tol_factor=1e-3): """Return matched boundary-face index pairs between two submeshes. Boundary faces whose centroids coincide within :func:`pybamm.meshes.unstructured_submesh._geometric_tolerance` (``tol_factor`` of the smallest element edge) are paired. Returns ``(a_idx, b_idx, matched)`` where ``matched`` is True iff at least one pair was found. """ a_idx = ( np.concatenate(list(a_mesh.boundary_faces.values())) if a_mesh.boundary_faces else np.array([], dtype=int) ) b_idx = ( np.concatenate(list(b_mesh.boundary_faces.values())) if b_mesh.boundary_faces else np.array([], dtype=int) ) if ( len(a_idx) == 0 or len(b_idx) == 0 # meshes of different spatial dimension can never share an interface or a_mesh.face_centroids.shape[1] != b_mesh.face_centroids.shape[1] ): return np.array([], dtype=int), np.array([], dtype=int), False from pybamm.meshes.unstructured_submesh import _geometric_tolerance a_c = a_mesh.face_centroids[a_idx] b_c = b_mesh.face_centroids[b_idx] # main's mesh module owns the geometric tolerance definition tol = _geometric_tolerance([a_mesh, b_mesh], rel=tol_factor) tree = cKDTree(b_c) d, j = tree.query(a_c, distance_upper_bound=tol) keep = np.isfinite(d) matched_b = j[keep] if len(np.unique(matched_b)) != len(matched_b): raise pybamm.GeometryError( f"Interface between meshes is not one-to-one: multiple faces " f"matched the same neighbor face within tolerance {tol:.2e}. " "The meshes are non-conforming at the interface." ) return a_idx[keep], b_idx[matched_b], bool(keep.any()) def _compute_pair_interface(self, a_mesh, b_mesh, a_name, b_name): """Populate ``interface_data`` and ``iface_<other>`` face buckets for a pair of submeshes that share a non-empty conformal interface. If either mesh already has an interface entry for the other (e.g. set up by 1D-stack auto-pairing in :class:`pybamm.Mesh` or by a manual ``compute_interface_data`` call), this method is a no-op so existing models keep their original face-tag scheme. """ if b_name in a_mesh.interface_data or a_name in b_mesh.interface_data: return False a_match, b_match, ok = self._interface_face_match(a_mesh, b_mesh) if not ok: return False a_cells = a_mesh.face_owner[a_match] b_cells = b_mesh.face_owner[b_match] face_areas = a_mesh.face_areas[a_match] cell_distances = np.linalg.norm( b_mesh.cell_centroids[b_cells] - a_mesh.cell_centroids[a_cells], axis=1, ) a_mesh.interface_data[b_name] = { "left_cells": a_cells, "right_cells": b_cells, "left_faces": a_match, "right_faces": b_match, "face_areas": face_areas, "cell_distances": cell_distances, "other_mesh": b_mesh, } b_mesh.interface_data[a_name] = { "left_cells": b_cells, "right_cells": a_cells, "left_faces": b_match, "right_faces": a_match, "face_areas": face_areas, "cell_distances": cell_distances, "other_mesh": a_mesh, } # Add new face-tag buckets for these interfaces. Order matches # across both meshes so per-face BCs line up element-wise. a_iface_tag = f"iface_{b_name}" b_iface_tag = f"iface_{a_name}" a_mesh.boundary_faces[a_iface_tag] = a_match b_mesh.boundary_faces[b_iface_tag] = b_match # Remove interface faces from the axis-aligned buckets so external # BCs don't double-count them. a_match_set = {int(i) for i in a_match} b_match_set = {int(i) for i in b_match} for tag in list(a_mesh.boundary_faces.keys()): if tag.startswith("iface_"): continue keep = np.array( [int(i) not in a_match_set for i in a_mesh.boundary_faces[tag]], dtype=bool, ) if keep.any(): a_mesh.boundary_faces[tag] = a_mesh.boundary_faces[tag][keep] else: del a_mesh.boundary_faces[tag] for tag in list(b_mesh.boundary_faces.keys()): if tag.startswith("iface_"): continue keep = np.array( [int(i) not in b_match_set for i in b_mesh.boundary_faces[tag]], dtype=bool, ) if keep.any(): b_mesh.boundary_faces[tag] = b_mesh.boundary_faces[tag][keep] else: del b_mesh.boundary_faces[tag] return True def _auto_compute_all_interfaces(self, mesh): """Walk every pair of unstructured submeshes; pair faces where they coincide. Replaces the 1D-stack adjacency assumption with arbitrary topology (star, tree, graph).""" from pybamm.meshes.unstructured_submesh import UnstructuredSubMesh domains = [] for raw in mesh: name = raw[0] if isinstance(raw, tuple) else raw sm = mesh[raw] if isinstance(sm, UnstructuredSubMesh): domains.append((name, sm)) seen = set() for (a, ma), (b, mb) in itertools.combinations(domains, 2): if ma is mb or (a, b) in seen or (b, a) in seen: continue seen.add((a, b)) # returns False when the pair shares no conformal interface self._compute_pair_interface(ma, mb, a, b) # ------------------------------------------------------------------ # internal BC assembly for arbitrary-topology Concatenation # ------------------------------------------------------------------
[docs] def set_internal_bcs_for_concat(self, disc, var, children, outer_bcs): """Build internal BC dict for each ``Concatenation`` child by walking its mesh's ``interface_data`` graph instead of assuming consecutive 1D-stack pairs. Returns ``None`` when no ``iface_<other>`` face buckets exist in any child mesh — that means there's no graph-discovered topology, so the caller should fall through to the legacy 1D-stack pairwise routine. For each child ``T_a`` on submesh ``mesh_a`` (graph case): - Pass through any user-supplied ``outer_bcs`` whose tag matches an external boundary tag present in ``mesh_a.boundary_faces``. - For each interface ``mesh_a ↔ mesh_b`` (one entry per neighbor in ``mesh_a.interface_data``), set ``iface_<b>`` to the discretised internal Neumann gradient between ``T_a`` and the matching child ``T_b``. """ from pybamm.meshes.unstructured_submesh import UnstructuredSubMesh # Skip if no graph-discovered interfaces — caller falls back to the # default 1D-stack pairwise logic. has_iface = False for c in children: primary = c.domain[0] sm = self.mesh[primary] if isinstance(sm, UnstructuredSubMesh) and any( k.startswith("iface_") for k in sm.boundary_faces ): has_iface = True break if not has_iface: return None bcs_out = {} name_to_child = {c.domain[0]: c for c in children} for child in children: primary = child.domain[0] child_mesh = self.mesh[primary] if not isinstance(child_mesh, UnstructuredSubMesh): continue # leave default handling for non-unstructured children bcs = {} for tag, bc_value in outer_bcs.items(): if tag in child_mesh.boundary_faces: bcs[tag] = bc_value for neighbor_name in child_mesh.interface_data: neighbor_child = name_to_child.get(neighbor_name) if neighbor_child is None: continue if f"iface_{neighbor_name}" not in child_mesh.boundary_faces: pybamm.logger.warning( f"Domain {primary!r} has interface data for " f"{neighbor_name!r} but no 'iface_{neighbor_name}' " "face bucket; skipping the internal BC, so these " "domains will not be coupled." ) continue left_disc = disc.process_symbol(child) right_disc = disc.process_symbol(neighbor_child) neighbor_mesh = self.mesh[neighbor_name] # External conditions feed the interface gradient's cross # term; the interface faces themselves enter as cross rows. grad = self.internal_neumann_condition( left_disc, right_disc, child_mesh, neighbor_mesh, left_bcs=self._external_bcs(child_mesh, outer_bcs), right_bcs=self._external_bcs(neighbor_mesh, outer_bcs), ) bcs[f"iface_{neighbor_name}"] = (grad, "Neumann") bcs_out[child] = bcs return bcs_out
@staticmethod def _external_bcs(submesh, outer_bcs): """The entries of ``outer_bcs`` on this mesh's exterior face tags.""" return { tag: bc for tag, bc in outer_bcs.items() if tag in submesh.boundary_faces and not tag.startswith("iface_") } @staticmethod def _is_scalar_value(symbol): """Whether ``symbol`` is a single value to broadcast over faces. Time- or input-dependent scalars evaluate for shape to ``()`` or ``(1,)`` rather than ``(1, 1)``, so every scalar shape counts. """ if isinstance(symbol, pybamm.Scalar): return True shape = getattr(symbol, "shape_for_testing", None) return shape is not None and int(np.prod(shape)) == 1 @staticmethod def _bc_contribution(n, n_bnd, owners, coeffs, bc_value, repeats=1): """Build a symbolic BC contribution vector of size ``n * repeats``. For scalar ``bc_value``: returns ``Vector(accumulated_coeffs) * bc_value``. For vector ``bc_value``: returns ``Matrix @ bc_value``, where the value has one entry per boundary face (shared across auxiliary-domain repeats) or ``n_bnd * repeats`` entries (one per face per repeat). """ is_scalar = FiniteVolumeUnstructured._is_scalar_value(bc_value) if is_scalar: row = np.zeros(n) np.add.at(row, owners, coeffs) if repeats > 1: row = np.tile(row, repeats) return pybamm.Vector(row) * bc_value else: M = csr_matrix((coeffs, (owners, np.arange(n_bnd))), shape=(n, n_bnd)) if repeats > 1: bc_shape = getattr(bc_value, "shape_for_testing", None) if bc_shape == (n_bnd * repeats, 1): M = FiniteVolumeUnstructured._block_diagonal(M, repeats) else: M = csr_matrix(kron(np.ones((repeats, 1)), M)) return pybamm.Matrix(M) @ bc_value @staticmethod def _tile_bc_value(bc_value, n_bnd, repeats): """Lift a BC value to ``n_bnd * repeats`` entries. Scalars and already-full values (``n_bnd * repeats`` entries) pass through; a per-face value (``n_bnd`` entries, shared across auxiliary-domain repeats) is tiled, matching :meth:`_bc_contribution`. """ if repeats == 1: return bc_value is_scalar = FiniteVolumeUnstructured._is_scalar_value(bc_value) if is_scalar or getattr(bc_value, "shape_for_testing", None) == ( n_bnd * repeats, 1, ): return bc_value tile = csr_matrix(kron(np.ones((repeats, 1)), eye(n_bnd, dtype=np.float64))) return pybamm.Matrix(tile) @ bc_value # ------------------------------------------------------------------ # spatial_variable # ------------------------------------------------------------------
[docs] def spatial_variable(self, symbol): """Return a vector of cell-centroid coordinates for ``symbol``'s direction (or its leading name token, e.g. ``x_n`` -> ``x``), tiled over auxiliary domains. Raises :class:`pybamm.DomainError` rather than guessing when neither identifies a coordinate.""" symbol_mesh = self.mesh[symbol.domain] repeats = self._get_auxiliary_domain_repeats(symbol.domains) dim = symbol_mesh.dimension direction = getattr(symbol, "direction", None) if direction is not None: direction_cols = {"lr": 0, "fb": 1, "tb": dim - 1} if direction not in direction_cols or (direction == "fb" and dim == 2): valid = "'lr', 'tb'" if dim == 2 else "'lr', 'fb', 'tb'" raise pybamm.DomainError( f"Unknown direction {direction!r} for spatial variable " f"{symbol.name!r} on a {dim}D unstructured mesh; valid " f"directions are {valid}." ) col = direction_cols[direction] else: axis_name = symbol.name.split("_")[0] name_cols = {"x": 0, "y": 1, "z": dim - 1} if axis_name not in name_cols or (axis_name == "y" and dim == 2): valid = "'x'/'z'" if dim == 2 else "'x'/'y'/'z'" raise pybamm.DomainError( f"Cannot infer a coordinate for spatial variable " f"{symbol.name!r} on a {dim}D unstructured mesh; name it " f"with a leading {valid} token (e.g. 'x_n') or set its " "direction." ) col = name_cols[axis_name] entries = np.tile(symbol_mesh.cell_centroids[:, col], repeats) return pybamm.Vector(entries, domains=symbol.domains)
# ------------------------------------------------------------------ # broadcast # ------------------------------------------------------------------
[docs] def broadcast(self, symbol, domains, broadcast_type): """See :meth:`pybamm.SpatialMethod.broadcast`.""" domain = domains["primary"] primary_pts = self.mesh[domain].npts aux_repeats = self._get_auxiliary_domain_repeats(domains) full_size = primary_pts * aux_repeats if broadcast_type.startswith("primary"): sub_vector = np.ones((primary_pts, 1)) if symbol.shape_for_testing == (): out = symbol * pybamm.Vector(sub_vector) else: matrix = self._block_diagonal(sub_vector, symbol.shape_for_testing[0]) out = pybamm.Matrix(matrix) @ symbol elif broadcast_type.startswith("full"): out = symbol * pybamm.Vector(np.ones(full_size), domains=domains) else: from scipy.sparse import vstack # secondary/tertiary broadcast tiles the child by the size of the # new (slower-varying) dimension, matching SpatialMethod.broadcast if broadcast_type.startswith("secondary"): reps = self._get_auxiliary_domain_repeats( {"secondary": domains.get("secondary", [])} ) else: reps = self._get_auxiliary_domain_repeats( {"tertiary": domains.get("tertiary", [])} ) identity = eye(symbol.shape[0]) matrix = vstack([identity for _ in range(reps)]) out = pybamm.Matrix(matrix) @ symbol return out.with_domains(domains)
# ================================================================== # Core operators # ================================================================== # ------------------------------------------------------------------ # Laplacian (TPFA) # ------------------------------------------------------------------
[docs] def laplacian(self, symbol, discretised_symbol, boundary_conditions): """Laplacian ``Matrix @ discretised_symbol + bc_rhs``: the two-point flux plus the non-orthogonal cross term, which is built from the BC-aware Green-Gauss gradient so it stays fully implicit.""" domain = symbol.domain submesh = self.mesh[domain] n = submesh.npts d = submesh.dimension repeats = self._get_auxiliary_domain_repeats(symbol.domains) L = self._tpfa_matrix(submesh) K = self._cross_term_matrices(submesh) bcs = boundary_conditions.get(symbol, {}) # The gradient is only assembled if some face actually needs a cross # term; on orthogonal meshes and boundaries the callable never fires. gradient_cache = [] def gradient(): if not gradient_cache: gradient_cache.append( self._least_squares_gradient(submesh, bcs, repeats) ) return gradient_cache[0] bc_rhs = pybamm.Vector(np.zeros(n * repeats)) if bcs: L, bc_rhs = self._apply_bcs_to_laplacian( submesh, L, bc_rhs, bcs, repeats=repeats, gradient=gradient ) if K is not None: G_components, grad_bc_vecs, _ = gradient() for k in range(d): L = L + K[k] @ G_components[k] if bcs: K_full = self._block_diagonal(K[k], repeats) bc_rhs = bc_rhs + pybamm.Matrix(K_full) @ grad_bc_vecs[k] L = csr_matrix(L) L_full = self._block_diagonal(L, repeats) result = pybamm.Matrix(L_full) @ discretised_symbol + bc_rhs return result
@staticmethod def _operator_cache(submesh): """Per-submesh cache for assembled operator matrices. Keyed on the face-owner connectivity so a cell reordering (e.g. ``optimize_ordering``) invalidates it. Cached matrices must never be mutated in place. """ fingerprint = hash(submesh.face_owner.tobytes()) cache = getattr(submesh, "_fv_operator_cache", None) if cache is None or cache.get("fingerprint") != fingerprint: cache = submesh._fv_operator_cache = {"fingerprint": fingerprint} return cache def _face_geometry(self, submesh): """Cached per-internal-face geometry shared by the TPFA operators. Returns a dict with the owner-to-neighbor centroid distance ``dist`` and unit direction ``e_ij``, the signed ``cos_theta = n · e_ij``, the distance-weighted owner interpolation weight ``w_owner`` for face values, and the largest non-orthogonality angle in degrees. Raises ------ pybamm.GeometryError If a face normal points away from the neighbor centroid: the two-point flux is undefined on such (inverted or non-star-shaped) cells. """ cache = self._operator_cache(submesh) if "face_geometry" in cache: return cache["face_geometry"] n_int = submesh.n_internal_faces owner = submesh.face_owner[:n_int] neighbor = submesh.face_neighbor[:n_int] centroids = submesh.cell_centroids face_centroids = submesh.face_centroids[:n_int] delta = centroids[neighbor] - centroids[owner] dist = np.linalg.norm(delta, axis=1) e_ij = delta / dist[:, np.newaxis] cos_theta = np.sum(submesh.face_normals[:n_int] * e_ij, axis=1) if np.any(cos_theta <= 0): raise pybamm.GeometryError( f"{int(np.count_nonzero(cos_theta <= 0))} internal face(s) " "have a normal pointing away from the neighbor centroid " "(inverted or non-star-shaped cells), so the two-point flux " "is undefined there. Fix the mesh." ) d_owner = np.linalg.norm(face_centroids - centroids[owner], axis=1) d_neighbor = np.linalg.norm(face_centroids - centroids[neighbor], axis=1) w_owner = d_neighbor / (d_owner + d_neighbor) max_angle = np.degrees(np.arccos(np.min(cos_theta))) if n_int else 0.0 cache["face_geometry"] = { "dist": dist, "e_ij": e_ij, "cos_theta": cos_theta, "w_owner": w_owner, "max_angle_deg": float(max_angle), } return cache["face_geometry"] def _alpha(self, cos_theta): """Implicit weight of the two-point difference in ``n = alpha e + k``. Any ``alpha`` is consistent because ``k`` is built from the same value; the choice only sets how much flux the compact stencil carries versus the reconstructed-gradient cross term. """ if self.options["non-orthogonal correction"] == "minimum": return cos_theta # Not floored: capping alpha on sliver faces lets the explicit cross # term dominate and gives the diffusion operator growing modes return 1.0 / cos_theta def _decomposition(self, submesh): """``(alpha, k)`` per internal face for ``n = alpha e_ij + k``.""" geometry = self._face_geometry(submesh) alpha = self._alpha(geometry["cos_theta"]) n_int = submesh.n_internal_faces k = submesh.face_normals[:n_int] - alpha[:, np.newaxis] * geometry["e_ij"] return alpha, self._drop_orthogonal(k) @classmethod def _drop_orthogonal(cls, k): """Zero ``k`` on faces that are orthogonal to within rounding, so they neither enter nor widen the cross-term stencil.""" k = k.copy() k[np.linalg.norm(k, axis=1) < cls._ORTHOGONALITY_TOL] = 0.0 return k def _boundary_decomposition(self, submesh, faces): """``(dist, alpha, k)`` for boundary ``faces``, splitting the outward normal along the unit vector from the owner centroid to the face centroid: ``n = alpha e_b + k``. ``dist * cos(theta)`` is the perpendicular distance, so ``alpha / dist`` is ``1 / (delta · n)`` for the over-relaxed choice. """ delta = ( submesh.face_centroids[faces] - submesh.cell_centroids[submesh.face_owner[faces]] ) dist = np.linalg.norm(delta, axis=1) e_b = delta / dist[:, np.newaxis] normals = submesh.face_normals[faces] alpha = self._alpha(np.sum(normals * e_b, axis=1)) return dist, alpha, self._drop_orthogonal(normals - alpha[:, np.newaxis] * e_b) def _cross_term_matrices(self, submesh): """Assemble (or fetch the cached) matrices ``K_k`` mapping the cell gradient components to the cell divergence of the internal-face cross fluxes ``A_f k_f · grad(u)_f``, where ``grad(u)_f`` is the distance-weighted interpolation of the two cell gradients. Returns ``None`` when every internal face is orthogonal (``k = 0``), so orthogonal meshes pay nothing for the correction. """ cache = self._operator_cache(submesh) key = ("cross", self.options["non-orthogonal correction"]) if key in cache: return cache[key] n = submesh.npts n_int = submesh.n_internal_faces d = submesh.dimension _, k = self._decomposition(submesh) if n_int == 0 or not k.any(): cache[key] = None return None owner = submesh.face_owner[:n_int] neighbor = submesh.face_neighbor[:n_int] areas = submesh.face_areas[:n_int] vol = submesh.cell_volumes w_owner = self._face_geometry(submesh)["w_owner"] face_rows = np.tile(np.arange(n_int), 2) both = np.concatenate([owner, neighbor]) # P: cell gradient -> face gradient; S: face flux -> cell divergence # (+owner, -neighbor, so the cross flux is conservative by construction) P = csr_matrix( (np.concatenate([w_owner, 1.0 - w_owner]), (face_rows, both)), shape=(n_int, n), ) S = csr_matrix( ( np.concatenate([1.0 / vol[owner], -1.0 / vol[neighbor]]), (both, face_rows), ), shape=(n, n_int), ) matrices = [] for kk in range(d): matrix = csr_matrix(S @ diags(areas * k[:, kk]) @ P) matrix.eliminate_zeros() # orthogonal faces must not widen the stencil matrices.append(matrix) cache[key] = matrices return cache[key] def _tpfa_matrix(self, submesh): """Assemble (or fetch the cached) two-point part of the Laplacian for internal faces only: the ``alpha (u_j - u_i) / d`` term of the decomposition ``n = alpha e_ij + k``. :meth:`_cross_term_matrices` supplies the ``k · grad(u)_f`` remainder; on orthogonal meshes ``alpha = 1`` and this is the whole operator. """ cache = self._operator_cache(submesh) key = ("tpfa", self.options["non-orthogonal correction"]) if key in cache: return cache[key] n = submesh.npts n_int = submesh.n_internal_faces owner = submesh.face_owner[:n_int] neighbor = submesh.face_neighbor[:n_int] alpha, _ = self._decomposition(submesh) coeff = ( submesh.face_areas[:n_int] * alpha / self._face_geometry(submesh)["dist"] ) vol = submesh.cell_volumes rows = np.concatenate([owner, neighbor, owner, neighbor]) cols = np.concatenate([neighbor, owner, owner, neighbor]) data = np.concatenate( [ coeff / vol[owner], coeff / vol[neighbor], -coeff / vol[owner], -coeff / vol[neighbor], ] ) cache[key] = csr_matrix(coo_matrix((data, (rows, cols)), shape=(n, n))) return cache[key] def _div_D_grad_matrices(self, submesh): """Assemble (or fetch the cached) matrices for :meth:`div_D_grad`: ``G`` (two-point difference per internal face), ``W`` (linear interpolation to faces), ``S`` (face flux to cell divergence), the geometric factor ``geo`` per internal face, the cross-term matrices ``C`` (``None`` on orthogonal meshes) and ``W_harmonic`` (resistance weights for the harmonic mean of ``D``). """ cache = self._operator_cache(submesh) key = ("div_D_grad", self.options["non-orthogonal correction"]) if key in cache: return cache[key] n = submesh.npts n_int = submesh.n_internal_faces vol = submesh.cell_volumes owner = submesh.face_owner[:n_int] neighbor = submesh.face_neighbor[:n_int] alpha, _ = self._decomposition(submesh) geo = submesh.face_areas[:n_int] * alpha / self._face_geometry(submesh)["dist"] # G (n_int x n): u_neighbor - u_owner per face G = csr_matrix( ( np.concatenate([-np.ones(n_int), np.ones(n_int)]), (np.tile(np.arange(n_int), 2), np.concatenate([owner, neighbor])), ), shape=(n_int, n), ) # W (n_int x n): linear (distance-weighted) interpolation to faces, # used for the face gradient of the cross term w_owner = self._face_geometry(submesh)["w_owner"] face_rows = np.tile(np.arange(n_int), 2) both = np.concatenate([owner, neighbor]) W = csr_matrix( (np.concatenate([w_owner, 1.0 - w_owner]), (face_rows, both)), shape=(n_int, n), ) # W_h (n_int x n): resistance weights for the harmonic mean of D, # D_f = 1 / (W_h @ (1/D)); each cell weighs by its own centroid-to-face # distance, so a face between two materials carries the series flux W_harmonic = csr_matrix( (np.concatenate([1.0 - w_owner, w_owner]), (face_rows, both)), shape=(n_int, n), ) # S (n x n_int): face flux -> cell divergence (+owner, -neighbor, /V) S = csr_matrix( ( np.concatenate([1.0 / vol[owner], -1.0 / vol[neighbor]]), (np.concatenate([owner, neighbor]), np.tile(np.arange(n_int), 2)), ), shape=(n, n_int), ) # C[k] (n_int x n): cell gradient component -> face cross flux # A_f k_f,k grad_k(u)_f, interpolated like D; None when orthogonal _, k_vec = self._decomposition(submesh) if not k_vec.any(): C = None else: areas = submesh.face_areas[:n_int] C = [] for kk in range(submesh.dimension): matrix = csr_matrix(diags(areas * k_vec[:, kk]) @ W) matrix.eliminate_zeros() C.append(matrix) cache[key] = (G, W, S, geo, C, W_harmonic) return cache[key]
[docs] def div_D_grad(self, div_symbol, grad_child, disc_D, disc_u, boundary_conditions): """Discretise ``div(D * grad(u))`` as a single TPFA operation. Fully symbolic — works for both constant and state-dependent scalar ``D``. Internal-face fluxes use the distance-weighted harmonic mean of ``D`` (resistances in series, as :class:`pybamm.FiniteVolume` does for coefficients of a gradient, so material interfaces carry the exact two-cell flux) and the two-point normal derivative plus its non-orthogonal cross term (see :meth:`_tpfa_matrix`). ``D`` must be strictly positive. This method is only reached when the expression is written as ``div(D * grad(u))`` (a single product, matched syntactically during discretisation); other flux forms go through the generic :meth:`gradient`/:meth:`divergence` operators, which cannot apply boundary conditions conservatively and raise instead. """ is_tensor_D = isinstance(disc_D, pybamm.TensorField) domain = div_symbol.domain submesh = self.mesh[domain] n = submesh.npts repeats = self._get_auxiliary_domain_repeats(div_symbol.domains) vol = submesh.cell_volumes G, _, S, geo, C, W_harmonic = self._div_D_grad_matrices(submesh) bcs = boundary_conditions.get(grad_child, {}) def lift(matrix): if repeats == 1: return matrix return self._block_diagonal(matrix, repeats) def tile(values): return np.tile(values, repeats) if repeats > 1 else values # Cell gradient components, assembled lazily: only non-orthogonal # faces (internal or Dirichlet) need them for their cross term. gradient_cache = [] def gradient(): if not gradient_cache: G_grad, grad_bc, _ = self._least_squares_gradient(submesh, bcs, repeats) gradient_cache.append( [ pybamm.Matrix(lift(G_grad[k])) @ disc_u + grad_bc[k] for k in range(submesh.dimension) ] ) return gradient_cache[0] def _is_zero(comp): """A constant component that is identically zero (skipped).""" if isinstance(comp, pybamm.Broadcast): return _is_zero(comp.child) if not comp.is_constant(): return False return not np.any(comp.evaluate()) def directional(faces, cells): """n.K.n on ``faces`` from the coefficient of the cells in ``cells``.""" normals = submesh.face_normals[faces] E = csr_matrix( (np.ones(len(faces)), (np.arange(len(faces)), cells)), shape=(len(faces), n), ) E_f = pybamm.Matrix(lift(E)) dim = submesh.dimension terms = [] if disc_D.rank == 1: if len(disc_D.components) != dim: raise pybamm.DiscretisationError( f"A VectorField coefficient needs {dim} components " f"(one per axis), got {len(disc_D.components)}" ) for i, comp in enumerate(disc_D.components): if not _is_zero(comp): terms.append( pybamm.Vector(tile(normals[:, i] ** 2)) * (E_f @ comp) ) else: rows = disc_D.components if len(rows) != dim or any(len(r) != dim for r in rows): raise pybamm.DiscretisationError( f"A TensorField coefficient must be {dim}x{dim}" ) for i in range(dim): for j in range(dim): weight = normals[:, i] * normals[:, j] if np.any(weight) and not _is_zero(rows[i][j]): terms.append( pybamm.Vector(tile(weight)) * (E_f @ rows[i][j]) ) if not terms: raise pybamm.DiscretisationError("anisotropic coefficient is zero") out = terms[0] for term in terms[1:]: out = out + term return out normal_grad = pybamm.Matrix(lift(G)) @ disc_u * pybamm.Vector(tile(geo)) if C is not None: for k, grad_k in enumerate(gradient()): normal_grad = normal_grad + pybamm.Matrix(lift(C[k])) @ grad_k is_scalar_D = not is_tensor_D and self._is_scalar_value(disc_D) if is_tensor_D: # n.K.n from each side, combined like a scalar D (harmonic mean); # the tangential part of K n is dropped. n_int = submesh.n_internal_faces faces = np.arange(n_int) k_owner = directional(faces, submesh.face_owner[:n_int]) k_neigh = directional(faces, submesh.face_neighbor) w_owner = self._face_geometry(submesh)["w_owner"] D_face = 1 / ( pybamm.Vector(tile(1.0 - w_owner)) / k_owner + pybamm.Vector(tile(w_owner)) / k_neigh ) elif is_scalar_D: D_face = disc_D else: if isinstance(disc_D, pybamm.Vector) and np.any(disc_D.entries <= 0): raise pybamm.DiscretisationError( "div(D * grad(u)) needs a strictly positive coefficient D: " "faces take its harmonic mean." ) D_face = 1 / (pybamm.Matrix(lift(W_harmonic)) @ (1 / disc_D)) result = pybamm.Matrix(lift(S)) @ (D_face * normal_grad) # Boundary conditions bc_rhs = pybamm.Vector(np.zeros(n * repeats)) if bcs: for side, (bc_value, bc_type) in bcs.items(): self._check_bc_type(bc_type) fi_arr = self._boundary_faces_for_side(submesh, side) n_bnd = len(fi_arr) bnd_own = submesh.face_owner[fi_arr] E = csr_matrix( (np.ones(n_bnd), (np.arange(n_bnd), bnd_own)), shape=(n_bnd, n), ) P = csr_matrix( (np.ones(n_bnd), (bnd_own, np.arange(n_bnd))), shape=(n, n_bnd), ) E_f, P_f = lift(E), lift(P) if is_tensor_D: D_bnd = directional(fi_arr, bnd_own) elif is_scalar_D: D_bnd = disc_D else: D_bnd = pybamm.Matrix(E_f) @ disc_D bc_value = self._tile_bc_value(bc_value, n_bnd, repeats) a_over_v = submesh.face_areas[fi_arr] / vol[bnd_own] if bc_type == "Dirichlet": dist, alpha, k_vec = self._boundary_decomposition(submesh, fi_arr) u_bnd = pybamm.Matrix(E_f) @ disc_u normal_grad_bnd = (bc_value - u_bnd) * pybamm.Vector( tile(a_over_v * alpha / dist) ) if k_vec.any(): for k, grad_k in enumerate(gradient()): normal_grad_bnd = normal_grad_bnd + ( pybamm.Matrix(E_f) @ grad_k ) * pybamm.Vector(tile(a_over_v * k_vec[:, k])) bc_rhs = bc_rhs + pybamm.Matrix(P_f) @ (D_bnd * normal_grad_bnd) elif bc_type == "Neumann" and bc_value != pybamm.Scalar(0): bc_rhs = bc_rhs + pybamm.Matrix(P_f) @ ( D_bnd * bc_value * pybamm.Vector(tile(self._neumann_sign(side) * a_over_v)) ) return result + bc_rhs
def _apply_bcs_to_laplacian( self, submesh, L, bc_rhs, bcs, repeats=1, gradient=None ): """Return the Laplacian matrix and RHS modified for boundary conditions. ``bc_rhs`` is a pybamm expression (symbolic vector of size ``npts * repeats``). ``L`` is not mutated (it may be cached). ``gradient`` is a zero-argument callable returning the ``(matrices, bc_vecs)`` of the cell gradient; it is only called for Dirichlet faces whose centroid direction is not normal to the face, which need the cross term ``A k · grad(u)``. Without it those faces get the two-point term only. """ n = submesh.npts d = submesh.dimension diag_correction = np.zeros(n) cross_diag = np.zeros((d, n)) for side, (bc_value, bc_type) in bcs.items(): self._check_bc_type(bc_type) face_indices = self._boundary_faces_for_side(submesh, side) n_bnd = len(face_indices) owners = submesh.face_owner[face_indices] a_over_v = submesh.face_areas[face_indices] / submesh.cell_volumes[owners] if bc_type == "Dirichlet": dist, alpha, k_vec = self._boundary_decomposition(submesh, face_indices) coeffs = a_over_v * alpha / dist np.add.at(diag_correction, owners, -coeffs) bc_rhs = bc_rhs + self._bc_contribution( n, n_bnd, owners, coeffs, bc_value, repeats=repeats ) if gradient is not None: for k in range(d): np.add.at(cross_diag[k], owners, a_over_v * k_vec[:, k]) elif bc_type == "Neumann": coeffs = self._neumann_sign(side) * a_over_v bc_rhs = bc_rhs + self._bc_contribution( n, n_bnd, owners, coeffs, bc_value, repeats=repeats ) if np.any(diag_correction): L = csr_matrix(L + diags(diag_correction)) if cross_diag.any(): G_components, grad_bc_vecs, _ = gradient() for k in range(d): scale = diags(cross_diag[k]) L = L + scale @ G_components[k] scale_full = self._block_diagonal(scale, repeats) bc_rhs = bc_rhs + pybamm.Matrix(scale_full) @ grad_bc_vecs[k] L = csr_matrix(L) return L, bc_rhs @staticmethod def _boundary_faces_for_side(submesh, side): """Boundary-face indices for a BC side, raising when the tag is unknown. BC sides map directly onto ``submesh.boundary_faces`` keys; a missing key means the BC cannot be applied, so failing loudly here is what stops typos and interface-consumed sides from silently dropping BCs. """ if side not in submesh.boundary_faces: raise pybamm.DiscretisationError( f"No boundary faces tagged {side!r} on this mesh (available " f"tags: {sorted(submesh.boundary_faces)}). The side may be " "misspelled, or its faces were absorbed into an internal " "interface by interface discovery." ) return submesh.boundary_faces[side] @staticmethod def _check_bc_type(bc_type): if bc_type not in ("Dirichlet", "Neumann"): raise pybamm.DiscretisationError( f"boundary condition must be Dirichlet or Neumann, not {bc_type!r}" ) # Named sides whose outward normal points along the negative coordinate # axis (see UnstructuredSubMesh._identify_boundary_faces). _NEGATIVE_NORMAL_SIDES = frozenset({"left", "front", "bottom"}) @classmethod def _neumann_sign(cls, side): """Sign converting a Neumann boundary value to an outward-normal derivative. Named axis sides carry PyBaMM coordinate-direction values, so sides with a negative outward normal flip sign; any other face tag (Gmsh region names, ``iface_*``) is already outward-normal. """ return -1.0 if side in cls._NEGATIVE_NORMAL_SIDES else 1.0 # ------------------------------------------------------------------ # Gradient (Green-Gauss) # ------------------------------------------------------------------
[docs] def gradient(self, symbol, discretised_symbol, boundary_conditions): """Least-squares cell-centroid gradient, returned as a :class:`pybamm.VectorField` with one component per dimension. Exact on linear fields for any cell shape: every face contributes one directional-derivative equation — towards the neighbour centroid (internal faces), towards the face centroid holding the prescribed value (Dirichlet), or the normal derivative itself (Neumann). Boundary faces without a condition contribute nothing. """ domain = symbol.domain submesh = self.mesh[domain] d = submesh.dimension repeats = self._get_auxiliary_domain_repeats(symbol.domains) bcs = boundary_conditions.get(symbol, {}) if bcs: missing = [tag for tag in submesh.boundary_faces if tag not in bcs] if missing: pybamm.logger.warning( f"Gradient of {symbol.name!r}: boundary face buckets " f"{missing} have no boundary condition and are fitted as " "zero normal derivative (the operators' zero-flux " "treatment), which is wrong if the field varies normal " "to those boundaries." ) G_components, bc_vecs, _ = self._least_squares_gradient(submesh, bcs, repeats) components = [] for k in range(d): Gk = self._block_diagonal(G_components[k], repeats) comp = pybamm.Matrix(Gk) @ discretised_symbol + bc_vecs[k] components.append(comp) return pybamm.VectorField(*components)
# Row kinds of the least-squares gradient fit _ROW_INTERNAL, _ROW_DIRICHLET, _ROW_NEUMANN, _ROW_NO_BC, _ROW_INTERFACE = range(5) def _face_bc_kinds(self, submesh, bcs, interface_faces=None): """Per-face row kind (see the ``_ROW_*`` constants).""" kinds = np.full(len(submesh.face_owner), self._ROW_NO_BC, dtype=int) kinds[: submesh.n_internal_faces] = self._ROW_INTERNAL for side, (_, bc_type) in bcs.items(): self._check_bc_type(bc_type) faces = self._boundary_faces_for_side(submesh, side) kinds[faces] = ( self._ROW_DIRICHLET if bc_type == "Dirichlet" else self._ROW_NEUMANN ) if interface_faces is not None: kinds[interface_faces] = self._ROW_INTERFACE return kinds def _least_squares_matrices(self, submesh, bcs, interface=None): """Cached matrix part of the least-squares gradient for one BC layout. Each cell has the same number of faces ``m``, so the per-cell normal equations are solved in a batch. Rows are unit-direction equations ``e · grad(u) = b``: ``e`` towards the neighbour centroid with ``b = (u_j - u_i) / dist`` (internal faces, and interface faces towards the other mesh's cell), towards the face centroid with ``b = (u_b - u_i) / dist`` (Dirichlet), or the outward normal with ``b`` the prescribed derivative (Neumann). Boundary faces without a condition take ``b = 0``, matching the operators' zero-flux treatment of such faces. Cells whose directions do not span the space get the minimum-norm fit via the pseudo-inverse. Parameters ---------- submesh : UnstructuredSubMesh bcs : dict ``{side: (value, type)}`` boundary conditions. interface : dict, optional Cross-mesh rows: ``faces`` (this mesh's interface faces), ``other_cells`` and ``other_centroids`` (the paired cells of the other mesh), ``n_other`` and a hashable ``key`` for caching. Returns ------- tuple ``(G, coeff, slot, length, G_cross)``: ``G[k]`` maps cell values to gradient component ``k`` and ``G_cross[k]`` (``None`` without an interface) maps the other mesh's values; ``coeff`` of shape ``(n, d, m)`` holds ``grad_k(cell) = sum_m coeff[cell, k, m] b_m``; ``slot[f]`` and ``length[f]`` are the row position within the owner cell and the row distance of face ``f``, used to place boundary values. """ cache = self._operator_cache(submesh) signature = tuple(sorted((side, bc_type) for side, (_, bc_type) in bcs.items())) interface_key = None if interface is None else interface["key"] key = ("least_squares", signature, interface_key) if key in cache: return cache[key] n = submesh.npts d = submesh.dimension n_int = submesh.n_internal_faces n_faces = len(submesh.face_owner) centroids = submesh.cell_centroids interface_faces = None if interface is None else interface["faces"] kinds = self._face_bc_kinds(submesh, bcs, interface_faces) # Half-face rows: the owner side of every face, then the neighbour # side of internal faces, so row f (< n_faces) belongs to face f. row_face = np.concatenate([np.arange(n_faces), np.arange(n_int)]) cell = np.concatenate([submesh.face_owner, submesh.face_neighbor[:n_int]]) other = np.concatenate( [ submesh.face_neighbor[:n_int], np.full(n_faces - n_int, -1), submesh.face_owner[:n_int], ] ) row_kind = kinds[row_face] toward = np.where( (row_kind == self._ROW_INTERNAL)[:, np.newaxis], centroids[np.maximum(other, 0)], submesh.face_centroids[row_face], ) if interface is not None: toward[interface_faces] = interface["other_centroids"] other[interface_faces] = interface["other_cells"] delta = toward - centroids[cell] length = np.linalg.norm(delta, axis=1) direction = delta / length[:, np.newaxis] normal_rows = (row_kind == self._ROW_NEUMANN) | (row_kind == self._ROW_NO_BC) direction[normal_rows] = submesh.face_normals[row_face[normal_rows]] length[normal_rows] = 1.0 counts = np.bincount(cell, minlength=n) if np.any(counts != counts[0]): raise pybamm.DiscretisationError( "Least-squares gradient needs every cell to have the same " "number of faces; the mesh connectivity is inconsistent." ) m = int(counts[0]) order = np.argsort(cell, kind="stable") dirs = direction[order].reshape(n, m, d) normal = np.einsum("nmi,nmj->nij", dirs, dirs) coeff = np.einsum("nij,nmj->nim", np.linalg.pinv(normal), dirs) slot = np.empty(len(cell), dtype=int) slot[order] = np.arange(len(cell)) % m def row_coefficients(rows, k): return coeff[cell[rows], k, slot[rows]] / length[rows] internal = np.nonzero(row_kind == self._ROW_INTERNAL)[0] dirichlet = np.nonzero(row_kind == self._ROW_DIRICHLET)[0] across = np.nonzero(row_kind == self._ROW_INTERFACE)[0] G = [] G_cross = None if interface is None else [] for k in range(d): c_int = row_coefficients(internal, k) c_dir = row_coefficients(dirichlet, k) c_across = row_coefficients(across, k) rows = np.concatenate( [cell[internal], cell[internal], cell[dirichlet], cell[across]] ) cols = np.concatenate( [other[internal], cell[internal], cell[dirichlet], cell[across]] ) data = np.concatenate([c_int, -c_int, -c_dir, -c_across]) G.append(csr_matrix(coo_matrix((data, (rows, cols)), shape=(n, n)))) if interface is not None: G_cross.append( csr_matrix( coo_matrix( (c_across, (cell[across], other[across])), shape=(n, interface["n_other"]), ) ) ) cache[key] = (G, coeff, slot[:n_faces], length[:n_faces], G_cross) return cache[key] def _least_squares_gradient(self, submesh, bcs, repeats=1, interface=None): """``(matrices, bc_vecs, cross_matrices)`` of the least-squares gradient: component ``k`` is ``matrices[k] @ u + bc_vecs[k]``, plus ``cross_matrices[k] @ u_other`` when ``interface`` rows are given (sizes lifted by ``repeats`` for auxiliary domains).""" n = submesh.npts d = submesh.dimension G, coeff, slot, length, G_cross = self._least_squares_matrices( submesh, bcs, interface ) bc_vecs = [pybamm.Vector(np.zeros(n * repeats)) for _ in range(d)] for side, (bc_value, bc_type) in bcs.items(): faces = self._boundary_faces_for_side(submesh, side) owners = submesh.face_owner[faces] for k in range(d): coeffs = coeff[owners, k, slot[faces]] if bc_type == "Dirichlet": coeffs = coeffs / length[faces] else: coeffs = coeffs * self._neumann_sign(side) bc_vecs[k] = bc_vecs[k] + self._bc_contribution( n, len(faces), owners, coeffs, bc_value, repeats=repeats ) return G, bc_vecs, G_cross def _green_gauss_matrices(self, submesh): """ Build (or fetch the cached) Green-Gauss gradient matrices G_k for k = 0..d-1. For each cell i, the gradient component k is: (grad u)_k,i = (1/V_i) * sum_f [u_f * n_k,f * A_f] where u_f is interpolated from owner/neighbor (distance-weighted for internal faces) or just the owner value (boundary faces). """ cache = self._operator_cache(submesh) if "green_gauss" in cache: return cache["green_gauss"] n = submesh.npts d = submesh.dimension n_int = submesh.n_internal_faces owner = submesh.face_owner neighbor = submesh.face_neighbor normals = submesh.face_normals areas = submesh.face_areas vol = submesh.cell_volumes centroids = submesh.cell_centroids face_centroids = submesh.face_centroids G = [csr_matrix((n, n)) for _ in range(d)] # --- internal faces: distance-weighted interpolation --- int_owner = owner[:n_int] int_neighbor = neighbor[:n_int] d_owner = np.linalg.norm(face_centroids[:n_int] - centroids[int_owner], axis=1) d_neighbor = np.linalg.norm( face_centroids[:n_int] - centroids[int_neighbor], axis=1 ) d_total = d_owner + d_neighbor w_owner = d_neighbor / d_total # weight for owner value w_neighbor = d_owner / d_total # weight for neighbor value for k in range(d): nk_A = normals[:n_int, k] * areas[:n_int] # distance-weighted face value scatters to owner (+) and # neighbor (-), each divided by that cell's volume rows = np.concatenate([int_owner, int_owner, int_neighbor, int_neighbor]) cols = np.concatenate([int_owner, int_neighbor, int_owner, int_neighbor]) data = np.concatenate( [ w_owner * nk_A / vol[int_owner], w_neighbor * nk_A / vol[int_owner], -w_owner * nk_A / vol[int_neighbor], -w_neighbor * nk_A / vol[int_neighbor], ] ) G[k] = G[k] + csr_matrix(coo_matrix((data, (rows, cols)), shape=(n, n))) # --- boundary faces: u_f = u_owner (zeroth-order extrapolation) --- n_total = len(owner) bnd_indices = np.arange(n_int, n_total) if len(bnd_indices) > 0: bnd_owner = owner[bnd_indices] for k in range(d): nk_A = normals[bnd_indices, k] * areas[bnd_indices] rows = bnd_owner cols = bnd_owner data = nk_A / vol[bnd_owner] G[k] = G[k] + csr_matrix(coo_matrix((data, (rows, cols)), shape=(n, n))) cache["green_gauss"] = G return G # ------------------------------------------------------------------ # Divergence # ------------------------------------------------------------------
[docs] def divergence(self, symbol, discretised_symbol, boundary_conditions): """Face-flux divergence of a cell-centred vector field. Boundary faces use zeroth-order flux extrapolation, so fluxes built from BC-carrying gradients are rejected (see the raise below). """ domain = symbol.domain submesh = self.mesh[domain] n = submesh.npts d = submesh.dimension repeats = self._get_auxiliary_domain_repeats(symbol.domains) # BC-bearing fluxes must use the div(D*grad(u)) TPFA intercept; # here the prescribed boundary flux would be silently ignored. bc_gradient_parents = [ node.child for node in symbol.pre_order() if isinstance(node, pybamm.Gradient) and node.child in boundary_conditions ] if bc_gradient_parents: names = sorted({parent.name for parent in bc_gradient_parents}) raise pybamm.DiscretisationError( f"Cannot discretise div of a general flux containing grad of " f"{names} on an unstructured mesh: the boundary conditions " "would be ignored and the result would not be conservative. " "Write the equation as div(D * grad(u)) (a single product) so " "the TPFA discretisation applies the boundary conditions." ) if isinstance(discretised_symbol, pybamm.VectorField): comps = discretised_symbol.components elif isinstance(discretised_symbol, (list, tuple)): comps = list(discretised_symbol) else: raise pybamm.DiscretisationError( "FiniteVolumeUnstructured.divergence expects a VectorField or " f"list of {d} component arrays, got {type(discretised_symbol)}" ) D_components = self._divergence_matrices(submesh) result = pybamm.Vector(np.zeros(n * repeats)) for k in range(d): Dk = self._block_diagonal(D_components[k], repeats) result = result + pybamm.Matrix(Dk) @ comps[k] return result
def _divergence_matrices(self, submesh): """Divergence matrices ``D_k``: ``(div F)_i = (1/V_i) sum_f F_k,f n_k,f A_f``. The face-value interpolation is identical to the Green-Gauss gradient's, so the two operators share one assembly. """ return self._green_gauss_matrices(submesh) # ------------------------------------------------------------------ # gradient_squared |grad u|^2 # ------------------------------------------------------------------
[docs] def gradient_squared(self, symbol, discretised_symbol, boundary_conditions): """Pointwise ``|grad u|^2`` via :meth:`gradient`.""" grad = self.gradient(symbol, discretised_symbol, boundary_conditions) result = None for comp in grad.components: sq = comp**2 result = sq if result is None else result + sq return result
# ------------------------------------------------------------------ # Binary operator handling (scalar * VectorField, etc.) # ------------------------------------------------------------------
[docs] def process_binary_operators(self, bin_op, left, right, disc_left, disc_right): """Apply a binary operator componentwise when either operand is a :class:`pybamm.VectorField`, lifting scalars to N components.""" if isinstance(disc_left, pybamm.VectorField) or isinstance( disc_right, pybamm.VectorField ): if isinstance(disc_left, pybamm.VectorField) and isinstance( disc_right, pybamm.VectorField ): n = disc_left.n_components elif isinstance(disc_left, pybamm.VectorField): n = disc_left.n_components disc_right = pybamm.VectorField(*[disc_right] * n) else: n = disc_right.n_components disc_left = pybamm.VectorField(*[disc_left] * n) new_comps = [ pybamm.simplify_if_constant( bin_op.create_copy( [disc_left.components[k], disc_right.components[k]] ) ) for k in range(n) ] return pybamm.VectorField(*new_comps) return bin_op._binary_new_copy(disc_left, disc_right)
# ------------------------------------------------------------------ # Integral operators # ------------------------------------------------------------------
[docs] def integral( self, child, discretised_child, integration_dimension, integration_variable=None ): """Volume integral over the primary domain (cell-volume weights).""" int_mat = self.definite_integral_matrix( child, integration_dimension=integration_dimension ) return int_mat @ discretised_child
[docs] def definite_integral_matrix( self, child, vector_type="row", integration_dimension="primary" ): """Cell-volume weights of the primary domain as a :class:`pybamm.Matrix`, one block per auxiliary-domain repeat. Parameters ---------- child : pybamm.Symbol The symbol being integrated. vector_type : str, optional ``"row"`` (default) or ``"column"``. integration_dimension : str, optional Only ``"primary"`` is supported: cells have no secondary structure to integrate over. Raises ------ NotImplementedError For a non-primary ``integration_dimension``. """ if integration_dimension != "primary": raise NotImplementedError( f"Integral in the {integration_dimension!r} dimension is not " "implemented on unstructured meshes; only the primary (cell) " "dimension can be integrated." ) if vector_type not in ("row", "column"): raise pybamm.DiscretisationError( f"vector_type must be 'row' or 'column', not {vector_type!r}" ) submesh = self.mesh[child.domain] repeats = self._get_auxiliary_domain_repeats(child.domains) shape = (1, -1) if vector_type == "row" else (-1, 1) block = csr_matrix(submesh.cell_volumes.reshape(shape)) return pybamm.Matrix(self._block_diagonal(block, repeats))
[docs] def boundary_integral(self, child, discretised_child, region): """Integral of the owner-cell values of ``child`` over the boundary faces of ``region`` (``"entire"`` = all exterior faces).""" submesh = self.mesh[child.domain] repeats = self._get_auxiliary_domain_repeats(child.domains) if region == "entire": # every exterior boundary face; iface_* buckets are internal # interfaces, not part of the domain boundary iface = [ indices for tag, indices in submesh.boundary_faces.items() if tag.startswith("iface_") ] face_indices = np.setdiff1d( np.arange(submesh._boundary_face_start, len(submesh.face_owner)), np.concatenate(iface) if iface else np.array([], dtype=int), ) else: face_indices = self._boundary_faces_for_side(submesh, region) n = submesh.npts owners = submesh.face_owner[face_indices] face_areas = submesh.face_areas[face_indices] row = np.zeros(n) np.add.at(row, owners, face_areas) mat = csr_matrix(row.reshape(1, -1)) mat = self._block_diagonal(mat, repeats) return pybamm.Matrix(mat) @ discretised_child
# ------------------------------------------------------------------ # boundary_value_or_flux # ------------------------------------------------------------------ _CORNER_SIDES = { "top-right": ("top", "right"), "top-left": ("top", "left"), "bottom-right": ("bottom", "right"), "bottom-left": ("bottom", "left"), }
[docs] def boundary_value_or_flux(self, symbol, discretised_child, bcs=None): """Owner-cell values on a boundary side (zeroth-order boundary value); corner sides return the single closest boundary cell.""" if isinstance(symbol, pybamm.BoundaryGradient): raise NotImplementedError( "BoundaryGradient is not implemented for unstructured meshes; " "returning the boundary value instead would be silently wrong." ) submesh = self.mesh[discretised_child.domain] n = submesh.npts repeats = self._get_auxiliary_domain_repeats(discretised_child.domains) side = symbol.side if side in self._CORNER_SIDES: return self._corner_boundary_value( submesh, n, repeats, side, discretised_child ) face_indices = self._boundary_faces_for_side(submesh, side) n_bnd = len(face_indices) owners = submesh.face_owner[face_indices] sub_matrix = csr_matrix( (np.ones(n_bnd), (np.arange(n_bnd), owners)), shape=(n_bnd, n), ) mat = self._block_diagonal(sub_matrix, repeats) bv_vector = pybamm.Matrix(mat) out = bv_vector @ discretised_child out = out.without_domains() return out
def _corner_boundary_value(self, submesh, n, repeats, side, discretised_child): """Extract the value from the boundary cell closest to a corner of the (x, z) bounding box. Zeroth-order (cell value) regardless of the ``extrapolation`` option. Candidates are restricted to cells owning boundary faces on the two named sides, so interior cells of non-convex domains are never picked; in 3D, ties across y pick the lowest cell index. """ tb_side, lr_side = self._CORNER_SIDES[side] centroids = submesh.cell_centroids # cells owning boundary faces on either named side (fall back to all # boundary-owner cells when a side bucket is missing) candidates = np.unique( np.concatenate( [ submesh.face_owner[submesh.boundary_faces[tag]] for tag in (tb_side, lr_side) if tag in submesh.boundary_faces ] or [submesh.face_owner[submesh._boundary_face_start :]] ) ) x_coords = centroids[candidates, 0] z_coords = centroids[candidates, -1] target_x = x_coords.max() if lr_side == "right" else x_coords.min() target_z = z_coords.max() if tb_side == "top" else z_coords.min() dists = (x_coords - target_x) ** 2 + (z_coords - target_z) ** 2 cell_idx = int(candidates[np.argmin(dists)]) sub_matrix = csr_matrix( (np.ones(1), (np.zeros(1, dtype=int), [cell_idx])), shape=(1, n), ) mat = self._block_diagonal(sub_matrix, repeats) out = pybamm.Matrix(mat) @ discretised_child out = out.without_domains() return out # ------------------------------------------------------------------ # internal_neumann_condition # ------------------------------------------------------------------
[docs] def internal_neumann_condition( self, left_symbol_disc, right_symbol_disc, left_mesh, right_mesh, left_bcs=None, right_bcs=None, ): """Normal gradient across the interface between two submeshes, one value per interface face (outward from ``left_mesh``). On unstructured meshes this is the two-point difference plus the non-orthogonal cross term (see :meth:`_tpfa_matrix`), whose face gradient is fitted on both sides with the interface faces as cross-mesh rows. ``left_bcs``/``right_bcs`` are the external boundary conditions of each side, used by that fit. """ from pybamm.meshes.unstructured_submesh import UnstructuredSubMesh repeats = self._get_auxiliary_domain_repeats(left_symbol_disc.domains) if repeats != self._get_auxiliary_domain_repeats(right_symbol_disc.domains): raise pybamm.DomainError( "Number of secondary points in subdomains do not match" ) if isinstance(left_mesh, UnstructuredSubMesh): return self._internal_neumann_unstructured( left_symbol_disc, right_symbol_disc, left_mesh, right_mesh, repeats, left_bcs or {}, right_bcs or {}, ) else: return self._internal_neumann_structured( left_symbol_disc, right_symbol_disc, left_mesh, right_mesh, repeats, )
def _internal_neumann_unstructured( self, left_symbol_disc, right_symbol_disc, left_mesh, right_mesh, repeats, left_bcs=None, right_bcs=None, ): left_bcs = left_bcs or {} right_bcs = right_bcs or {} # Find the interface_data entry that pairs left_mesh with right_mesh. # Each entry stores ``other_mesh`` so multi-neighbor topologies pick # the correct partner instead of grabbing the first dict value. interface = next( ( data for data in left_mesh.interface_data.values() if data.get("other_mesh") is right_mesh ), None, ) if interface is None: rev = next( ( data for data in right_mesh.interface_data.values() if data.get("other_mesh") is left_mesh ), None, ) if rev is not None: interface = { "left_cells": rev["right_cells"], "right_cells": rev["left_cells"], "left_faces": rev["right_faces"], "right_faces": rev["left_faces"], "face_areas": rev["face_areas"], "cell_distances": rev["cell_distances"], } if interface is None: raise pybamm.DiscretisationError( "No interface data pairs these two unstructured meshes, so " "the internal gradient between them cannot be formed and the " "domains would be silently decoupled. Check that both meshes " "carry boundary tags (e.g. detect_box_boundaries() for " "axis-aligned boxes) so interface discovery can pair their " "faces." ) left_cells = interface["left_cells"] right_cells = interface["right_cells"] left_faces = interface["left_faces"] n_faces = len(left_cells) d = left_mesh.dimension def lift(matrix): return pybamm.Matrix(self._block_diagonal(matrix, repeats)) def tile(values): return pybamm.Vector(np.tile(values, repeats)) left_sub = csr_matrix( (np.ones(n_faces), (np.arange(n_faces), left_cells)), shape=(n_faces, left_mesh.npts), ) right_sub = csr_matrix( (np.ones(n_faces), (np.arange(n_faces), right_cells)), shape=(n_faces, right_mesh.npts), ) # n = alpha e + k with e the unit left-to-right centroid direction and # n the interface normal, outward from the left cells. normals = left_mesh.face_normals[left_faces] c_left = left_mesh.cell_centroids[left_cells] c_right = right_mesh.cell_centroids[right_cells] delta = c_right - c_left dist = np.linalg.norm(delta, axis=1) e_ij = delta / dist[:, np.newaxis] alpha = self._alpha(np.sum(normals * e_ij, axis=1)) k_vec = normals - alpha[:, np.newaxis] * e_ij two_point = diags(alpha / dist) value = (lift(two_point @ right_sub) @ right_symbol_disc).without_domains() - ( lift(two_point @ left_sub) @ left_symbol_disc ).without_domains() k_vec = self._drop_orthogonal(k_vec) if not k_vec.any(): return value face_centroids = left_mesh.face_centroids[left_faces] d_left = np.linalg.norm(face_centroids - c_left, axis=1) d_right = np.linalg.norm(face_centroids - c_right, axis=1) w_left = d_right / (d_left + d_right) def side_gradient(mesh, bcs, faces, other, other_cells, own_disc, other_disc): interface_rows = { "key": (id(other), hash(faces.tobytes())), "faces": faces, "other_cells": other_cells, "other_centroids": other.cell_centroids[other_cells], "n_other": other.npts, } G, bc_vecs, G_cross = self._least_squares_gradient( mesh, bcs, repeats, interface=interface_rows ) return [ (lift(G[k]) @ own_disc).without_domains() + (lift(G_cross[k]) @ other_disc).without_domains() + bc_vecs[k].without_domains() for k in range(d) ] grad_left = side_gradient( left_mesh, left_bcs, left_faces, right_mesh, right_cells, left_symbol_disc, right_symbol_disc, ) grad_right = side_gradient( right_mesh, right_bcs, interface["right_faces"], left_mesh, left_cells, right_symbol_disc, left_symbol_disc, ) for k in range(d): face_gradient = ( lift(diags(w_left) @ left_sub) @ grad_left[k] + lift(diags(1.0 - w_left) @ right_sub) @ grad_right[k] ) value = value + face_gradient * tile(k_vec[:, k]) return value def _internal_neumann_structured( self, left_symbol_disc, right_symbol_disc, left_mesh, right_mesh, repeats, ): """Fallback for structured meshes (same logic as FiniteVolume).""" left_npts = left_mesh.npts right_npts = right_mesh.npts left_sub_matrix = np.zeros((1, left_npts)) left_sub_matrix[0][left_npts - 1] = 1 left_matrix = pybamm.Matrix(self._block_diagonal(left_sub_matrix, repeats)) right_sub_matrix = np.zeros((1, right_npts)) right_sub_matrix[0][0] = 1 right_matrix = pybamm.Matrix(self._block_diagonal(right_sub_matrix, repeats)) # structured fallback: 1D submeshes expose ``nodes``, not ``vertices`` right_mesh_x = right_mesh.nodes[0] left_mesh_x = left_mesh.nodes[-1] dx = right_mesh_x - left_mesh_x dy_r = (right_matrix / dx) @ right_symbol_disc dy_r = dy_r.without_domains() dy_l = (left_matrix / dx) @ left_symbol_disc dy_l = dy_l.without_domains() return dy_r - dy_l # ------------------------------------------------------------------ # concatenation # ------------------------------------------------------------------
[docs] def concatenation(self, disc_children): """See :meth:`pybamm.SpatialMethod.concatenation`.""" return pybamm.domain_concatenation(disc_children, self.mesh)
# ------------------------------------------------------------------ # Not implemented # ------------------------------------------------------------------
[docs] def indefinite_integral(self, child, discretised_child, direction): raise NotImplementedError( "Indefinite integral is not supported on unstructured meshes. " "Use the direct PDE form instead." )
[docs] def delta_function(self, symbol, discretised_symbol): raise NotImplementedError( "Delta function is not supported on unstructured meshes." )