# Copyright (c) 2012-2024, OpenGeoSys Community (http://www.opengeosys.org)
# Distributed under a Modified BSD License.
# See accompanying file LICENSE.txt or
# http://www.opengeosys.org/project/license
#
# Author: Dominik Kern (TU Bergakademie Freiberg)
import logging
from pathlib import Path
from typing import Any, Union
import meshio
import numpy as np
logger = logging.getLogger(__name__)
# For more info on __all__ see: https://stackoverflow.com/a/35710527/80480
__all__ = [
"my_remove_orphaned_nodes",
"print_info",
"find_cells_at_nodes",
"find_connected_domain_cells",
"msh2vtu",
]
[docs]
def my_remove_orphaned_nodes(my_mesh: meshio.Mesh) -> None:
"""Auxiliary function to remove points not belonging to any cell"""
# find connected points and derive mapping from all points to them
connected_point_index = np.array([])
for cell_block in my_mesh.cells:
cell_block_values = cell_block.data
connected_point_index = np.concatenate(
[connected_point_index, cell_block_values.flatten()]
).astype(int)
unique_point_index = np.unique(connected_point_index)
old2new = np.zeros(len(my_mesh.points))
for new_index, old_index in enumerate(unique_point_index):
old2new[old_index] = int(new_index)
# update mesh to remaining points
my_mesh.points = my_mesh.points[unique_point_index] # update points
output_point_data = {}
for pd_key, pd_value in my_mesh.point_data.items():
output_point_data[pd_key] = pd_value[unique_point_index]
my_mesh.point_data = output_point_data # update point data
output_cell_blocks = []
for cell_block in my_mesh.cells:
cell_type = cell_block.type
cell_block_values = cell_block.data
updated_values = old2new[cell_block_values].astype(int)
output_cell_blocks.append(meshio.CellBlock(cell_type, updated_values))
# cell data are not affected by point changes
my_mesh.cells = output_cell_blocks # update cells
# print info for mesh: statistics and data field names
[docs]
def print_info(mesh: meshio.Mesh) -> None:
N, D = mesh.points.shape
logging.info("%d points in %d dimensions", N, D)
cell_info = "cells: "
for cell_type, cell_values in mesh.cells_dict.items():
cell_info += str(len(cell_values)) + " " + cell_type + ", "
logging.info(cell_info[0:-2])
logging.info("point_data=%s", str(list(mesh.point_data)))
logging.info("cell_data=%s", str(list(mesh.cell_data)))
logging.info("cell_sets=%s", str(list(mesh.cell_sets)))
logging.info("##")
# function to create node connectivity list, i.e. store for each node (point) to
# which element (cell) it belongs
[docs]
def find_cells_at_nodes(
cells: Any, node_count: int, cell_start_index: int
) -> list[set]:
# depending on the numbering of mixed meshes in OGS one may think of an
# object-oriented way to add elements (of different type) to node
# connectivity
# initialize list of sets
node_connectivity: list[set] = [set() for _ in range(node_count)]
cell_index = cell_start_index
for cell in cells:
for node in cell:
node_connectivity[node].add(cell_index)
cell_index += 1
if node_connectivity.count(set()) > 0:
unconnected_nodes = [
node
for node in range(node_count)
if node_connectivity[node] == set()
]
logging.info("Points not connected with domain cells:")
logging.info(unconnected_nodes)
return node_connectivity
# function to find out to which domain elements a boundary element belongs
[docs]
def find_connected_domain_cells(
boundary_cells_values: Any, domain_cells_at_node: list[set[int]]
) -> tuple[np.ndarray, np.ndarray]:
warned_gt1 = False # to avoid flood of warnings
warned_lt1 = False # to avoid flood of warnings
number_of_boundary_cells = len(boundary_cells_values)
# to return unique common connected domain cell to be stored as cell_data
# ("bulk_element_id"), if there are more than one do not store anything
domain_cells_array = np.zeros(number_of_boundary_cells)
domain_cells_number = np.zeros(
number_of_boundary_cells
) # number of connected domain_cells
for cell_index, cell_values in enumerate(
boundary_cells_values
): # cell lists node of which it is comprised
connected_domain_cells = []
for node in cell_values:
connected_domain_cells.append(domain_cells_at_node[node])
common_domain_cells = set.intersection(*connected_domain_cells)
number_of_connected_domain_cells = len(common_domain_cells)
domain_cells_number[cell_index] = number_of_connected_domain_cells
# there should be one domain cell for each boundary cell, however cells
# of boundary dimension may be in the domain (e.g. as sources)
if number_of_connected_domain_cells == 1:
# assign only one (unique) connected dmain cell
domain_cells_array[cell_index] = common_domain_cells.pop()
elif number_of_connected_domain_cells < 1 and not warned_lt1:
logging.warning(
"find_connected_domain_cells: cell %s"
" of boundary dimension does not belong to any domain cell!",
str(cell_index),
)
# domain_cell in domain_cells_array remains zero, as there is no
# cell to assign
warned_lt1 = True
logging.warning(
"Possibly more cells may not belong to any domain cell."
)
elif not warned_gt1:
logging.warning(
"find_connected_domain_cells: cell %s"
" of boundary dimension belongs to more than one domain cell!"
)
# domain_cell in domain_cells_array remains zero, because structure
# is 1D and only the boundary case is relevant for further use
warned_gt1 = True
logging.warning(
"Possibly more cells may belong to more than one domain cell."
)
return domain_cells_array, domain_cells_number
# TODO: rename
[docs]
def msh2vtu(
filename: Path,
output_path: Path = Path(),
output_prefix: str = "",
dim: Union[int, list[int]] = 0,
delz: bool = False,
swapxy: bool = False,
reindex: bool = False,
keep_ids: bool = False,
ascii: bool = False,
log_level: Union[int, str] = "DEBUG",
) -> int:
"""
Convert a gmsh mesh (.msh) to an unstructured grid file (.vtu).
Prepares a Gmsh-mesh for use in OGS by extracting domain-,
boundary- and physical group-submeshes, and saves them in
vtu-format. Note that all mesh entities should belong to
physical groups.
:param filename: Gmsh mesh file (.msh) as input data
:param output_path: Path of output files, defaults to current working dir
:param output_prefix: Output files prefix, defaults to basename of inputfile
:param dim: Spatial dimension (1, 2 or 3), trying automatic detection,
if not given. If multiple dimensions are provided, all elements
of these dimensions are embedded in the resulting domain mesh.
:param delz: Delete z-coordinate, for 2D-meshes with z=0.
Note that vtu-format requires 3D points.
:param swapxy: Swap x and y coordinate
:param reindex: Renumber physical group / region / Material IDs to be
renumbered beginning with zero.
:param keep_ids: By default, rename 'gmsh:physical' to 'MaterialIDs'
and change type of corresponding cell data to INT32.
If True, this is skipped.
:param ascii: Save output files (.vtu) in ascii format.
:param log_level: Level of log output. Possible values:
<https://docs.python.org/3/library/logging.html#levels>
:returns: A MeshSeries object
"""
logging.getLogger().setLevel(log_level)
if isinstance(dim, list):
assert len(dim) < 3, "Specify at most 3 dim values."
filename = Path(filename)
# some variable declarations
ph_index = 0 # to access physical group id in field data
geo_index = 1 # to access geometrical dimension in field data
dim0 = 0
dim1 = 1
dim2 = 2
dim3 = 3
# for all points, as the selection goes via the cells and subsequent trim
ogs_point_data_key = "bulk_node_ids"
# to associate domain points with original points
ogs_domain_point_data_key = "original_node_number"
available_cell_types = {
dim0: {"vertex"},
dim1: {"line", "line3", "line4"},
dim2: {
*["triangle", "triangle6", "triangle9", "triangle10"],
*["quad", "quad8", "quad9"],
},
dim3: {
*["tetra", "tetra10"],
*["pyramid", "pyramid13", "pyramid15", "pyramid14"],
*["wedge", "wedge15", "wedge18"],
*["hexahedron", "hexahedron20", "hexahedron27"],
*["prism", "prism15", "prism18"],
},
}
gmsh_physical_cell_data_key = "gmsh:physical"
ogs_domain_cell_data_key = "MaterialIDs"
ogs_boundary_cell_data_key = "bulk_elem_ids"
ogs = not keep_ids
# check if input file exists and is in gmsh-format
if not filename.is_file():
logging.warning("No input file (mesh) found.")
# raise FileNotFoundError
return 1
if filename.suffix != ".msh":
logging.warning(
"Warning, input file seems not to be in gmsh-format (*.msh)"
)
# if no parameter given, use same basename as input file
output_basename = filename.stem if output_prefix == "" else output_prefix
logging.info("Output: %s", output_basename)
# read in mesh (be aware of shallow copies, i.e. by reference)
mesh: meshio.Mesh = meshio.read(str(filename))
points, point_data = mesh.points, mesh.point_data
cells_dict, cell_data, cell_data_dict = (
mesh.cells_dict,
mesh.cell_data,
mesh.cell_data_dict,
)
field_data = mesh.field_data
number_of_original_points = len(points)
existing_cell_types = set(mesh.cells_dict.keys()) # elements found in mesh
logging.info("Original mesh (read)")
logging.info(mesh)
# check if element types are supported in current version of this script
all_available_cell_types: set[str] = set()
for cell_types in available_cell_types.values():
all_available_cell_types = all_available_cell_types.union(cell_types)
for cell_type in existing_cell_types:
if cell_type not in all_available_cell_types:
logging.warning("Cell type %s not supported", str(cell_type))
# set spatial dimension of mesh
if dim == 0:
assert isinstance(dim, int)
# automatically detect spatial dimension of mesh
_dim = dim0 # initial value
for test_dim, test_cell_types in available_cell_types.items():
if (
len(test_cell_types.intersection(existing_cell_types))
and test_dim > dim
):
_dim = test_dim
logging.info("Detected mesh dimension: %s", str(_dim))
logging.info("##")
else:
# trust the user
_dim = max(dim) if isinstance(dim, list) else dim
# delete third dimension if wanted by user
if delz:
if _dim <= dim2:
logging.info("Remove z coordinate of all points.")
points = mesh.points[:, :2]
else:
logging.info(
"Mesh seems to be in 3D, z-coordinate cannot be removed. Option"
" -z ignored."
)
# special case in 2D workflow
if swapxy:
logging.info("Swapping x- and y-coordinate")
points[:, 0], points[:, 1] = points[:, 1], -points[:, 0]
# boundary and domain cell types depend on dimension
if dim1 <= _dim <= dim3:
boundary_dim = _dim - 1
domain_dim = _dim
boundary_cell_types = existing_cell_types.intersection(
available_cell_types[boundary_dim]
)
domain_cell_types = existing_cell_types.intersection(
available_cell_types[domain_dim]
if isinstance(dim, int)
else set().union(*[available_cell_types[d] for d in dim])
)
else:
logging.warning("Error, invalid dimension dim=%s!", str(_dim))
return 1 # sys.exit()
# Check for existence of physical groups
if gmsh_physical_cell_data_key in cell_data:
physical_groups_found = True
# reconstruct field data, when empty (physical groups may have a number,
# but no name)
# TODO may there be other field data, than physical groups?
if field_data == {}:
# detect dimension by cell type
for pg_cell_type, pg_cell_data in cell_data_dict[
gmsh_physical_cell_data_key
].items():
if pg_cell_type in available_cell_types[dim0]:
pg_dim = dim0
if pg_cell_type in available_cell_types[dim1]:
pg_dim = dim1
if pg_cell_type in available_cell_types[dim2]:
pg_dim = dim2
if pg_cell_type in available_cell_types[dim3]:
pg_dim = dim3
pg_ids = np.unique(pg_cell_data)
for pg_id in pg_ids:
pg_key = "PhysicalGroup_" + str(pg_id)
field_data[pg_key] = np.array([pg_id, pg_dim])
id_list_domains = [
physical_group[ph_index]
for physical_group in field_data.values()
if physical_group[geo_index] == domain_dim
]
id_offset = min(id_list_domains, default=0) if reindex else 0
else:
logging.info("No physical groups found.")
physical_groups_found = False
############################################################################
# Extract domain mesh, note that meshio 4.3.3. offers
# remove_lower_dimensional_cells(), but we want to keep a uniform style for
# domain and subdomains. Make sure to use domain_mesh=deepcopy(mesh) in this
# case!
############################################################################
all_points = np.copy(points) # copy all, superfluous get deleted later
if ogs:
# to associate domain points later
original_point_numbers = np.arange(number_of_original_points)
all_point_data = {}
all_point_data[ogs_domain_point_data_key] = np.uint64(
original_point_numbers
)
else:
all_point_data = {
key: value[:] for key, value in point_data.items()
} # deep copy
domain_cells = []
if ogs:
domain_cell_data_key = ogs_domain_cell_data_key
else:
domain_cell_data_key = gmsh_physical_cell_data_key
domain_cell_data: dict[str, list[int]] = {}
domain_cell_data[domain_cell_data_key] = []
for domain_cell_type in domain_cell_types:
# cells
domain_cells_values = cells_dict[domain_cell_type]
number_of_domain_cells = len(domain_cells_values)
domain_cells_block = (domain_cell_type, domain_cells_values)
domain_cells.append(domain_cells_block)
# cell_data
if physical_groups_found:
if domain_cell_type in cell_data_dict[gmsh_physical_cell_data_key]:
domain_in_physical_group = True
else:
domain_in_physical_group = False
else:
domain_in_physical_group = False
if domain_in_physical_group:
if ogs:
domain_cell_data_values = cell_data_dict[
gmsh_physical_cell_data_key
][domain_cell_type]
# ogs needs MaterialIDs as int32, possibly beginning with zero
# (by id_offset)
domain_cell_data_values = np.int32(
domain_cell_data_values - id_offset
)
else:
domain_cell_data_values = cell_data_dict[
gmsh_physical_cell_data_key
][domain_cell_type]
else:
domain_cell_data_values = np.zeros(
(number_of_domain_cells), dtype=int
)
logging.info(
"Some domain cells are not in a physical group, their"
" PhysicalID/MaterialID is set to zero."
)
domain_cell_data[domain_cell_data_key].append(domain_cell_data_values)
if len(domain_cells):
domain_mesh = meshio.Mesh(
points=all_points,
point_data=all_point_data,
cells=domain_cells,
cell_data=domain_cell_data,
)
my_remove_orphaned_nodes(domain_mesh)
if len(domain_mesh.points) != number_of_original_points:
logging.warning(
"There are nodes out of the domain mesh. If ogs option is set,"
" then no bulk_node_id can be assigned to these nodes."
)
meshio.write(
Path(output_path, output_basename + "_domain.vtu"),
domain_mesh,
binary=not ascii,
)
logging.info("Domain mesh (written)")
print_info(domain_mesh)
if ogs:
# store domain node numbers for use as bulk_node_id (point_data)
original2domain_point_table = (
np.ones(number_of_original_points) * number_of_original_points
)
# initialize with non-existing number --> error when bulk_id for
# non-domain mesh should be written
for domain_point_index, original_point_index in enumerate(
domain_mesh.point_data[ogs_domain_point_data_key]
):
original2domain_point_table[
original_point_index
] = domain_point_index
# prepare data needed for bulk_elem_id (cell_data), also needed without
# ogs option to detect boundaries
# node connectivity for a mixed mesh (check for OGS compliance), needed
# with and without ogs option to identify boundary cells
cell_start_index = 0
domain_cells_at_node: list[set[int]] = [
set() for _ in range(number_of_original_points)
] # initialize list of sets
# make a list for each type of domain cells
for cell_block in domain_cells:
block_domain_cells_at_node = find_cells_at_nodes(
cell_block[1], number_of_original_points, cell_start_index
) # later used for boundary mesh and submeshes
cell_start_index += len(
cell_block[1]
) # assume consecutive cell numbering (as it is written to vtu)
# add connectivities of current cell type to entries (sets) of total
# connectivity (list of sets)
for total_list, block_list in zip(
domain_cells_at_node, block_domain_cells_at_node
):
total_list.update(block_list)
else:
logging.info("Empty domain mesh, nothing written to file.")
############################################################################
# Extract boundary mesh
############################################################################
# points, process full list (all points), later trimmed according to cell
# selection, deep copy needed because removed_orphaned_nodes() operates on
# shallow copy of point_data
# copy again, in case previous remove_orphaned_nodes() affected all_points
all_points = np.copy(points)
if ogs:
all_point_data = {}
# now containing domain node numbers
all_point_data[ogs_point_data_key] = np.uint64(
original2domain_point_table
)
else:
all_point_data = {
key: value[:] for key, value in point_data.items()
} # deep copy
boundary_cells = []
boundary_cell_data: dict[str, list] = {}
if ogs:
boundary_cell_data_key = ogs_boundary_cell_data_key
else:
boundary_cell_data_key = gmsh_physical_cell_data_key
boundary_cell_data[boundary_cell_data_key] = []
for boundary_cell_type in boundary_cell_types:
# preliminary, as there may be cells of boundary dimension inside domain
# (i.e. which are no boundary cells)
boundary_cells_values = cells_dict[boundary_cell_type]
connected_cells, connected_cells_count = (
np.asarray(np.uint64(t))
for t in find_connected_domain_cells(
boundary_cells_values, domain_cells_at_node
)
)
# a boundary cell is connected with exactly one domain cell
boundary_index = connected_cells_count == 1
if not boundary_index.all():
logging.info(
"For information, there are cells of boundary dimension not on"
" the boundary (e.g. inside domain)."
)
multi_connection_index = connected_cells_count > 1
logging.info(
"Cells of type %s connected to more than one domain cell:",
boundary_cell_type,
)
logging.info(boundary_cells_values[multi_connection_index])
zero_connection_index = connected_cells_count < 1
logging.info(
"Cells of type %s not connected to any domain cell:",
boundary_cell_type,
)
logging.info(boundary_cells_values[zero_connection_index])
boundary_cells_values = boundary_cells_values[
boundary_index
] # final boundary cells
boundary_cells_block = (boundary_cell_type, boundary_cells_values)
boundary_cells.append(boundary_cells_block)
# cell_data
boundary_in_physical_group = physical_groups_found and (
boundary_cell_type in cell_data_dict[gmsh_physical_cell_data_key]
)
if ogs:
boundary_cell_data_values = connected_cells[boundary_index]
else:
if boundary_in_physical_group:
boundary_cell_data_values = cell_data_dict[
gmsh_physical_cell_data_key
][boundary_cell_type]
else:
# cells of specific type
number_of_boundary_cells = len(boundary_cells_values)
boundary_cell_data_values = np.zeros(
(number_of_boundary_cells), dtype=int
)
logging.info(
"Some boundary cells are not in a physical group, their"
" PhysicalID is set to zero."
)
boundary_cell_data[boundary_cell_data_key].append(
boundary_cell_data_values
)
boundary_mesh = meshio.Mesh(
points=all_points,
point_data=all_point_data,
cells=boundary_cells,
cell_data=boundary_cell_data,
)
if len(boundary_cells):
my_remove_orphaned_nodes(boundary_mesh)
meshio.write(
Path(output_path, output_basename + "_boundary.vtu"),
boundary_mesh,
binary=not ascii,
)
logging.info("Boundary mesh (written)")
print_info(boundary_mesh)
else:
logging.info("No boundary elements detected.")
############################################################################
# Now we want to extract subdomains given by physical groups in gmsh
# name=user-defined name of physical group, data=[physical_id, geometry_id]
############################################################################
if not physical_groups_found:
return 0
for name, data in field_data.items():
subdomain_dim = data[geo_index] # 0 or 1 or 2 or 3
if dim0 <= subdomain_dim and subdomain_dim <= dim3:
subdomain_cell_types = existing_cell_types.intersection(
available_cell_types[subdomain_dim]
)
else:
logging.warning("Invalid dimension found in physical groups.")
continue
all_points = np.copy(points)
# point data, make another copy due to possible changes by previous
# actions
if ogs:
all_point_data = {}
all_point_data[ogs_point_data_key] = np.uint64(
original2domain_point_table
)
else:
all_point_data = {
key: value[:] for key, value in point_data.items()
} # deep copy
# cells, cell_data
subdomain_cells = [] # list
subdomain_cell_data: dict[str, list] = {} # dict
if ogs:
if subdomain_dim == domain_dim:
subdomain_cell_data_key = ogs_domain_cell_data_key
elif subdomain_dim == boundary_dim:
subdomain_cell_data_key = ogs_boundary_cell_data_key
else:
# use gmsh, as the requirements from OGS
subdomain_cell_data_key = gmsh_physical_cell_data_key
else:
# same for all dimensions
subdomain_cell_data_key = gmsh_physical_cell_data_key
subdomain_cell_data[subdomain_cell_data_key] = [] # list
# flag to indicate invalid bulk_element_ids, then no cell data will be
# written
subdomain_cell_data_trouble = False
for cell_type in subdomain_cell_types:
# cells
all_false = np.full(
cell_data_dict[gmsh_physical_cell_data_key][cell_type].shape,
False,
)
selection_index = mesh.cell_sets_dict[name].get(
cell_type, all_false
)
selection_cells_values = cells_dict[cell_type][selection_index]
if len(selection_cells_values): # if there are some data
selection_cells_block = (cell_type, selection_cells_values)
subdomain_cells.append(selection_cells_block)
# cell data
if ogs:
selection_cell_data_values: Union[np.int32, np.uint64]
if subdomain_dim == boundary_dim:
(
connected_cells,
connected_cells_count,
) = find_connected_domain_cells(
selection_cells_values, domain_cells_at_node
)
# a boundary cell is connected with one domain cell,
# needed to write bulk_elem_id
boundary_index = connected_cells_count == 1
selection_cell_data_values = np.uint64(connected_cells)
if not boundary_index.all():
logging.info(
"In physical group %s"
" are bulk_elem_ids not uniquely defined,"
" e.g. for cells of boundary dimension inside"
" the domain, and thus not written. If"
" bulk_elem_ids should be written for a"
" physical group, then make sure all its"
" cells of boundary dimension are located at"
" the boundary.",
name,
)
subdomain_cell_data_trouble = True
elif subdomain_dim == domain_dim:
selection_cell_data_values = np.int32(
cell_data_dict[gmsh_physical_cell_data_key][
cell_type
][selection_index]
- id_offset
)
else: # any cells of lower dimension than boundary
selection_cell_data_values = np.int32(
cell_data_dict[gmsh_physical_cell_data_key][
cell_type
][selection_index]
)
else:
selection_cell_data_values = cell_data_dict[
gmsh_physical_cell_data_key
][cell_type][selection_index]
subdomain_cell_data[subdomain_cell_data_key].append(
selection_cell_data_values
)
outputfilename = output_basename + "_physical_group_" + name + ".vtu"
if subdomain_cell_data_trouble:
submesh = meshio.Mesh(
points=all_points,
point_data=all_point_data,
cells=subdomain_cells,
) # do not write invalid cell_data
else:
submesh = meshio.Mesh(
points=all_points,
point_data=all_point_data,
cells=subdomain_cells,
cell_data=subdomain_cell_data,
)
if len(subdomain_cells):
my_remove_orphaned_nodes(submesh)
outputfilename = (
output_basename + "_physical_group_" + name + ".vtu"
)
meshio.write(
Path(output_path, outputfilename), submesh, binary=not ascii
)
logging.info("Submesh %s (written)", name)
print_info(submesh)
else:
logging.info("Submesh %s empty (not written)", name)
return 0 # successfully finished