Source code for pybamm.meshes.unstructured_submesh

from enum import Enum

import numpy as np

import pybamm

from .meshes import MeshGenerator, SubMesh


class ElementType(str, Enum):
    """Element types supported by :class:`UnstructuredSubMesh`.

    The ``str`` mixin keeps plain strings (``element_type="quad"``)
    working everywhere an ``ElementType`` is expected.
    """

    TRIANGLE = "triangle"
    QUAD = "quad"
    TETRAHEDRON = "tetrahedron"
    HEXAHEDRON = "hexahedron"

    @property
    def meshio_name(self):
        """This element type's cell-type name in meshio's vocabulary."""
        return "tetra" if self is ElementType.TETRAHEDRON else self.value


[docs] class UnstructuredSubMesh(SubMesh): """ Cell-centered finite volume submesh on polygonal/polyhedral elements. Supported element types: * **2D**: triangles (3 vertices) or quadrilaterals (4 vertices) * **3D**: tetrahedra (4 vertices) or hexahedra (8 vertices) Hexahedra must have planar faces: volumes and face fluxes are ill-defined on warped (non-planar-faced) hexes, so construction raises a :class:`pybamm.GeometryError` for them. All operators are dimension-agnostic: the same code path handles both 2D and 3D, with dimension inferred from ``vertices.shape[1]``. Parameters ---------- vertices : numpy.ndarray Vertex coordinates, of shape ``(n_vertices, d)`` (d = 2 or 3). elements : numpy.ndarray Element vertex indices, of shape ``(n_cells, n_verts_per_cell)``. For 2D: 3 (triangles) or 4 (quads). For 3D: 4 (tetrahedra) or 8 (hexahedra). coord_sys : str, optional Coordinate system, default ``"cartesian"``. boundary_faces : dict[str, numpy.ndarray] or None, optional Maps boundary name to face indices. If ``None``, no boundary tags are assigned: tags must come from the mesh source (the built-in generator tags its own box output, file generators use the mesh file's boundary names), or call :meth:`detect_box_boundaries` for a hand-built axis-aligned box. """ def __init__(self, vertices, elements, coord_sys="cartesian", boundary_faces=None): super().__init__() self.vertices = np.asarray(vertices, dtype=float) self.elements = np.asarray(elements, dtype=int) self.dimension = self.vertices.shape[1] self.coord_sys = coord_sys verts_per_cell = self.elements.shape[1] if self.dimension == 2 and verts_per_cell == 4: self.element_type = ElementType.QUAD elif self.dimension == 2 and verts_per_cell == 3: self.element_type = ElementType.TRIANGLE elif self.dimension == 3 and verts_per_cell == 4: self.element_type = ElementType.TETRAHEDRON elif self.dimension == 3 and verts_per_cell == 8: self.element_type = ElementType.HEXAHEDRON else: raise pybamm.GeometryError( f"Unsupported: {verts_per_cell} vertices per cell in {self.dimension}D" ) self._compute_cell_geometry() self._build_face_connectivity() self._compute_face_geometry() self.boundary_faces = boundary_faces if boundary_faces is not None else {} self.npts = len(self.elements) self.npts_lr = self.npts self.npts_tb = 1 self.internal_boundaries = [] self.interface_data = {} # ------------------------------------------------------------------ # Cell geometry # ------------------------------------------------------------------ def _compute_cell_geometry(self): verts = self.vertices[self.elements] # (n_cells, n_verts, d) # Vertex mean is the exact centroid for simplices (triangles, tets); # quads and hexes overwrite it with the true area/volume centroid. self.cell_centroids = verts.mean(axis=1) if self.element_type == ElementType.TRIANGLE: v0, v1, v2 = verts[:, 0], verts[:, 1], verts[:, 2] cross = (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - ( v1[:, 1] - v0[:, 1] ) * (v2[:, 0] - v0[:, 0]) self.cell_volumes = 0.5 * np.abs(cross) elif self.element_type == ElementType.QUAD: # Shoelace formula for arbitrary simple quadrilaterals # Vertices ordered: v0, v1, v2, v3 (counterclockwise or clockwise) x = verts[:, :, 0] # (n_cells, 4) y = verts[:, :, 1] # (n_cells, 4) # shoelace: sum_i (x_i * y_{i+1} - x_{i+1} * y_i) x_next = np.roll(x, -1, axis=1) y_next = np.roll(y, -1, axis=1) cross = x * y_next - x_next * y signed_area = 0.5 * np.sum(cross, axis=1) self.cell_volumes = np.abs(signed_area) # Polygon centroid (exact for non-parallelogram quads, where the # vertex mean is not) cx = np.sum((x + x_next) * cross, axis=1) / (6.0 * signed_area) cy = np.sum((y + y_next) * cross, axis=1) / (6.0 * signed_area) self.cell_centroids = np.column_stack([cx, cy]) elif self.element_type == ElementType.TETRAHEDRON: v0, v1, v2, v3 = verts[:, 0], verts[:, 1], verts[:, 2], verts[:, 3] d1 = v1 - v0 d2 = v2 - v0 d3 = v3 - v0 det = ( d1[:, 0] * (d2[:, 1] * d3[:, 2] - d2[:, 2] * d3[:, 1]) - d1[:, 1] * (d2[:, 0] * d3[:, 2] - d2[:, 2] * d3[:, 0]) + d1[:, 2] * (d2[:, 0] * d3[:, 1] - d2[:, 1] * d3[:, 0]) ) self.cell_volumes = np.abs(det) / 6.0 elif self.element_type == ElementType.HEXAHEDRON: # 5-tet decomposition, exact only for planar-faced hexes (warped # faces are rejected in _compute_face_geometry). Tet volumes are # summed signed so a degenerate or inverted cell cannot cancel # into a plausible positive volume. t = verts[:, self._HEX_TETS] # (n_cells, 5, 4, d) d1 = t[:, :, 1] - t[:, :, 0] d2 = t[:, :, 2] - t[:, :, 0] d3 = t[:, :, 3] - t[:, :, 0] tet_vols = np.einsum("ijk,ijk->ij", d1, np.cross(d2, d3)) / 6.0 vol = tet_vols.sum(axis=1) self.cell_volumes = np.abs(vol) # Volume-weighted tet centroids: exact for planar-faced hexes # such as frusta, where the vertex mean is not moment = np.einsum("ij,ijk->ik", tet_vols, t.mean(axis=2)) safe_vol = np.where(vol == 0.0, 1.0, vol) nonzero = (vol != 0.0)[:, None] self.cell_centroids = np.where( nonzero, moment / safe_vol[:, None], self.cell_centroids ) # ------------------------------------------------------------------ # Face-cell connectivity # ------------------------------------------------------------------ def _build_face_connectivity(self): """Extract faces, identify internal / boundary, record owner-neighbor.""" if self.element_type == ElementType.HEXAHEDRON: n_verts_per_face = 4 else: n_verts_per_face = self.dimension elems = self.elements n_cells = len(elems) # Build all faces at once using local face definitions if self.element_type == ElementType.QUAD: idx = np.arange(4) local = np.stack([idx, (idx + 1) % 4], axis=1) # (4, 2) elif self.element_type == ElementType.TRIANGLE: local = np.array([[1, 2], [0, 2], [0, 1]]) # skip vertex 0, 1, 2 elif self.element_type == ElementType.TETRAHEDRON: local = np.array([[1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2]]) elif self.element_type == ElementType.HEXAHEDRON: local = np.array(self._HEX_FACES) n_fpc = len(local) all_faces = elems[:, local].reshape(-1, n_verts_per_face) cell_ids = np.repeat(np.arange(n_cells), n_fpc) # Canonical keys: sort vertex indices within each face sorted_faces = np.sort(all_faces, axis=1) # Find unique faces and which are shared (internal) vs single (boundary) _, inverse, counts = np.unique( sorted_faces, axis=0, return_inverse=True, return_counts=True ) # numpy 2.0.0 (the declared floor) returns inverse with shape (n, 1); # 2.0.1+ returns (n,). Flatten so masks below stay 1-D everywhere. inverse = inverse.reshape(-1) # A manifold mesh shares each face between at most two cells. A count of # three or more means overlapping or non-conforming elements; such faces # match neither branch below and would silently vanish, so reject them. if (counts > 2).any(): raise pybamm.GeometryError( "Unstructured mesh is non-manifold: at least one face is shared " "by more than two cells. Check for overlapping or duplicated " "elements in the input mesh." ) is_internal = counts[inverse] == 2 is_boundary = counts[inverse] == 1 # For internal faces, we need owner/neighbor pairs. # Group by unique face index; first occurrence is owner, second is neighbor. internal_mask = is_internal int_inv = inverse[internal_mask] int_cells = cell_ids[internal_mask] int_faces_raw = all_faces[internal_mask] # Sort by unique-face-id to pair them up: [owner0, neighbor0, owner1, neighbor1, ...] order = np.argsort(int_inv, kind="stable") int_cells_sorted = int_cells[order] int_faces_sorted = int_faces_raw[order] internal_owner = int_cells_sorted[0::2] internal_neighbor = int_cells_sorted[1::2] internal_face_verts = int_faces_sorted[0::2] # Boundary faces bnd_face_verts = all_faces[is_boundary] bnd_owners = cell_ids[is_boundary] n_internal = len(internal_owner) n_boundary = len(bnd_owners) self.faces = np.concatenate([internal_face_verts, bnd_face_verts], axis=0) self.face_owner = np.concatenate([internal_owner, bnd_owners]) self.face_neighbor = internal_neighbor self.n_internal_faces = n_internal self._n_boundary_faces = n_boundary self._boundary_face_start = n_internal # Standard hex vertex ordering: # 0=(i,j,k) 1=(i+1,j,k) 2=(i+1,j+1,k) 3=(i,j+1,k) # 4=(i,j,k+1) 5=(i+1,j,k+1) 6=(i+1,j+1,k+1) 7=(i,j+1,k+1) _HEX_FACES = [ (0, 3, 7, 4), # x- (left) (1, 2, 6, 5), # x+ (right) (0, 1, 5, 4), # y- (front) (3, 2, 6, 7), # y+ (back) (0, 1, 2, 3), # z- (bottom) (4, 5, 6, 7), # z+ (top) ] # 5-tet split for volume/centroid computation, each tet ordered so its # signed volume is positive for a right-handed hex _HEX_TETS = np.array( [(0, 1, 2, 5), (0, 2, 3, 7), (0, 5, 7, 4), (2, 7, 5, 6), (0, 5, 2, 7)] ) # ------------------------------------------------------------------ # Face geometry # ------------------------------------------------------------------ def _compute_face_geometry(self): face_verts = self.vertices[self.faces] self.face_centroids = face_verts.mean(axis=1) if self.dimension == 2: v0, v1 = face_verts[:, 0], face_verts[:, 1] edge = v1 - v0 self.face_areas = np.linalg.norm(edge, axis=1) normals = np.column_stack([edge[:, 1], -edge[:, 0]]) elif self.element_type == ElementType.HEXAHEDRON: # Face = quad: 4 vertices. Area via cross product of diagonals. v0 = face_verts[:, 0] v1 = face_verts[:, 1] v2 = face_verts[:, 2] v3 = face_verts[:, 3] # Quad areas, normals, and the 5-tet cell volumes are only # well-defined when all four vertices of a face are coplanar, # so warped (non-planar) faces are rejected outright. plane_normal = np.cross(v1 - v0, v2 - v0) plane_norm = np.linalg.norm(plane_normal, axis=1) safe_norm = np.where(plane_norm < 1e-30, 1.0, plane_norm) offset = np.abs( np.einsum("ij,ij->i", v3 - v0, plane_normal / safe_norm[:, None]) ) diag_len = np.linalg.norm(v2 - v0, axis=1) warped = offset > 1e-8 * np.maximum(diag_len, 1e-30) if warped.any(): raise pybamm.GeometryError( f"{int(warped.sum())} hexahedral face(s) are non-planar " "(warped): the fourth vertex does not lie in the plane of " "the other three. Volumes and face fluxes are ill-defined " "on warped hexahedra. Fix the mesh, or use tetrahedra." ) diag1 = v2 - v0 diag2 = v3 - v1 cross = np.cross(diag1, diag2) self.face_areas = 0.5 * np.linalg.norm(cross, axis=1) normals = cross # Area-weighted centroid over the (0,1,2)/(0,2,3) triangle split: # exact for planar non-parallelogram quads (e.g. trapezoids), # where the vertex mean is not. Signed areas taken along the face # normal keep non-convex planar quads exact too. n_hat = plane_normal / safe_norm[:, None] area1 = 0.5 * np.einsum("ij,ij->i", np.cross(v1 - v0, v2 - v0), n_hat) area2 = 0.5 * np.einsum("ij,ij->i", np.cross(v2 - v0, v3 - v0), n_hat) total = area1 + area2 safe_total = np.where(np.abs(total) < 1e-30, 1.0, total) weighted = ( area1[:, None] * (v0 + v1 + v2) + area2[:, None] * (v0 + v2 + v3) ) / (3.0 * safe_total[:, None]) nonzero = np.abs(total) >= 1e-30 self.face_centroids[nonzero] = weighted[nonzero] else: # Face = triangle: 3 vertices v0, v1, v2 = face_verts[:, 0], face_verts[:, 1], face_verts[:, 2] cross = np.cross(v1 - v0, v2 - v0) self.face_areas = 0.5 * np.linalg.norm(cross, axis=1) normals = cross # Normalize norms = np.linalg.norm(normals, axis=1, keepdims=True) norms = np.where(norms < 1e-30, 1.0, norms) normals = normals / norms # Orient outward from owner cell: if the normal points from the # owner centroid toward the face centroid, keep it; otherwise flip. owner_centroids = self.cell_centroids[self.face_owner] to_face = self.face_centroids - owner_centroids dot = np.sum(normals * to_face, axis=1) flip = dot < 0 normals[flip] *= -1 self.face_normals = normals # ------------------------------------------------------------------ # Boundary identification # ------------------------------------------------------------------
[docs] def detect_box_boundaries(self): """Tag boundary faces of an axis-aligned box by outward normal. Assigns ``left``/``right`` (x), ``front``/``back`` (y, 3D only), and ``bottom``/``top`` (z) by each exterior face's dominant normal direction, overwriting ``boundary_faces``. Only meaningful for axis-aligned box domains (each face genuinely normal to one axis); on curved geometry the buckets are not surfaces. The built-in generator calls this on its output; meshes from files should carry their own boundary names instead. """ bnd_start = self._boundary_face_start bnd_centroids = self.face_centroids[bnd_start:] if len(bnd_centroids) == 0: self.boundary_faces = {} return # Classify every external face by its outward normal direction so all # protrusions (e.g., tabs) get assigned a BC bucket. bnd_normals = self.face_normals[bnd_start:] n_bnd = len(bnd_normals) # A zero-area face has no normal direction and would land in an # arbitrary bucket; surface it instead. if np.any(self.face_areas[bnd_start:] < 1e-30): raise pybamm.GeometryError( "Mesh has degenerate (zero-area) boundary faces; boundary " "detection cannot classify them." ) axis = np.argmax(np.abs(bnd_normals), axis=1) positive = (bnd_normals[np.arange(n_bnd), axis] >= 0).astype(int) if self.dimension == 3: names = ["left", "right", "front", "back", "bottom", "top"] slot = axis * 2 + positive else: names = ["left", "right", "bottom", "top"] slot = np.where(axis == 0, positive, 2 + positive) self.boundary_faces = {} for k, name in enumerate(names): indices = np.nonzero(slot == k)[0] + bnd_start if len(indices) > 0: self.boundary_faces[name] = indices
# ------------------------------------------------------------------ # Combining domains # ------------------------------------------------------------------
[docs] @classmethod def combine(cls, submeshes): """ Weld a list of :class:`UnstructuredSubMesh` objects into one mesh. Coincident boundary nodes at domain interfaces are merged so that face connectivity spans across domains. Parameters ---------- submeshes : list of UnstructuredSubMesh The submeshes to combine, in order. Returns ------- UnstructuredSubMesh A single mesh covering the union of the input domains. """ from scipy.spatial import cKDTree element_types = {sm.element_type for sm in submeshes} if len(element_types) > 1: raise pybamm.GeometryError( f"Cannot combine unstructured submeshes of different element " f"types: {sorted(t.value for t in element_types)}. All domains " f"must use the same element type." ) # Weld coincident nodes across submeshes regardless of which face tag # they belong to, so that interfaces of arbitrary topology (star, tree, # graph) become internal faces and TPFA handles cross-region flux # without internal Neumann book-keeping. tol = _geometric_tolerance(submeshes) combined_nodes = np.asarray(submeshes[0].vertices, dtype=float) global_maps = [np.arange(len(combined_nodes))] for sm in submeshes[1:]: d, j = cKDTree(combined_nodes).query(sm.vertices) new = d >= tol global_maps.append( np.where(new, np.cumsum(new) - 1 + len(combined_nodes), j) ) combined_nodes = np.vstack([combined_nodes, sm.vertices[new]]) combined_elements = np.concatenate( [gm[sm.elements] for gm, sm in zip(global_maps, submeshes, strict=True)], axis=0, ) combined = cls( combined_nodes, combined_elements, coord_sys=submeshes[0].coord_sys, ) # The combined mesh starts with no tags: every tag is propagated from # the input submeshes by matching boundary face centroids. Faces that # welding turned internal (interface faces) match no combined boundary # face and drop out naturally. tag_centroids = {} # tag -> list of centroid arrays for sm in submeshes: for tag, face_indices in sm.boundary_faces.items(): tag_centroids.setdefault(tag, []).append( sm.face_centroids[face_indices] ) if tag_centroids: bnd_start = combined._boundary_face_start bnd_centroids = combined.face_centroids[bnd_start:] if len(bnd_centroids) > 0: tree = cKDTree(bnd_centroids) # Welding may move nodes (and so centroids) by up to the weld # tolerance, so tag matching must never be tighter than it match_tol = tol for tag, centroid_list in tag_centroids.items(): all_src = np.concatenate(centroid_list, axis=0) dists, idxs = tree.query(all_src) matched = idxs[dists < match_tol] if len(matched) > 0: combined.boundary_faces[tag] = np.unique(matched) + bnd_start # Welding only produces internal faces where interface nodes coincide. # If a region shares no interface with the rest (mismatched transverse # grids, wrong units, or a non-conforming input mesh) it stays a # separate connected component and no flux can cross into it, which # otherwise solves silently to a wrong answer. Reuse the integer face # connectivity (no distance tolerance) to require a single component. if len(submeshes) > 1 and combined.npts > 1: from scipy.sparse import csr_matrix from scipy.sparse.csgraph import connected_components n_int = combined._boundary_face_start rows = np.concatenate([combined.face_owner[:n_int], combined.face_neighbor]) cols = np.concatenate([combined.face_neighbor, combined.face_owner[:n_int]]) adjacency = csr_matrix( (np.ones(len(rows)), (rows, cols)), shape=(combined.npts, combined.npts), ) n_components, _ = connected_components(adjacency, directed=False) if n_components > 1: raise pybamm.GeometryError( f"Combined unstructured mesh has {n_components} disconnected " f"regions: welding produced no interface between some domains, " f"so no flux could cross and the solve would be silently wrong. " f"Adjacent domains must share a conforming interface — matching " f"transverse grids and coordinate units, or a fragmented " f"(node-shared) mesh from the mesh generator." ) return combined
[docs] def optimize_ordering(self): """Reorder cells using Reverse Cuthill-McKee to reduce Jacobian bandwidth. Permutes all cell-indexed arrays (elements, centroids, volumes, face_owner, face_neighbor, interface_data) so that adjacent cells have nearby indices, minimising the bandwidth of the FVM connectivity matrix. """ from scipy.sparse import csr_matrix from scipy.sparse.csgraph import reverse_cuthill_mckee n = self.npts if n <= 1: return n_int = self._boundary_face_start owners = self.face_owner[:n_int] neighbors = self.face_neighbor rows = np.concatenate([owners, neighbors]) cols = np.concatenate([neighbors, owners]) data = np.ones(len(rows), dtype=np.float64) adj = csr_matrix((data, (rows, cols)), shape=(n, n)) perm = reverse_cuthill_mckee(adj) inv_perm = np.empty(n, dtype=int) inv_perm[perm] = np.arange(n) self.elements = self.elements[perm] self.cell_centroids = self.cell_centroids[perm] self.cell_volumes = self.cell_volumes[perm] self.face_owner = inv_perm[self.face_owner] self.face_neighbor = inv_perm[self.face_neighbor] # In interface_data, "left_cells" indexes this mesh and "right_cells" # indexes the neighbour, so only the former is permuted. The neighbour # holds a mirrored view of the same pairing and is updated with it. for data_dict in self.interface_data.values(): permuted = inv_perm[data_dict["left_cells"]] data_dict["left_cells"] = permuted other = data_dict.get("other_mesh") if other is None: continue for mirror in other.interface_data.values(): if mirror.get("other_mesh") is self: mirror["right_cells"] = permuted
[docs] def boundary_loops(self): """Return boundary loops as a list of ``matplotlib.path.Path`` (2D only). Walks boundary edges to extract one or more closed loops. The first path is the outer boundary (largest area); subsequent paths are holes. Use this to test containment: a point is in the domain if it is inside the outer loop and outside all hole loops. """ if self.dimension != 2: return None from matplotlib.path import Path bnd_start = self._boundary_face_start bnd_edges = self.faces[bnd_start:] if len(bnd_edges) == 0: return None adj: dict[int, list[tuple[int, int]]] = {} for i, edge in enumerate(bnd_edges): v0, v1 = int(edge[0]), int(edge[1]) adj.setdefault(v0, []).append((i, v1)) adj.setdefault(v1, []).append((i, v0)) visited: set[int] = set() loops: list[list[int]] = [] for start_edge_idx in range(len(bnd_edges)): if start_edge_idx in visited: continue start_v = int(bnd_edges[start_edge_idx][0]) loop = [start_v] current = start_v while True: found = False for edge_idx, next_v in adj[current]: if edge_idx not in visited: visited.add(edge_idx) loop.append(next_v) current = next_v found = True break if not found: break loops.append(loop) def signed_area(pts): x, y = pts[:, 0], pts[:, 1] return 0.5 * np.sum(x[:-1] * y[1:] - x[1:] * y[:-1]) loop_data = [] for loop in loops: pts = self.vertices[loop] sa = signed_area(pts) loop_data.append((abs(sa), pts)) loop_data.sort(key=lambda t: t[0], reverse=True) paths = [] for pts in (ld[1] for ld in loop_data): codes = [Path.LINETO] * len(pts) codes[0] = Path.MOVETO codes[-1] = Path.CLOSEPOLY paths.append(Path(pts, codes)) return paths
[docs] def contains_points_3d(self, query_pts): """Test whether 3D points lie inside the mesh domain. Uses the generalized winding number (Van Oosterom--Strackee signed solid angle sum over all boundary triangles). Points inside the domain return ``True``; points outside or inside internal cavities return ``False``. """ query_pts = np.asarray(query_pts, dtype=np.float64) bnd_start = self._boundary_face_start bnd_fv = self.faces[bnd_start:] bnd_normals = self.face_normals[bnd_start:] n_vpf = bnd_fv.shape[1] if n_vpf == 3: tri_idx = bnd_fv tri_normals = bnd_normals elif n_vpf == 4: tri_idx = np.concatenate( [bnd_fv[:, [0, 1, 2]], bnd_fv[:, [0, 2, 3]]], axis=0 ) tri_normals = np.concatenate([bnd_normals, bnd_normals], axis=0) else: raise pybamm.GeometryError( f"contains_points_3d: unsupported face with {n_vpf} vertices" ) v0 = self.vertices[tri_idx[:, 0]] v1 = self.vertices[tri_idx[:, 1]] v2 = self.vertices[tri_idx[:, 2]] # Ensure consistent CCW orientation from outside (matching outward normals) cross = np.cross(v1 - v0, v2 - v0) flip = np.sum(cross * tri_normals, axis=1) < 0 v1_fixed = v1.copy() v2_fixed = v2.copy() v1_fixed[flip] = v2[flip] v2_fixed[flip] = v1[flip] n_query = len(query_pts) winding = np.zeros(n_query) # Loop over query points (usually few), vectorised over the many # boundary triangles — the reverse nesting costs O(n_triangles) # per point regardless of how few points are asked about. for i in range(n_query): a = v0 - query_pts[i] b = v1_fixed - query_pts[i] c = v2_fixed - query_pts[i] an = np.linalg.norm(a, axis=1) bn = np.linalg.norm(b, axis=1) cn = np.linalg.norm(c, axis=1) num = np.einsum("ij,ij->i", a, np.cross(b, c)) den = ( an * bn * cn + np.einsum("ij,ij->i", a, b) * cn + np.einsum("ij,ij->i", a, c) * bn + np.einsum("ij,ij->i", b, c) * an ) winding[i] = 2.0 * np.arctan2(num, den).sum() return winding > 2.0 * np.pi
# ====================================================================== # Mesh generators # ======================================================================
[docs] class UnstructuredMeshGenerator(MeshGenerator): """ Built-in generator that creates meshes from structured grids. * **2D**: rectangular domain meshed as quads, or triangulated by splitting each quad into 2 triangles. * **3D**: rectangular prism meshed as hexahedra, or split into 6 tetrahedra per hex (Kuhn decomposition). On box domains, hexahedra (the 3D default) are preferred for real simulations: same resolution with 6x fewer cells and ideal face orthogonality. The simplex element types chiefly exercise the same code paths as user-supplied (e.g. gmsh) meshes without needing a mesh file, which is useful for testing and validation. Parameters ---------- coord_sys : str, optional Coordinate system, default ``"cartesian"``. element_type : str, optional ``"quad"`` or ``"triangle"`` in 2D; ``"hexahedron"`` or ``"tetrahedron"`` in 3D. If ``None``, defaults to ``"triangle"`` in 2D and ``"hexahedron"`` in 3D. """ def __init__(self, coord_sys="cartesian", element_type=None): self.submesh_type = UnstructuredSubMesh self.submesh_params = {} self.coord_sys = coord_sys self._element_type = element_type def __call__(self, lims, npts): spatial_vars, spatial_lims = self._parse_lims(lims) dim = len(spatial_vars) if dim == 2: return self._generate_2d(spatial_vars, spatial_lims, npts) elif dim == 3: return self._generate_3d(spatial_vars, spatial_lims, npts) else: raise pybamm.GeometryError( f"UnstructuredMeshGenerator supports 2D and 3D, got {dim} spatial variables" ) def __repr__(self): return "Generator for UnstructuredSubMesh" # ------------------------------------------------------------------ @staticmethod def _parse_lims(lims): spatial_vars = [] spatial_lims = [] for var, var_lims in lims.items(): if var == "tabs": continue if isinstance(var, str): var = getattr(pybamm.standard_spatial_vars, var) spatial_vars.append(var) spatial_lims.append(var_lims) return spatial_vars, spatial_lims # ------------------------------------------------------------------ # 2D # ------------------------------------------------------------------ def _generate_2d(self, spatial_vars, spatial_lims, npts): var_x, var_z = spatial_vars lim_x, lim_z = spatial_lims nx = npts[var_x.name] nz = npts[var_z.name] x_edges = np.linspace(lim_x["min"], lim_x["max"], nx + 1) z_edges = np.linspace(lim_z["min"], lim_z["max"], nz + 1) etype = self._element_type or ElementType.TRIANGLE if etype == ElementType.QUAD: nodes, elements = _make_quad_grid(x_edges, z_edges) elif etype == ElementType.TRIANGLE: nodes, elements = _quad_to_tri(x_edges, z_edges) else: raise pybamm.GeometryError(f"Unsupported 2D element_type: {etype!r}") submesh = UnstructuredSubMesh(nodes, elements, coord_sys=self.coord_sys) # The generator's output is an axis-aligned box by construction submesh.detect_box_boundaries() return submesh # ------------------------------------------------------------------ # 3D # ------------------------------------------------------------------ def _generate_3d(self, spatial_vars, spatial_lims, npts): var_x, var_y, var_z = spatial_vars lim_x, lim_y, lim_z = spatial_lims nx = npts[var_x.name] ny = npts[var_y.name] nz = npts[var_z.name] x_edges = np.linspace(lim_x["min"], lim_x["max"], nx + 1) y_edges = np.linspace(lim_y["min"], lim_y["max"], ny + 1) z_edges = np.linspace(lim_z["min"], lim_z["max"], nz + 1) etype = self._element_type or ElementType.HEXAHEDRON if etype == ElementType.HEXAHEDRON: nodes, elements = _hex_grid(x_edges, y_edges, z_edges) elif etype == ElementType.TETRAHEDRON: nodes, elements = _hex_to_tet(x_edges, y_edges, z_edges) else: raise pybamm.GeometryError(f"Unsupported 3D element_type: {etype!r}") submesh = UnstructuredSubMesh(nodes, elements, coord_sys=self.coord_sys) # The generator's output is an axis-aligned box by construction submesh.detect_box_boundaries() return submesh
[docs] class UserSuppliedUnstructuredMesh(MeshGenerator): """ Load an unstructured mesh from an external file via *meshio*. Supported cell types are tetrahedra (3D) and triangles or quadrilaterals (2D). Hexahedral file meshes are rejected: file meshes commonly contain warped (non-planar-faced) hexes, whose volumes and face fluxes are ill-defined. Convert such meshes to tetrahedra before loading. The interface between adjacent domains must be **conforming**: the two sides must share the same interface nodes, so that welding in :meth:`UnstructuredSubMesh.combine` turns the interface into internal faces. In gmsh, build the regions from one geometry or fragment the parts (``BooleanFragments`` / ``Coherence``) so the shared surface is meshed once. A non-conforming interface raises a :class:`pybamm.GeometryError` when the domains are combined. Parameters ---------- filepath : str Path to the mesh file (GMSH ``.msh``, VTK ``.vtu``, etc.). subdomain_mapping : dict[str, int] or None Maps PyBaMM domain name to physical group / cell-data tag. boundary_mapping : dict[str, int] or None Maps boundary name to physical group / facet tag. coord_sys : str, optional Coordinate system, default ``"cartesian"``. merge_tolerance : float or None, optional Absolute length (in the mesh file's units) below which coincident nodes across cell blocks are welded, by quantising coordinates to a grid of this spacing. Default ``1e-12``; pass ``None`` or ``0`` to disable welding. """ def __init__( self, filepath, subdomain_mapping=None, boundary_mapping=None, coord_sys="cartesian", merge_tolerance=1e-12, ): self.submesh_type = UnstructuredSubMesh self.submesh_params = {} self.filepath = filepath self.subdomain_mapping = subdomain_mapping or {} self.boundary_mapping = boundary_mapping or {} self.coord_sys = coord_sys self.merge_tolerance = merge_tolerance self._cached_mesh = None def __call__(self, lims, npts): meshio = pybamm.import_optional_dependency("meshio") if self._cached_mesh is None: self._cached_mesh = meshio.read(self.filepath) mesh = self._cached_mesh nodes = mesh.points # Determine which domain is being requested from the lims keys domain_name = self._domain_name_from_lims(lims) # Extract supported cells (triangles/quads or tets/hexes) cells, cell_type = self._extract_supported_cells(mesh) if domain_name and domain_name in self.subdomain_mapping: tag_value = self.subdomain_mapping[domain_name] cell_mask = self._get_cell_mask(mesh, cell_type, tag_value) elements = cells[cell_mask] else: elements = cells # Weld coincident nodes across cell blocks so touching regions # (e.g. body-tab interfaces) are thermally connected. if self.merge_tolerance is not None and self.merge_tolerance > 0: scale = 1.0 / self.merge_tolerance quantized = np.round(nodes * scale).astype(np.int64) _, unique_idx, inverse = np.unique( quantized, axis=0, return_index=True, return_inverse=True ) # numpy 2.0.0 returns inverse with shape (n, 1); 2.0.1+ (n,) inverse = inverse.reshape(-1) nodes = nodes[unique_idx] elements = inverse[elements] # Re-index nodes to compact numbering unique_nodes = np.unique(elements) node_map = np.full(nodes.shape[0], -1, dtype=int) node_map[unique_nodes] = np.arange(len(unique_nodes)) compact_nodes = nodes[unique_nodes] compact_elements = node_map[elements] # Trim to 2D if all z-coordinates are zero if compact_nodes.shape[1] == 3 and np.allclose(compact_nodes[:, 2], 0): compact_nodes = compact_nodes[:, :2] submesh = UnstructuredSubMesh( compact_nodes, compact_elements, coord_sys=self.coord_sys ) if self.boundary_mapping: facet_type = "triangle" if cell_type == ElementType.TETRAHEDRON else "line" facets, facet_tags = _extract_tagged_facets(mesh, facet_type) if facets is None: pybamm.logger.warning( f"boundary_mapping given but no tagged '{facet_type}' " f"facets found in {self.filepath}; no boundary tags set" ) else: if self.merge_tolerance is not None and self.merge_tolerance > 0: facets = inverse[facets] facets = node_map[facets] # Facets of other subdomains reference nodes outside this # submesh and cannot match in_domain = (facets >= 0).all(axis=1) for name, tag in self.boundary_mapping.items(): matched = _match_facets_to_boundary_faces( facets[in_domain & (facet_tags == tag)], submesh ) if len(matched) > 0: submesh.boundary_faces[name] = matched else: pybamm.logger.warning( f"boundary_mapping entry {name!r} (tag {tag}) " f"matched no boundary faces of this submesh" ) return submesh def __repr__(self): return f"UserSuppliedUnstructuredMesh({self.filepath})" @staticmethod def _domain_name_from_lims(lims): for var in lims: if var == "tabs": continue if isinstance(var, str): name = var else: name = var.name for prefix in ("x_n", "x_s", "x_p"): if name.startswith(prefix): domain_map = { "x_n": "negative electrode", "x_s": "separator", "x_p": "positive electrode", } return domain_map.get(prefix) return None @staticmethod def _extract_supported_cells(mesh): # Hexahedra from files are rejected outright rather than silently # dropped: file meshes commonly contain warped (non-planar-faced) # hexes, for which cell volumes and face fluxes are ill-defined. if any( block.type == ElementType.HEXAHEDRON.meshio_name for block in mesh.cells ): raise pybamm.GeometryError( "Hexahedral cells in mesh files are not supported: warped " "(non-planar-faced) hexahedra have ill-defined volumes and " "face fluxes. Convert the mesh to tetrahedra (e.g. with " "gmsh or meshio) and reload. Hexahedral meshes are still " "available through pybamm.UnstructuredMeshGenerator, whose " "axis-aligned cells are always well-defined." ) # Prefer 3D cells when present, otherwise fall back to 2D. for cell_type in ( ElementType.TETRAHEDRON, ElementType.TRIANGLE, ElementType.QUAD, ): blocks = [ block.data for block in mesh.cells if block.type == cell_type.meshio_name ] if blocks: if len(blocks) == 1: return blocks[0], cell_type return np.concatenate(blocks, axis=0), cell_type raise pybamm.GeometryError( "No supported cells found in mesh file (expected tetra/triangle/quad)" ) @staticmethod def _get_cell_mask(mesh, cell_type, tag_value): for data_list in mesh.cell_data.values(): matching = [ data for block, data in zip(mesh.cells, data_list, strict=False) if block.type == cell_type.meshio_name ] if matching: if len(matching) == 1: return matching[0] == tag_value return np.concatenate(matching, axis=0) == tag_value raise pybamm.GeometryError( f"Could not find cell data tag {tag_value} for cell type '{cell_type}'" )
# ====================================================================== # Tagged-region mesh generator # ======================================================================
[docs] class TaggedSubMeshGenerator(MeshGenerator): """ Build an :class:`UnstructuredSubMesh` from cells of a single Gmsh physical group in a ``.msh`` file. Use one instance per region in a multi-domain pybamm model — the region name doubles as the pybamm domain name. Compare to :class:`UserSuppliedUnstructuredMesh`, which routes multiple regions through one generator by introspecting ``lims``; ``TaggedSubMeshGenerator`` is simpler when the model already supplies one mesh generator per domain. Regions that share an interface must be conforming across it (the shared surface meshed once, so both regions reference the same interface nodes), or combining the domains raises a :class:`pybamm.GeometryError`. Fragment the geometry in gmsh (``BooleanFragments`` / ``Coherence``) to guarantee this. Parameters ---------- region : str Gmsh physical-group name (key in ``meshio.read(...).field_data``). mesh_path : str or pathlib.Path Path to the ``.msh`` file. scale : float, optional Multiplier applied to mesh node coordinates (e.g. ``1e-3`` to convert mm to m). Default ``1.0``. coord_sys : str, optional Coordinate system label, default ``"cartesian"``. boundary_mapping : dict[str, str or int] or None, optional Maps boundary name to a gmsh physical *surface* group, given as its ``field_data`` name or integer tag. Matching tagged surface triangles become the named entries in ``boundary_faces``. Without it the submesh carries no boundary tags. """ _mesh_cache: dict = {} def __init__( self, region, mesh_path, scale=1.0, coord_sys="cartesian", boundary_mapping=None ): self.submesh_type = UnstructuredSubMesh self.submesh_params = {} self._mesh_path = mesh_path self._region = region self._scale = float(scale) self.coord_sys = coord_sys self.boundary_mapping = boundary_mapping or {} @classmethod def _read(cls, path): if path not in cls._mesh_cache: meshio = pybamm.import_optional_dependency("meshio") cls._mesh_cache[path] = meshio.read(str(path)) return cls._mesh_cache[path] def __call__(self, lims, npts): m = self._read(self._mesh_path) if self._region not in m.field_data: raise pybamm.GeometryError( f"region {self._region!r} not in mesh field_data; " f"available: {list(m.field_data)}" ) tag_id = int(m.field_data[self._region][0]) tet_blocks = [] for block, tags in zip( m.cells, m.cell_data.get("gmsh:physical", []), strict=False ): if block.type != ElementType.TETRAHEDRON.meshio_name: continue mask = np.asarray(tags, dtype=np.int32) == tag_id if mask.any(): tet_blocks.append(block.data[mask]) if not tet_blocks: raise pybamm.GeometryError(f"no tets for region {self._region!r}") elements = np.concatenate(tet_blocks, axis=0) unique_nodes = np.unique(elements) node_map = np.full(m.points.shape[0], -1, dtype=np.int64) node_map[unique_nodes] = np.arange(len(unique_nodes)) nodes = m.points[unique_nodes] * self._scale submesh = UnstructuredSubMesh( nodes, node_map[elements], coord_sys=self.coord_sys ) if self.boundary_mapping: facets, facet_tags = _extract_tagged_facets(m, "triangle") if facets is None: pybamm.logger.warning( f"boundary_mapping given but no tagged surface triangles " f"found in {self._mesh_path}; no boundary tags set" ) else: facets = node_map[facets] in_domain = (facets >= 0).all(axis=1) for name, group in self.boundary_mapping.items(): if isinstance(group, str): if group not in m.field_data: raise pybamm.GeometryError( f"boundary group {group!r} not in mesh " f"field_data; available: {list(m.field_data)}" ) group = int(m.field_data[group][0]) matched = _match_facets_to_boundary_faces( facets[in_domain & (facet_tags == group)], submesh ) if len(matched) > 0: submesh.boundary_faces[name] = matched else: pybamm.logger.warning( f"boundary_mapping entry {name!r} matched no " f"boundary faces of region {self._region!r}" ) return submesh
# ====================================================================== # Interface data # ======================================================================
[docs] def compute_interface_data(left_mesh, right_mesh, left_name=None, right_name=None): """ Compute coupling data for the interface between two adjacent :class:`UnstructuredSubMesh` objects. Finds "right" boundary faces of *left_mesh* and "left" boundary faces of *right_mesh*, matches them by face centroid position, and records cell indices, face areas, and centroid-to-centroid distances. Domains are assumed to be stacked along x: faces are paired by their transverse (non-x) centroid coordinates. Parameters ---------- left_mesh : UnstructuredSubMesh right_mesh : UnstructuredSubMesh left_name : str or None Domain name of the left mesh (stored as key in ``interface_data``). right_name : str or None Domain name of the right mesh (stored as key in ``interface_data``). Returns ------- dict Keys: ``"left_cells"``, ``"right_cells"``, ``"face_areas"``, ``"cell_distances"``. """ left_bnd = left_mesh.boundary_faces.get("right", np.array([], dtype=int)) right_bnd = right_mesh.boundary_faces.get("left", np.array([], dtype=int)) if len(left_bnd) == 0 or len(right_bnd) == 0: raise pybamm.GeometryError( "Cannot compute interface data: one or both meshes have no " "matching boundary faces ('right' on left_mesh, 'left' on right_mesh)." ) left_centroids = left_mesh.face_centroids[left_bnd] right_centroids = right_mesh.face_centroids[right_bnd] # Match faces by transverse coordinates (all coords except x) left_transverse = left_centroids[:, 1:] right_transverse = right_centroids[:, 1:] # Build a mapping by closest transverse match from scipy.spatial import cKDTree tree = cKDTree(right_transverse) dists, right_indices = tree.query(left_transverse) tol = _geometric_tolerance([left_mesh, right_mesh]) if np.any(dists > tol): raise pybamm.GeometryError( f"Interface faces do not match: max transverse mismatch = {dists.max():.2e}. " "Ensure both meshes have the same transverse grid." ) # The nearest-neighbour query is one-directional: without this check a # surplus right face silently loses its flux, and a doubly-claimed one # double-counts it. if len(left_bnd) != len(right_bnd) or len(np.unique(right_indices)) != len( right_indices ): raise pybamm.GeometryError( f"Interface face pairing is not one-to-one: {len(left_bnd)} 'right' " f"faces on the left mesh vs {len(right_bnd)} 'left' faces on the " f"right mesh. Both sides must expose the same interface faces." ) left_cells = left_mesh.face_owner[left_bnd] right_cells = right_mesh.face_owner[right_bnd[right_indices]] face_areas = left_mesh.face_areas[left_bnd] left_cell_centroids = left_mesh.cell_centroids[left_cells] right_cell_centroids = right_mesh.cell_centroids[right_cells] cell_distances = np.linalg.norm(right_cell_centroids - left_cell_centroids, axis=1) result = { "left_cells": left_cells, "right_cells": right_cells, "face_areas": face_areas, "cell_distances": cell_distances, "other_mesh": right_mesh, } if right_name is not None: left_mesh.interface_data[right_name] = result if left_name is not None: right_mesh.interface_data[left_name] = { "left_cells": right_cells, "right_cells": left_cells, "face_areas": face_areas, "cell_distances": cell_distances, "other_mesh": left_mesh, } return result
# ====================================================================== # Geometric tolerance # ====================================================================== def _geometric_tolerance(submeshes, rel=1e-3): """Distance below which two points of these meshes are the same point. Scaled to the smallest sampled element edge: distinct nodes (and face centroids) are at least one edge length apart, so a small fraction of it can never merge genuinely distinct entities, while absorbing the interface jitter that reduced-precision mesh files and unit conversions produce. An absolute tolerance cannot do both across mesh scales — battery meshes in SI units are ~1e-4 m across. """ min_edge = min( float( np.linalg.norm( sm.vertices[sm.elements[:, 1]] - sm.vertices[sm.elements[:, 0]], axis=1 ).min() ) for sm in submeshes ) return rel * min_edge # ====================================================================== # Boundary facet tagging helpers # ====================================================================== def _extract_tagged_facets(mesh, facet_type): """Concatenate a meshio mesh's facet blocks and their integer tags. Returns ``(facets, tags)`` arrays, or ``(None, None)`` if the mesh has no facet blocks of ``facet_type`` or no cell data to tag them with. Prefers the ``gmsh:physical`` cell-data key, falling back to the first available key. """ block_ids = [i for i, b in enumerate(mesh.cells) if b.type == facet_type] if not block_ids: return None, None data_lists = mesh.cell_data.get("gmsh:physical") if data_lists is None: data_lists = next(iter(mesh.cell_data.values()), None) if data_lists is None: return None, None facets = np.concatenate([mesh.cells[i].data for i in block_ids], axis=0) tags = np.concatenate([np.asarray(data_lists[i]) for i in block_ids]) return facets, tags def _match_facets_to_boundary_faces(facets, submesh): """Return submesh boundary-face indices whose vertex sets match ``facets``.""" bnd_start = submesh._boundary_face_start lookup = { tuple(face): bnd_start + i for i, face in enumerate(np.sort(submesh.faces[bnd_start:], axis=1).tolist()) } matched = { lookup[key] for key in map(tuple, np.sort(facets, axis=1).tolist()) if key in lookup } return np.array(sorted(matched), dtype=int) # ====================================================================== # Grid-to-simplex helpers # ====================================================================== def _make_quad_grid(x_edges, z_edges): """ Build a structured quadrilateral mesh on a rectangle. Vertices are ordered counterclockwise so that the shoelace formula gives a positive area and consecutive-edge face enumeration is consistent. Returns ------- nodes : (n_nodes, 2) elements : (n_cells, 4) """ nx = len(x_edges) - 1 nz = len(z_edges) - 1 xx, zz = np.meshgrid(x_edges, z_edges, indexing="ij") nodes = np.column_stack([xx.ravel(), zz.ravel()]) i, j = np.meshgrid(np.arange(nx), np.arange(nz), indexing="ij") n0 = (i * (nz + 1) + j).ravel() elements = np.column_stack([n0, n0 + (nz + 1), n0 + (nz + 2), n0 + 1]) return nodes, elements def _quad_to_tri(x_edges, z_edges): """ Triangulate a rectangle defined by ``x_edges`` and ``z_edges``. Each quad cell is split into 2 triangles using the lower-left to upper-right diagonal (consistent across all cells for interface conformity). Returns ------- nodes : (n_nodes, 2) elements : (n_cells, 3) """ nx = len(x_edges) - 1 nz = len(z_edges) - 1 xx, zz = np.meshgrid(x_edges, z_edges, indexing="ij") nodes = np.column_stack([xx.ravel(), zz.ravel()]) i, j = np.meshgrid(np.arange(nx), np.arange(nz), indexing="ij") n0 = (i * (nz + 1) + j).ravel() elements = np.empty((2 * len(n0), 3), dtype=int) elements[0::2] = np.column_stack([n0, n0 + (nz + 1), n0 + (nz + 2)]) elements[1::2] = np.column_stack([n0, n0 + (nz + 2), n0 + 1]) return nodes, elements def _hex_grid(x_edges, y_edges, z_edges): """ Create a hexahedral grid from edge arrays. Returns nodes and 8-vertex hex elements suitable for :class:`UnstructuredSubMesh` with ``element_type="hexahedron"``. Vertex ordering per hex matches :attr:`UnstructuredSubMesh._HEX_FACES`: :: 0=(i,j,k) 1=(i+1,j,k) 2=(i+1,j+1,k) 3=(i,j+1,k) 4=(i,j,k+1) 5=(i+1,j,k+1) 6=(i+1,j+1,k+1) 7=(i,j+1,k+1) Returns ------- nodes : (n_nodes, 3) elements : (n_cells, 8) """ nx = len(x_edges) - 1 ny = len(y_edges) - 1 nz = len(z_edges) - 1 xx, yy, zz = np.meshgrid(x_edges, y_edges, z_edges, indexing="ij") nodes = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) # Loop order determines cell numbering and hence Jacobian bandwidth. # Bandwidth = product of the two fastest-varying dimension sizes. # Minimise by putting the largest dimension outermost (slowest). dims = sorted([(nx, "x"), (ny, "y"), (nz, "z")], key=lambda d: d[0], reverse=True) grids = np.meshgrid(*(np.arange(n) for n, _ in dims), indexing="ij") idx = {name: grid.ravel() for (_, name), grid in zip(dims, grids, strict=True)} i, j, k = idx["x"], idx["y"], idx["z"] n0 = i * (ny + 1) * (nz + 1) + j * (nz + 1) + k p = (ny + 1) * (nz + 1) q = nz + 1 elements = np.column_stack( [n0, n0 + p, n0 + p + q, n0 + q, n0 + 1, n0 + p + 1, n0 + p + q + 1, n0 + q + 1] ) return nodes, elements def _hex_to_tet(x_edges, y_edges, z_edges): """ Tetrahedralise a rectangular prism defined by edge arrays. Each hex cell is split into 6 tetrahedra with the Kuhn (Freudenthal) decomposition: every tet shares the main diagonal from vertex 0 to vertex 6, one tet per monotone vertex path between them. The split is identical for every cell and puts the same face-local diagonal on opposite faces of each hex, so any two grids that share a boundary plane and transverse edges triangulate it identically — including across domain boundaries — with no cell-parity bookkeeping. Returns ------- nodes : (n_nodes, 3) elements : (n_cells, 4) """ nx = len(x_edges) - 1 ny = len(y_edges) - 1 nz = len(z_edges) - 1 xx, yy, zz = np.meshgrid(x_edges, y_edges, z_edges, indexing="ij") nodes = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) # Hex vertices numbered: # 0 = (i, j, k ) 4 = (i, j, k+1) # 1 = (i+1, j, k ) 5 = (i+1, j, k+1) # 2 = (i+1, j+1, k ) 6 = (i+1, j+1, k+1) # 3 = (i, j+1, k ) 7 = (i, j+1, k+1) # # One tet per monotone path 0 -> 6, stepping +x/+y/+z in each of the # 6 possible orders (xyz, xzy, yxz, yzx, zxy, zyx). kuhn_tets = np.array( [ (0, 1, 2, 6), (0, 1, 5, 6), (0, 3, 2, 6), (0, 3, 7, 6), (0, 4, 5, 6), (0, 4, 7, 6), ] ) i, j, k = ( grid.ravel() for grid in np.meshgrid( np.arange(nx), np.arange(ny), np.arange(nz), indexing="ij" ) ) n0 = i * (ny + 1) * (nz + 1) + j * (nz + 1) + k p = (ny + 1) * (nz + 1) q = nz + 1 hex_verts = np.column_stack( [n0, n0 + p, n0 + p + q, n0 + q, n0 + 1, n0 + p + 1, n0 + p + q + 1, n0 + q + 1] ) elements = hex_verts[:, kuhn_tets].reshape(-1, 4) return nodes, elements