Skip to content

Commit e154fec

Browse files
Rishab87rtimms
andauthored
Added load_mesh_from_file function to support external 3D meshes. (#5162)
* adding load ext mesh functions and few tests * updated changelog * adding meshio as optional dependency * minor doc changes * adding warnings for non tested file format, docstring instructions & changning name of submesh * ensuring coverage --------- Co-authored-by: Robert Timms <[email protected]>
1 parent 350bc4d commit e154fec

File tree

11 files changed

+7067
-0
lines changed

11 files changed

+7067
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Features
44

5+
- Added helper functions to import external 3D meshes in PyBaMM ([#5162](https://github.com/pybamm-team/PyBaMM/pull/5162))
56
- Added support for algebraic and differential surface form in composite models. ([#5165](https://github.com/pybamm-team/PyBaMM/pull/5165))
67
- Adds a composite electrode electrode soh model ([#5160](https://github.com/pybamm-team/PyBaMM/pull/5129))
78
- Generalises `set_initial_soc` to `set_initial_state` and adds Thevenin initial state setting. ([#5129](https://github.com/pybamm-team/PyBaMM/pull/5129))

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ jax = [
118118
# Contains all optional dependencies, except for jax, and dev dependencies
119119
all = [
120120
"scikit-fem>=8.1.0",
121+
"meshio>=5.3.0",
121122
"pybamm[examples,plot,cite,bpx,tqdm]",
122123
]
123124

src/pybamm/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
from .meshes.scikit_fem_submeshes_3d import (
151151
ScikitFemSubMesh3D,
152152
ScikitFemGenerator3D,
153+
UserSuppliedSubmesh3D,
153154
)
154155

155156
# Serialisation

src/pybamm/meshes/scikit_fem_submeshes_3d.py

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import numpy as np
24

35
import pybamm
@@ -234,6 +236,87 @@ def __call__(self, lims, npts):
234236
return submesh
235237

236238

239+
class UserSuppliedSubmesh3D(pybamm.MeshGenerator):
240+
"""
241+
A mesh generator that loads a 3D mesh from an external file (e.g., VTK).
242+
243+
This allows using high-quality meshes from any external tool. The mesh
244+
file must contain integer tags identifying the different physical domains
245+
and boundaries. This generator works directly with your existing
246+
'ScikitFemSubMesh3D' and 'ScikitFiniteElement3D' classes.
247+
248+
Parameters
249+
----------
250+
file_path : str
251+
Path to the mesh file.
252+
boundary_mapping : dict
253+
Maps PyBaMM boundary names to the integer tags in the mesh file.
254+
domain_mapping : dict
255+
Maps PyBaMM domain names to the integer tags in the mesh file.
256+
domain_tag_name : str, optional
257+
The name of the data array in the mesh file for domain tags.
258+
boundary_tag_name : str, optional
259+
The name of the data array in the mesh file for boundary tags.
260+
coord_sys : str, optional
261+
The coordinate system ("cartesian" or "cylindrical polar").
262+
263+
Notes
264+
-----
265+
The external mesh file must meet the following criteria to be compatible:
266+
267+
1. **Element Types**: The 3D volume must be meshed with 4-node
268+
**tetrahedral** elements. The 2D boundaries must be meshed with
269+
3-node **triangular** elements.
270+
271+
2. **Physical Tags**: The file must contain integer tags to identify
272+
physical groups.
273+
- Each tetrahedron in the volume must be tagged with an integer
274+
that corresponds to a PyBaMM domain.
275+
- Each triangle on a boundary must be tagged with an integer that
276+
corresponds to a PyBaMM boundary.
277+
278+
3. **File Format**: Any format supported by the `meshio` library is
279+
acceptable. For maximum reliability in preserving physical tags,
280+
the **Gmsh `.msh` Version 2.2 (ASCII)** format is recommended.
281+
"""
282+
283+
def __init__(
284+
self,
285+
file_path,
286+
boundary_mapping,
287+
domain_mapping,
288+
domain_tag_name=None,
289+
boundary_tag_name=None,
290+
coord_sys=None,
291+
):
292+
super().__init__(ScikitFemSubMesh3D)
293+
self.file_path = file_path
294+
self.boundary_mapping = boundary_mapping
295+
self.domain_mapping = domain_mapping
296+
self.domain_tag_name = domain_tag_name
297+
self.boundary_tag_name = boundary_tag_name
298+
299+
if coord_sys:
300+
self.coord_sys = coord_sys
301+
else:
302+
if any("r_min" in k or "r_max" in k for k in boundary_mapping.keys()):
303+
self.coord_sys = "cylindrical polar"
304+
else:
305+
self.coord_sys = "cartesian"
306+
307+
def __call__(self, lims, npts):
308+
skfem_mesh = ScikitFemSubMesh3D.load_mesh_from_file(
309+
self.file_path,
310+
self.boundary_mapping,
311+
self.domain_mapping,
312+
self.domain_tag_name,
313+
self.boundary_tag_name,
314+
)
315+
nodes = skfem_mesh.p.T
316+
elements = skfem_mesh.t.T
317+
return self.submesh_type(skfem_mesh, nodes, elements, self.coord_sys)
318+
319+
237320
class ScikitFemSubMesh3D(pybamm.SubMesh):
238321
"""
239322
A 3D submesh class for unstructured tetrahedral meshes generated by scikit-fem.
@@ -323,3 +406,255 @@ def _from_json(cls, json_dict):
323406
elements = np.array(json_dict["elements"])
324407
mesh = skfem.MeshTet(nodes.T, elements.T)
325408
return cls(mesh, nodes, elements, json_dict["coord_sys"])
409+
410+
@staticmethod
411+
def load_mesh_from_file(
412+
file_path,
413+
boundary_mapping,
414+
domain_mapping,
415+
domain_tag_name=None,
416+
boundary_tag_name=None,
417+
):
418+
"""
419+
Loads a generic mesh file and converts it to a scikit-fem Mesh object.
420+
421+
This function is designed to work with meshes from most software packages
422+
including GMSH, Ansys, Abaqus, FEniCS, etc. It automatically detects
423+
common tag naming conventions or allows manual specification.
424+
425+
Parameters
426+
----------
427+
file_path : str
428+
The path to the mesh file (It supports .msh, .xdmf, may also work with others but not tested).
429+
boundary_mapping : dict
430+
Maps PyBaMM boundary names to integer tags (e.g., {"r_inner": 1}).
431+
domain_mapping : dict
432+
Maps PyBaMM domain names to integer tags (e.g., {"current collector": 5}).
433+
domain_tag_name : str, optional
434+
The name of the cell data array that contains domain tags for elements.
435+
boundary_tag_name : str, optional
436+
The name of the cell data array that contains boundary tags for facets.
437+
"""
438+
meshio = pybamm.util.import_optional_dependency("meshio")
439+
skfem = pybamm.util.import_optional_dependency("skfem")
440+
441+
tested_formats = [".msh", ".xdmf"]
442+
_, file_ext = os.path.splitext(file_path)
443+
if file_ext not in tested_formats: # pragma: no cover
444+
pybamm.logger.warning(
445+
f"File format '{file_ext}' has not been explicitly tested and may not "
446+
"work correctly. Recommended formats are .msh and .xdmf."
447+
)
448+
449+
try:
450+
m = meshio.read(file_path)
451+
except Exception as e:
452+
raise pybamm.GeometryError(
453+
f"Could not read mesh file '{file_path}': {e}"
454+
) from e
455+
456+
nodes = m.points
457+
if nodes.shape[1] != 3:
458+
raise pybamm.GeometryError(
459+
f"Mesh must be 3D, got points with shape {nodes.shape}"
460+
)
461+
462+
def find_tag_name(
463+
user_provided_name, default_candidates, available_data, context=""
464+
):
465+
"""
466+
Enhanced tag name finder that works with most mesh formats.
467+
"""
468+
# If user explicitly provided a name, use it
469+
if user_provided_name:
470+
if user_provided_name in available_data:
471+
return user_provided_name
472+
else:
473+
available_keys = list(available_data.keys())
474+
raise pybamm.GeometryError(
475+
f"User-specified {context} tag name '{user_provided_name}' not found. "
476+
f"Available arrays: {available_keys}"
477+
)
478+
479+
for candidate in default_candidates:
480+
if candidate in available_data:
481+
return candidate
482+
483+
integer_arrays = [
484+
name
485+
for name, data in available_data.items()
486+
if np.issubdtype(data.dtype, np.integer)
487+
]
488+
489+
if len(integer_arrays) == 1:
490+
return integer_arrays[0]
491+
492+
priority_patterns = ["physical", "tag", "id", "group", "material", "region"]
493+
for pattern in priority_patterns:
494+
for name in integer_arrays:
495+
if pattern.lower() in name.lower():
496+
return name
497+
498+
raise pybamm.GeometryError(
499+
f"Could not automatically detect {context} tag array in '{file_path}'. "
500+
f"Available arrays: {list(available_data.keys())}. "
501+
f"Integer arrays found: {integer_arrays}. "
502+
f"Please specify manually using '{context.replace(' ', '_')}_tag_name'."
503+
)
504+
505+
try:
506+
tet_cells = m.get_cells_type("tetra")
507+
if len(tet_cells) == 0:
508+
raise pybamm.GeometryError(
509+
f"No tetrahedral elements found in '{file_path}'"
510+
)
511+
512+
tet_cell_index = None
513+
for i, cell_block in enumerate(m.cells):
514+
if cell_block.type == "tetra":
515+
tet_cell_index = i
516+
break
517+
518+
if tet_cell_index is None:
519+
raise pybamm.GeometryError("Could not find tetrahedra in cell list")
520+
521+
tet_cell_data_dict = {}
522+
for name, data_list in m.cell_data.items():
523+
if len(data_list) > tet_cell_index:
524+
data = data_list[tet_cell_index]
525+
if len(data) == len(tet_cells):
526+
tet_cell_data_dict[name] = data
527+
528+
domain_tag_name = find_tag_name(
529+
domain_tag_name,
530+
[
531+
"gmsh:physical",
532+
"physical",
533+
"PhysicalGroup",
534+
"MaterialId",
535+
"CellEntityIds",
536+
"gmsh:geometrical",
537+
],
538+
tet_cell_data_dict,
539+
"domain",
540+
)
541+
542+
elements = tet_cells
543+
tet_cell_data = tet_cell_data_dict[domain_tag_name]
544+
545+
subdomains = {}
546+
for name, tag in domain_mapping.items():
547+
matching_elements = np.where(tet_cell_data == tag)[0]
548+
if len(matching_elements) == 0:
549+
pybamm.logger.warning(
550+
f"No elements found for domain '{name}' with tag {tag}"
551+
)
552+
else:
553+
subdomains[name] = matching_elements
554+
pybamm.logger.info(
555+
f"Found {len(matching_elements)} elements for domain '{name}' (tag {tag})"
556+
)
557+
558+
except Exception as e:
559+
raise pybamm.GeometryError(
560+
f"Failed to extract tetrahedral elements from '{file_path}': {e}"
561+
) from e
562+
563+
skfem_mesh = skfem.MeshTet(nodes.T, elements.T)
564+
565+
boundaries = {}
566+
try:
567+
tri_cells = m.get_cells_type("triangle")
568+
569+
if len(tri_cells) > 0:
570+
tri_cell_indices = []
571+
for i, cell_block in enumerate(m.cells):
572+
if cell_block.type == "triangle":
573+
tri_cell_indices.append(i)
574+
575+
# Combine all triangle cell data
576+
all_tri_cells = []
577+
all_tri_data = []
578+
579+
for idx in tri_cell_indices:
580+
block_cells = m.cells[idx].data
581+
all_tri_cells.extend(block_cells)
582+
583+
for name, data_list in m.cell_data.items():
584+
if len(data_list) > idx:
585+
if name not in [arr_name for arr_name, _ in all_tri_data]:
586+
all_tri_data.append((name, []))
587+
for arr_name, arr_data in all_tri_data:
588+
if arr_name == name:
589+
arr_data.extend(data_list[idx])
590+
break
591+
592+
all_tri_cells = np.array(all_tri_cells)
593+
594+
tri_cell_data_dict = {}
595+
for name, data in all_tri_data:
596+
tri_cell_data_dict[name] = np.array(data)
597+
598+
if tri_cell_data_dict:
599+
# Find boundary tag name
600+
boundary_tag_name = find_tag_name(
601+
boundary_tag_name,
602+
[
603+
"gmsh:physical",
604+
"physical",
605+
"PhysicalGroup",
606+
"FaceEntityIds",
607+
"gmsh:geometrical",
608+
],
609+
tri_cell_data_dict,
610+
"boundary",
611+
)
612+
613+
tri_cell_data = tri_cell_data_dict[boundary_tag_name]
614+
615+
# Map triangle facets to mesh boundary facets
616+
bnd_facets_indices = skfem_mesh.boundary_facets()
617+
bnd_facets_nodes = skfem_mesh.facets[:, bnd_facets_indices]
618+
619+
# Create mapping from node sets to facet indices
620+
facet_map = {}
621+
for i in range(len(bnd_facets_indices)):
622+
node_set = frozenset(bnd_facets_nodes[:, i])
623+
facet_map[node_set] = bnd_facets_indices[i]
624+
625+
# Find boundary facets for each tag
626+
for name, tag in boundary_mapping.items():
627+
tagged_triangles = all_tri_cells[tri_cell_data == tag]
628+
facet_indices = []
629+
630+
for tri in tagged_triangles:
631+
tri_set = frozenset(tri)
632+
if tri_set in facet_map:
633+
facet_indices.append(facet_map[tri_set])
634+
635+
if facet_indices:
636+
boundaries[name] = np.array(facet_indices)
637+
pybamm.logger.info(
638+
f"Found {len(facet_indices)} boundary facets for '{name}' (tag {tag})"
639+
)
640+
else:
641+
pybamm.logger.warning(
642+
f"No boundary facets found for '{name}' with tag {tag}"
643+
)
644+
645+
except Exception as e:
646+
pybamm.logger.warning(
647+
f"Could not extract boundary information from '{file_path}': {e}"
648+
)
649+
650+
if boundaries:
651+
skfem_mesh = skfem_mesh.with_boundaries(boundaries)
652+
if subdomains:
653+
skfem_mesh = skfem_mesh.with_subdomains(subdomains)
654+
655+
pybamm.logger.info(
656+
f"Successfully loaded mesh: {len(nodes)} nodes, {len(elements)} elements, "
657+
f"{len(boundaries)} boundary groups, {len(subdomains)} subdomains"
658+
)
659+
660+
return skfem_mesh

0 commit comments

Comments
 (0)