# SPDX-FileCopyrightText: Copyright (c) OpenGeoSys Community (opengeosys.org)
# SPDX-License-Identifier: BSD-3-Clause
import shutil
import subprocess
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import numpy as np
import pyvista as pv
from ogstools._find_ogs import cli
from ogstools.definitions import temp_file
from .file_io import save
[docs]
def node_reordering(
mesh: pv.UnstructuredGrid, method: int = 1, log: bool = True
) -> pv.UnstructuredGrid:
"""Reorders mesh nodes to make a mesh compatible with OGS6.
:param mesh: mesh whose nodes are to be reordered.
:param method:
0: Reversing order of nodes for all elements.\n
1: Reversing order of nodes unless it's perceived correct by OGS6
standards. This is the default selection.\n
2: Fixing node ordering issues between VTK and OGS6 (only applies
to prism-elements).\n
3: Re-ordering of mesh node vector such that all base nodes are
sorted before all nonlinear nodes.
:param log: If False, silence the NodeReordering tool's own log output.
"""
tmp_file = temp_file(".vtu", "node_reordering")
save(mesh, tmp_file)
cli().NodeReordering(
i=str(tmp_file), o=str(tmp_file), m=method, l="info" if log else "none"
)
return pv.XMLUnstructuredGridReader(tmp_file).read()
[docs]
def to_linear(mesh: pv.UnstructuredGrid) -> pv.UnstructuredGrid:
"Convert to a linear mesh."
tmp_file = temp_file(".vtu", "mesh", "to_linear")
save(mesh, tmp_file)
cli().convertToLinearMesh(i=str(tmp_file), o=str(tmp_file))
return pv.XMLUnstructuredGridReader(tmp_file).read()
[docs]
def to_quadratic(mesh: pv.UnstructuredGrid) -> pv.UnstructuredGrid:
"Convert to a quadratic mesh."
tmp_file = temp_file(".vtu", "mesh", "to_quadratic")
save(mesh, tmp_file)
cli().createQuadraticMesh(i=str(tmp_file), o=str(tmp_file))
return pv.XMLUnstructuredGridReader(tmp_file).read()
[docs]
def validate(
mesh: pv.UnstructuredGrid | Path | str, strict: bool = False
) -> bool:
"""Check conformity of mesh with OGS.
:param mesh: pyvista mesh or path to the mesh file.
:param strict: If True, raise a UserWarning if checkMesh returns an error.
"""
if isinstance(mesh, pv.DataSet):
mesh_file = str(temp_file(".vtu", "validate"))
save(mesh, mesh_file)
else:
mesh_file = str(mesh)
# ToDo Either checkMesh must return status of mesh (not of itself) OR
# cli() can handle stdout
if shutil.which("checkMesh") is None:
return True
ret = subprocess.run(
["checkMesh", mesh_file, "-v"], stdout=subprocess.PIPE, check=False
)
msg = ret.stdout.decode("utf-8")
is_valid = "No errors found." in msg
if not is_valid:
print(msg)
if strict and not is_valid:
msg = "Provided mesh is not compliant with OGS."
raise UserWarning(msg)
return is_valid
[docs]
def check_datatypes(
mesh: pv.UnstructuredGrid, strict: bool = False, meshname: str = ""
) -> bool:
mat_ids = mesh.cell_data.get("MaterialIDs", np.int32(0))
elem_ids = mesh.cell_data.get("bulk_element_ids", np.uint64(0))
node_ids = mesh.point_data.get("bulk_node_ids", np.uint64(0))
type_map = {
# Point coordinates is chosen as alternative/easier to read name for mesh.points
"Point coordinates": (
mesh.points.dtype,
{np.dtype("float32"), np.dtype("float64")},
),
"'MaterialIDs'": (
mat_ids.dtype,
{np.dtype("int32"), np.dtype("uint32")},
),
"'bulk_element_ids'": (elem_ids.dtype, {np.dtype("uint64")}),
"'bulk_node_ids'": (node_ids.dtype, {np.dtype("uint64")}),
}
for name, (datatype, ref_type) in type_map.items():
if datatype not in ref_type:
msg = (
f"{name} datatype needs to be {ref_type} for OGS, "
f"but instead it is {datatype}. "
)
if meshname != "":
msg += f"Error raised by mesh with {meshname=}"
if strict:
raise TypeError(msg)
return False
return True
[docs]
def reindex_material_ids(mesh: pv.UnstructuredGrid) -> None:
unique_mat_ids = np.unique(mesh["MaterialIDs"])
id_map = dict(
zip(*np.unique(unique_mat_ids, return_inverse=True), strict=True)
)
mesh["MaterialIDs"] = np.int32(list(map(id_map.get, mesh["MaterialIDs"])))
return
[docs]
def remove_data(mesh: pv.UnstructuredGrid, datanames: list[str]) -> None:
for dataname in datanames:
mesh.point_data.pop(dataname, None)
mesh.cell_data.pop(dataname, None)
mesh.field_data.pop(dataname, None)
[docs]
def axis_ids_2D(mesh: pv.DataSet) -> tuple[int, int]:
"Return the two axes, in which the mesh (predominantly) lives in."
from ogstools.plot.utils import get_projection
tri = pv.Triangle(
[mesh.points[0], mesh.points[mesh.n_points // 2], mesh.points[-1]]
)
axis_1, axis_2, _, _ = get_projection(tri)
len1, len2 = (len(np.unique(mesh.points[:, ax])) for ax in [axis_1, axis_2])
if len1 == len2:
if axis_2 > axis_1:
return axis_1, axis_2
return axis_2, axis_1
if len1 <= len2:
return axis_1, axis_2
return axis_2, axis_1
[docs]
def reshape_obs_points(
points: np.ndarray | list, mesh: pv.UnstructuredGrid | None = None
) -> np.ndarray:
points = np.asarray(points)
pts = points.reshape((-1, points.shape[-1]))
# Add missing columns to comply with pyvista expectations
if pts.shape[1] == 3:
pts_pyvista = pts
elif mesh is None:
pts_pyvista = np.hstack(
(pts, np.zeros((pts.shape[0], 3 - pts.shape[1])))
)
else:
# Detect and handle flat dimensions
geom = mesh.points
flat_axis = np.argwhere(np.all(np.isclose(geom, geom[0]), axis=0))
flat_axis = flat_axis.flatten()
if pts.shape[1] + len(flat_axis) < 3:
err_msg = (
"Number of flat axis and number of coordinates"
" in provided points doesn't add up to 3."
" Please ensure that the provided points match"
" the plane of the mesh."
)
raise RuntimeError(err_msg)
pts_pyvista = np.empty((pts.shape[0], 3))
pts_id = 0
for col_id in range(3):
if col_id in flat_axis:
pts_pyvista[:, col_id] = (
np.ones((pts.shape[0],)) * geom[0, col_id]
)
else:
pts_pyvista[:, col_id] = pts[:, pts_id]
pts_id = pts_id + 1
return pts_pyvista
[docs]
def ordered_cell_ids(edges: pv.PolyData) -> list[int]:
n_cells = edges.n_cells
# shape=(n_cells, 2, 3), the 2 is for pointA and pointB
cell_pts = np.asarray([cell.points for cell in edges.cell])
ordered_cell_ids = [0]
cell_id = 0
compare_idx = 1
def next_unused(length: int, used: list[int]) -> int:
return next(idx for idx in range(length) if idx not in sorted(used))
for _ in range(n_cells - 1):
matching = np.equal(
cell_pts[cell_id, compare_idx], cell_pts[:, 1 - compare_idx]
).all(axis=1)
if not any(matching):
ordered_cell_ids = ordered_cell_ids[::-1]
compare_idx = 1 - compare_idx
matching = np.equal(
cell_pts[ordered_cell_ids[-1], compare_idx],
cell_pts[:, 1 - compare_idx],
).all(axis=1)
if not any(matching):
next_id = next_unused(n_cells, ordered_cell_ids)
else:
next_id = np.argmax(matching)
else:
next_id = np.argmax(matching)
if next_id in ordered_cell_ids:
next_id = next_unused(n_cells, ordered_cell_ids)
ordered_cell_ids += [int(next_id)]
cell_id = int(next_id)
return ordered_cell_ids
[docs]
def unique_cell_types(mesh: pv.DataSet) -> list[pv.CellType]:
"Returns the unique cell types of the mesh"
if hasattr(mesh, "celltypes"):
return np.unique(mesh.celltypes).tolist()
return list({cell.type for cell in mesh.cell})
[docs]
def pv_set_attr(mesh: pv.DataSet, attr: str, value: Any) -> None:
"""
Set a PyVista mesh attribute.
Updates the attribute if it already exists; otherwise creates it
using :func:`pyvista.set_new_attribute`.
"""
if hasattr(mesh, attr):
setattr(mesh, attr, value)
else:
pv.set_new_attribute(mesh, attr, value)
[docs]
def angles(
dataset: pv.DataSet | Sequence[pv.DataSet],
center: Sequence = (0.0, 0.0, 0.0),
normal: Sequence = (0.0, 0.0, 1.0),
) -> np.ndarray:
"""Compute the angles of the mesh's points around a normal and center.
:param mesh: For the points of this mesh the angles are computed.
:param center: Center of rotation.
:param normal: Normal axis of rotation.
"""
mesh = dataset if isinstance(dataset, pv.DataSet) else dataset[0]
n = np.asarray(normal, dtype=float)
assert n.shape == (3,), "normal must be length-3"
assert not np.allclose(n, [0, 0, 0]), "normal must have a length"
n_unit = n / np.linalg.norm(n)
vecs = mesh.points - np.asarray(center, dtype=float)
# project each vector into the plane
v_dot_n = np.dot(vecs, n_unit) # (N,)
v_proj = vecs - np.outer(v_dot_n, n_unit) # (N,3)
trial_axis = np.array([1.0, 0.0, 0.0])
if abs(np.dot(trial_axis, n_unit)) > 0.9:
trial_axis = np.array([0.0, 1.0, 0.0])
u_axis = trial_axis - np.dot(trial_axis, n_unit) * n_unit
u_unit = u_axis / np.linalg.norm(u_axis)
v_axis = np.cross(n_unit, u_unit)
v_unit = v_axis / np.linalg.norm(v_axis)
# coordinates in plane
x = np.dot(v_proj, u_unit)
y = np.dot(v_proj, v_unit)
result = np.arctan2(y, x) # in radians, range (-pi, pi]
return np.where(np.hypot(x, y) > 1e-12, result, 0.0)
[docs]
def azimuth(dataset: pv.DataSet | pv.DataSet) -> np.ndarray | None:
"Calculate the azimuth angle with regards to the z-axis"
mesh = dataset if isinstance(dataset, pv.DataSet) else dataset[0]
if mesh.GetMaxSpatialDimension() == 2:
return None
pts, z = (mesh.points, mesh.points[:, 2])
r = np.hypot(*pts[:, [0, 1]].T)
return np.arctan(
np.divide(r, z, out=np.ones_like(z) * 1e12, where=z != 0.0)
)