lapy 1.6.0__tar.gz → 1.7.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lapy-1.6.0 → lapy-1.7.0.dev0}/PKG-INFO +12 -17
- {lapy-1.6.0 → lapy-1.7.0.dev0}/README.md +3 -2
- lapy-1.7.0.dev0/lapy/_cholmod.py +102 -0
- lapy-1.7.0.dev0/lapy/_gmsh_io.py +266 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/_tet_io.py +11 -96
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/_tria_io.py +15 -257
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/conformal.py +353 -120
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/diffgeo.py +26 -32
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/heat.py +8 -13
- lapy-1.7.0.dev0/lapy/intersect.py +238 -0
- lapy-1.7.0.dev0/lapy/remesh.py +434 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/shapedna.py +3 -3
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/solver.py +24 -20
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/tria_mesh.py +289 -18
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/utils/_config.py +0 -2
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/utils/_imports.py +1 -4
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy.egg-info/PKG-INFO +12 -17
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy.egg-info/SOURCES.txt +4 -1
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy.egg-info/requires.txt +6 -13
- {lapy-1.6.0 → lapy-1.7.0.dev0}/pyproject.toml +26 -29
- lapy-1.6.0/setup.py +0 -3
- {lapy-1.6.0 → lapy-1.7.0.dev0}/LICENSE +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/__init__.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/_read_geometry.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/_version.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/commands/__init__.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/commands/sys_info.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/io.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/plot.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/polygon.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/tet_mesh.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy/utils/__init__.py +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy.egg-info/dependency_links.txt +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy.egg-info/entry_points.txt +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/lapy.egg-info/top_level.txt +0 -0
- {lapy-1.6.0 → lapy-1.7.0.dev0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lapy
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0.dev0
|
|
4
4
|
Summary: A package for differential geometry on meshes (Laplace, FEM)
|
|
5
5
|
Author-email: Martin Reuter <martin.reuter@dzne.de>
|
|
6
6
|
Maintainer-email: Martin Reuter <martin.reuter@dzne.de>
|
|
@@ -14,43 +14,39 @@ Classifier: Operating System :: Microsoft :: Windows
|
|
|
14
14
|
Classifier: Operating System :: Unix
|
|
15
15
|
Classifier: Operating System :: MacOS
|
|
16
16
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
21
|
Classifier: Natural Language :: English
|
|
22
22
|
Classifier: Intended Audience :: Science/Research
|
|
23
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE
|
|
26
|
-
Requires-Dist: nibabel
|
|
27
|
-
Requires-Dist: numpy>=1.
|
|
28
|
-
Requires-Dist: plotly
|
|
29
|
-
Requires-Dist: psutil
|
|
30
|
-
Requires-Dist: scipy!=1.13.0
|
|
26
|
+
Requires-Dist: nibabel>=4.0
|
|
27
|
+
Requires-Dist: numpy>=1.23.2
|
|
28
|
+
Requires-Dist: plotly>=5.0
|
|
29
|
+
Requires-Dist: psutil>=5.9
|
|
30
|
+
Requires-Dist: scipy!=1.13.0,>=1.9.2
|
|
31
31
|
Provides-Extra: build
|
|
32
32
|
Requires-Dist: build; extra == "build"
|
|
33
33
|
Requires-Dist: twine; extra == "build"
|
|
34
34
|
Provides-Extra: chol
|
|
35
|
-
Requires-Dist:
|
|
35
|
+
Requires-Dist: cholespy>=2.0; extra == "chol"
|
|
36
36
|
Provides-Extra: doc
|
|
37
37
|
Requires-Dist: furo!=2023.8.17; extra == "doc"
|
|
38
38
|
Requires-Dist: matplotlib; extra == "doc"
|
|
39
|
-
Requires-Dist: memory-profiler; extra == "doc"
|
|
40
39
|
Requires-Dist: numpydoc; extra == "doc"
|
|
41
40
|
Requires-Dist: sphinx!=7.2.*,>=7.0; extra == "doc"
|
|
42
41
|
Requires-Dist: sphinxcontrib-bibtex; extra == "doc"
|
|
43
42
|
Requires-Dist: sphinx-copybutton; extra == "doc"
|
|
44
43
|
Requires-Dist: sphinx-design; extra == "doc"
|
|
45
|
-
Requires-Dist: sphinx-gallery; extra == "doc"
|
|
46
|
-
Requires-Dist: sphinx-issues; extra == "doc"
|
|
47
44
|
Requires-Dist: nbsphinx; extra == "doc"
|
|
48
45
|
Requires-Dist: IPython; extra == "doc"
|
|
49
46
|
Requires-Dist: ipykernel; extra == "doc"
|
|
50
47
|
Provides-Extra: style
|
|
51
48
|
Requires-Dist: bibclean; extra == "style"
|
|
52
49
|
Requires-Dist: codespell; extra == "style"
|
|
53
|
-
Requires-Dist: pydocstyle[toml]; extra == "style"
|
|
54
50
|
Requires-Dist: ruff; extra == "style"
|
|
55
51
|
Provides-Extra: test
|
|
56
52
|
Requires-Dist: pytest; extra == "test"
|
|
@@ -62,8 +58,6 @@ Requires-Dist: lapy[chol]; extra == "all"
|
|
|
62
58
|
Requires-Dist: lapy[doc]; extra == "all"
|
|
63
59
|
Requires-Dist: lapy[style]; extra == "all"
|
|
64
60
|
Requires-Dist: lapy[test]; extra == "all"
|
|
65
|
-
Provides-Extra: full
|
|
66
|
-
Requires-Dist: lapy[all]; extra == "full"
|
|
67
61
|
Dynamic: license-file
|
|
68
62
|
|
|
69
63
|
[](https://pypi.org/project/lapy/)
|
|
@@ -94,6 +88,8 @@ Key design goals:
|
|
|
94
88
|
- `heat`: heat kernel and diffusion utilities, geodesics via heat method.
|
|
95
89
|
- `shapedna`: compute ShapeDNA (Laplace spectra) for shape descriptors.
|
|
96
90
|
- `conformal`: conformal mapping methods for genus-0 surfaces.
|
|
91
|
+
- `intersect`: self-intersection tests for triangle meshes, and undoing vertex moves that create intersections.
|
|
92
|
+
- `remesh`: isotropic remeshing to near-equilateral triangles that keeps the topology, and closest points on a surface.
|
|
97
93
|
- `io`: read/write vertex functions and eigenvector files.
|
|
98
94
|
- `plot`: lightweight Plotly wrappers for interactive visualization.
|
|
99
95
|
|
|
@@ -128,8 +124,7 @@ python3 -m pip install --user --src /my/preferred/location --editable git+https:
|
|
|
128
124
|
|
|
129
125
|
### Dependencies and optional backends
|
|
130
126
|
- Core: Python 3, NumPy, SciPy.
|
|
131
|
-
- Optional
|
|
132
|
-
Note: CHOLMOD (via scikit-sparse) is not currently installable via plain `pip` on all platforms; use `conda` when possible. If `use_cholmod=True` is requested, LaPy attempts to import CHOLMOD and will raise if it is unavailable. Install ordering: install `numpy` and `scipy` first, then `scikit-sparse`.
|
|
127
|
+
- Optional: cholespy (for CHOLMOD) to accelerate sparse solves with the Cholesky decomposition. Install it with `pip install lapy[chol]`. If `use_cholmod=True` is requested without cholespy, LaPy raises an ImportError.
|
|
133
128
|
|
|
134
129
|
## Solver backends
|
|
135
130
|
- Default: SciPy sparse LU/QR routines.
|
|
@@ -26,6 +26,8 @@ Key design goals:
|
|
|
26
26
|
- `heat`: heat kernel and diffusion utilities, geodesics via heat method.
|
|
27
27
|
- `shapedna`: compute ShapeDNA (Laplace spectra) for shape descriptors.
|
|
28
28
|
- `conformal`: conformal mapping methods for genus-0 surfaces.
|
|
29
|
+
- `intersect`: self-intersection tests for triangle meshes, and undoing vertex moves that create intersections.
|
|
30
|
+
- `remesh`: isotropic remeshing to near-equilateral triangles that keeps the topology, and closest points on a surface.
|
|
29
31
|
- `io`: read/write vertex functions and eigenvector files.
|
|
30
32
|
- `plot`: lightweight Plotly wrappers for interactive visualization.
|
|
31
33
|
|
|
@@ -60,8 +62,7 @@ python3 -m pip install --user --src /my/preferred/location --editable git+https:
|
|
|
60
62
|
|
|
61
63
|
### Dependencies and optional backends
|
|
62
64
|
- Core: Python 3, NumPy, SciPy.
|
|
63
|
-
- Optional
|
|
64
|
-
Note: CHOLMOD (via scikit-sparse) is not currently installable via plain `pip` on all platforms; use `conda` when possible. If `use_cholmod=True` is requested, LaPy attempts to import CHOLMOD and will raise if it is unavailable. Install ordering: install `numpy` and `scipy` first, then `scikit-sparse`.
|
|
65
|
+
- Optional: cholespy (for CHOLMOD) to accelerate sparse solves with the Cholesky decomposition. Install it with `pip install lapy[chol]`. If `use_cholmod=True` is requested without cholespy, LaPy raises an ImportError.
|
|
65
66
|
|
|
66
67
|
## Solver backends
|
|
67
68
|
- Default: SciPy sparse LU/QR routines.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Sparse Cholesky factorization with CHOLMOD, provided by the cholespy package."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy import sparse
|
|
5
|
+
|
|
6
|
+
from .utils._imports import import_optional_dependency
|
|
7
|
+
|
|
8
|
+
_INT32_MAX = np.iinfo(np.int32).max
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def require_cholmod() -> None:
|
|
12
|
+
"""Raise an ImportError if cholespy, which provides CHOLMOD, is not installed."""
|
|
13
|
+
import_optional_dependency("cholespy", extra="It is required for use_cholmod=True.", raise_error=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CholeskyFactor:
|
|
17
|
+
"""Cholesky factorization of a sparse symmetric positive definite matrix.
|
|
18
|
+
|
|
19
|
+
CHOLMOD reads only one triangle of the matrix, so the matrix has to be
|
|
20
|
+
genuinely symmetric. The factorization and the solves run in double
|
|
21
|
+
precision; solutions are returned in ``float32`` for a ``float32`` matrix
|
|
22
|
+
and in ``float64`` otherwise.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
matrix : scipy.sparse.spmatrix
|
|
27
|
+
Sparse symmetric positive definite matrix of shape (n, n).
|
|
28
|
+
|
|
29
|
+
Raises
|
|
30
|
+
------
|
|
31
|
+
ImportError
|
|
32
|
+
If cholespy is not installed.
|
|
33
|
+
ValueError
|
|
34
|
+
If the matrix is not square, too large for 32-bit indices, or not
|
|
35
|
+
positive definite.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, matrix) -> None:
|
|
39
|
+
cholespy = import_optional_dependency(
|
|
40
|
+
"cholespy", extra="It is required for use_cholmod=True.", raise_error=True
|
|
41
|
+
)
|
|
42
|
+
a = sparse.csc_matrix(matrix)
|
|
43
|
+
a.sum_duplicates()
|
|
44
|
+
a.sort_indices()
|
|
45
|
+
n = a.shape[0]
|
|
46
|
+
if a.shape != (n, n):
|
|
47
|
+
raise ValueError(f"Cholesky factorization needs a square matrix, got shape {a.shape}.")
|
|
48
|
+
# cholespy takes 32-bit indices; scipy switches to 64-bit ones beyond that range
|
|
49
|
+
if n > _INT32_MAX or a.nnz > _INT32_MAX:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Matrix with {n} rows and {a.nnz} non-zeros is too large for CHOLMOD's 32-bit indices."
|
|
52
|
+
)
|
|
53
|
+
# output precision; cholespy factorizes in double precision on the CPU either way
|
|
54
|
+
self.dtype = np.dtype(np.float32) if a.dtype == np.float32 else np.dtype(np.float64)
|
|
55
|
+
# Passed as CSR, which for a symmetric matrix holds the same arrays as CSC.
|
|
56
|
+
# cholespy reallocates the arrays of a CSC input in place, which corrupts the
|
|
57
|
+
# heap when NumPy owns them (it crashes on Windows), but copies a CSR input.
|
|
58
|
+
try:
|
|
59
|
+
self._solver = cholespy.CholeskySolverD(
|
|
60
|
+
n,
|
|
61
|
+
a.indptr.astype(np.int32),
|
|
62
|
+
a.indices.astype(np.int32),
|
|
63
|
+
a.data.astype(np.float64),
|
|
64
|
+
cholespy.MatrixType.CSR,
|
|
65
|
+
)
|
|
66
|
+
except ValueError as err:
|
|
67
|
+
if "positive definite" not in str(err):
|
|
68
|
+
raise
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"Cholesky factorization failed because the matrix is not positive definite, "
|
|
71
|
+
"for example a Laplace operator without boundary conditions. "
|
|
72
|
+
"Use use_cholmod=False for the LU decomposition instead."
|
|
73
|
+
) from err
|
|
74
|
+
self.shape = (n, n)
|
|
75
|
+
|
|
76
|
+
def solve(self, b) -> np.ndarray:
|
|
77
|
+
"""Solve ``A x = b`` with the factorized matrix.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
b : array_like or scipy.sparse.spmatrix
|
|
82
|
+
Right hand side of shape (n,) or (n, n_rhs).
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
np.ndarray
|
|
87
|
+
Solution of the same shape as ``b``, ``float32`` for a ``float32``
|
|
88
|
+
matrix and ``float64`` otherwise.
|
|
89
|
+
|
|
90
|
+
Raises
|
|
91
|
+
------
|
|
92
|
+
ValueError
|
|
93
|
+
If the first dimension of ``b`` does not match the matrix.
|
|
94
|
+
"""
|
|
95
|
+
if sparse.issparse(b):
|
|
96
|
+
b = b.toarray()
|
|
97
|
+
b = np.ascontiguousarray(np.asarray(b), dtype=np.float64)
|
|
98
|
+
if b.ndim not in (1, 2) or b.shape[0] != self.shape[0]:
|
|
99
|
+
raise ValueError(f"Right hand side of shape {b.shape} does not match a matrix of shape {self.shape}.")
|
|
100
|
+
x = np.empty_like(b)
|
|
101
|
+
self._solver.solve(b, x)
|
|
102
|
+
return x.astype(self.dtype, copy=False)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Shared reader for GMSH meshes, MSH 2 ASCII format.
|
|
2
|
+
|
|
3
|
+
Both :mod:`lapy._tria_io` and :mod:`lapy._tet_io` parse the same file format,
|
|
4
|
+
so the parsing lives here once and each of them keeps only the thin wrapper
|
|
5
|
+
that turns the result into its own mesh type.
|
|
6
|
+
|
|
7
|
+
.. moduleauthor:: Nico Schloemer <nico.schloemer@gmail.com>
|
|
8
|
+
LICENSE MIT
|
|
9
|
+
https://github.com/nschloe/meshio
|
|
10
|
+
http://geuz.org/gmsh/doc/texinfo/gmsh.html#MSH-ASCII-file-format
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from itertools import islice
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# gmsh element code -> name, and the vertex count of each name.
|
|
23
|
+
GMSH_TO_NAME = {
|
|
24
|
+
15: "vertex",
|
|
25
|
+
1: "line",
|
|
26
|
+
2: "triangle",
|
|
27
|
+
3: "quad",
|
|
28
|
+
4: "tetra",
|
|
29
|
+
5: "hexahedron",
|
|
30
|
+
6: "wedge",
|
|
31
|
+
7: "pyramid",
|
|
32
|
+
8: "line3",
|
|
33
|
+
9: "triangle6",
|
|
34
|
+
10: "quad9",
|
|
35
|
+
11: "tetra10",
|
|
36
|
+
12: "hexahedron27",
|
|
37
|
+
13: "prism18",
|
|
38
|
+
14: "pyramid14",
|
|
39
|
+
26: "line4",
|
|
40
|
+
36: "quad16",
|
|
41
|
+
}
|
|
42
|
+
NAME_TO_GMSH = {v: k for k, v in GMSH_TO_NAME.items()}
|
|
43
|
+
NODES_PER_CELL = {
|
|
44
|
+
"vertex": 1,
|
|
45
|
+
"line": 2,
|
|
46
|
+
"triangle": 3,
|
|
47
|
+
"quad": 4,
|
|
48
|
+
"tetra": 4,
|
|
49
|
+
"hexahedron": 8,
|
|
50
|
+
"wedge": 6,
|
|
51
|
+
"pyramid": 5,
|
|
52
|
+
"line3": 3,
|
|
53
|
+
"triangle6": 6,
|
|
54
|
+
"quad9": 9,
|
|
55
|
+
"tetra10": 10,
|
|
56
|
+
"hexahedron27": 27,
|
|
57
|
+
"prism18": 18,
|
|
58
|
+
"pyramid14": 14,
|
|
59
|
+
"line4": 4,
|
|
60
|
+
"quad16": 16,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fail(msg: str) -> None:
|
|
65
|
+
"""Log and raise in the format the other readers use."""
|
|
66
|
+
msg = f"[{msg}] --> FAILED\n"
|
|
67
|
+
logger.error(msg)
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _expect(line: str, keyword: str) -> None:
|
|
72
|
+
"""Fail on a malformed file.
|
|
73
|
+
|
|
74
|
+
Not an assert: these validate untrusted file content, and assert is
|
|
75
|
+
stripped under ``python -O``, which would let a truncated file through and
|
|
76
|
+
only surface much later as a wrong mesh.
|
|
77
|
+
"""
|
|
78
|
+
if line.strip() != keyword:
|
|
79
|
+
_fail(f"{keyword} keyword not found")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _holds_type(block: str, name: str) -> bool:
|
|
83
|
+
"""Say whether an element block contains a cell type, without parsing it.
|
|
84
|
+
|
|
85
|
+
Each record starts ``number type ...``, so the type is the second token of
|
|
86
|
+
a line. Scanning the raw text for that is several times cheaper than
|
|
87
|
+
converting the whole block to integers, which lets a caller that asked for
|
|
88
|
+
triangles reject a volume mesh before paying for the conversion.
|
|
89
|
+
"""
|
|
90
|
+
return re.search(rf"(?m)^\s*\d+\s+{NAME_TO_GMSH[name]}\s", block) is not None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cells_from_block(flat: np.ndarray, num_cells: int) -> dict:
|
|
94
|
+
"""Split a flat MSH 2 element block into 0-based node indices per cell type.
|
|
95
|
+
|
|
96
|
+
A record is ``number type n_tags <tags...> <nodes...>``, so records only
|
|
97
|
+
share a width when the block holds a single element type with a single tag
|
|
98
|
+
count. That is the common case and reshapes in one step; a mixed block
|
|
99
|
+
falls back to walking the records.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
flat : np.ndarray
|
|
104
|
+
All integers of the element block, in file order.
|
|
105
|
+
num_cells : int
|
|
106
|
+
Number of records the block declares.
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
dict
|
|
111
|
+
Maps an element name to its (n_cells, n_vertices) index array.
|
|
112
|
+
"""
|
|
113
|
+
if num_cells == 0:
|
|
114
|
+
return {}
|
|
115
|
+
|
|
116
|
+
width, remainder = divmod(flat.size, num_cells)
|
|
117
|
+
if remainder == 0:
|
|
118
|
+
rows = flat.reshape(num_cells, width)
|
|
119
|
+
etype, n_tags = int(rows[0, 1]), int(rows[0, 2])
|
|
120
|
+
name = GMSH_TO_NAME[etype]
|
|
121
|
+
uniform = (
|
|
122
|
+
3 + n_tags + NODES_PER_CELL[name] == width
|
|
123
|
+
and bool((rows[:, 1] == etype).all())
|
|
124
|
+
and bool((rows[:, 2] == n_tags).all())
|
|
125
|
+
)
|
|
126
|
+
if uniform:
|
|
127
|
+
return {name: rows[:, -NODES_PER_CELL[name] :] - 1}
|
|
128
|
+
|
|
129
|
+
# Mixed block: walk the records. tolist() keeps the walk in plain python,
|
|
130
|
+
# which is far cheaper than numpy scalar indexing in a loop, and the node
|
|
131
|
+
# indices are extended into one flat list per type rather than appended as
|
|
132
|
+
# rows, so the final conversion is a single reshape.
|
|
133
|
+
values = flat.tolist()
|
|
134
|
+
chunks: dict = {}
|
|
135
|
+
pos, size = 0, len(values)
|
|
136
|
+
while pos < size:
|
|
137
|
+
name = GMSH_TO_NAME[values[pos + 1]]
|
|
138
|
+
n_nodes = NODES_PER_CELL[name]
|
|
139
|
+
end = pos + 3 + values[pos + 2] + n_nodes
|
|
140
|
+
chunks.setdefault(name, []).extend(values[end - n_nodes : end])
|
|
141
|
+
pos = end
|
|
142
|
+
return {
|
|
143
|
+
k: np.array(v, dtype=np.int64).reshape(-1, NODES_PER_CELL[k]) - 1
|
|
144
|
+
for k, v in chunks.items()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_gmsh(filename: str, want: str | None = None) -> tuple[np.ndarray, dict]:
|
|
149
|
+
"""Read points and cells from a GMSH file, MSH 2 ASCII format.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
filename : str
|
|
154
|
+
Filename to load, must end in ``.msh``.
|
|
155
|
+
want : str or None, default=None
|
|
156
|
+
Cell type the caller needs, for example ``"triangle"`` or ``"tetra"``.
|
|
157
|
+
When given, a file that cannot supply it is rejected as early as
|
|
158
|
+
possible: the node coordinates are skipped rather than parsed until the
|
|
159
|
+
element block has been checked, and the check itself is a text scan
|
|
160
|
+
rather than a full conversion. Reading a volume mesh as a triangle mesh
|
|
161
|
+
therefore costs a fraction of a full read.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
points : np.ndarray
|
|
166
|
+
Vertex coordinates, shape (n_points, 3).
|
|
167
|
+
cells : dict
|
|
168
|
+
Maps an element name to its (n_cells, n_vertices) index array.
|
|
169
|
+
|
|
170
|
+
Raises
|
|
171
|
+
------
|
|
172
|
+
OSError
|
|
173
|
+
If file is not found or not readable.
|
|
174
|
+
ValueError
|
|
175
|
+
If the extension is not ``.msh``, the file is binary or not version 2,
|
|
176
|
+
a section is malformed, or ``want`` is not present.
|
|
177
|
+
"""
|
|
178
|
+
if os.path.splitext(filename)[1] != ".msh":
|
|
179
|
+
_fail("no .msh file")
|
|
180
|
+
|
|
181
|
+
logger.debug("--> GMSH format ... ")
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
f = open(filename)
|
|
185
|
+
except OSError:
|
|
186
|
+
logger.error("[file not found or not readable]")
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
points = None
|
|
190
|
+
node_offset = None
|
|
191
|
+
num_nodes = 0
|
|
192
|
+
cells: dict = {}
|
|
193
|
+
|
|
194
|
+
with f:
|
|
195
|
+
while True:
|
|
196
|
+
line = f.readline()
|
|
197
|
+
if not line:
|
|
198
|
+
break
|
|
199
|
+
if not line.startswith("$"):
|
|
200
|
+
_fail(f"section keyword expected, found '{line.strip()}'")
|
|
201
|
+
section = line[1:].strip()
|
|
202
|
+
|
|
203
|
+
if section == "MeshFormat":
|
|
204
|
+
larr = list(filter(None, f.readline().split()))
|
|
205
|
+
if larr[0][0] != "2":
|
|
206
|
+
_fail(f"need mesh format 2, found {larr[0]}")
|
|
207
|
+
if larr[1] != "0":
|
|
208
|
+
# The file is opened in text mode, so the binary layout
|
|
209
|
+
# cannot be read from this handle at all.
|
|
210
|
+
_fail("binary format not implemented")
|
|
211
|
+
_expect(f.readline(), "$EndMeshFormat")
|
|
212
|
+
|
|
213
|
+
elif section == "Nodes":
|
|
214
|
+
num_nodes = int(f.readline())
|
|
215
|
+
if want is not None and not cells:
|
|
216
|
+
# Skip for now. If the element block turns out not to hold
|
|
217
|
+
# `want` we never pay to parse these coordinates, and
|
|
218
|
+
# skipping is about thirty times cheaper than parsing.
|
|
219
|
+
node_offset = f.tell()
|
|
220
|
+
for _ in islice(f, num_nodes):
|
|
221
|
+
pass
|
|
222
|
+
else:
|
|
223
|
+
points = _read_points(f, num_nodes)
|
|
224
|
+
_expect(f.readline(), "$EndNodes")
|
|
225
|
+
|
|
226
|
+
elif section == "Elements":
|
|
227
|
+
num_cells = int(f.readline())
|
|
228
|
+
block = "".join(islice(f, num_cells))
|
|
229
|
+
if want is not None:
|
|
230
|
+
first = block.split("\n", 1)[0].split()
|
|
231
|
+
known = len(first) > 1 and int(first[1]) == NAME_TO_GMSH[want]
|
|
232
|
+
if not known and not _holds_type(block, want):
|
|
233
|
+
_fail(f"file holds no {want} elements")
|
|
234
|
+
cells = _cells_from_block(
|
|
235
|
+
np.array(block.split(), dtype=np.int64), num_cells
|
|
236
|
+
)
|
|
237
|
+
_expect(f.readline(), "$EndElements")
|
|
238
|
+
|
|
239
|
+
else:
|
|
240
|
+
# Skip sections we do not use, such as $PhysicalNames,
|
|
241
|
+
# $Periodic or $NodeData. Erroring out instead would reject
|
|
242
|
+
# perfectly good files.
|
|
243
|
+
end = f"$End{section}"
|
|
244
|
+
while True:
|
|
245
|
+
line = f.readline()
|
|
246
|
+
if not line:
|
|
247
|
+
_fail(f"{end} keyword not found")
|
|
248
|
+
if line.strip() == end:
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
if points is None and node_offset is not None:
|
|
252
|
+
f.seek(node_offset)
|
|
253
|
+
points = _read_points(f, num_nodes)
|
|
254
|
+
|
|
255
|
+
if points is None:
|
|
256
|
+
_fail("$Nodes section not found")
|
|
257
|
+
if want is not None and want not in cells:
|
|
258
|
+
_fail(f"file holds no {want} elements")
|
|
259
|
+
|
|
260
|
+
return points, cells
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _read_points(f, num_nodes: int) -> np.ndarray:
|
|
264
|
+
"""Read a node block as (n, 3), dropping the leading index column."""
|
|
265
|
+
points = np.fromfile(f, "float32", num_nodes * 4, " ").reshape(num_nodes, 4)
|
|
266
|
+
return np.ascontiguousarray(points[:, 1:])
|
|
@@ -4,7 +4,6 @@ Should be called via the TetMesh member functions.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import logging
|
|
7
|
-
import os.path
|
|
8
7
|
from typing import TYPE_CHECKING
|
|
9
8
|
|
|
10
9
|
import numpy as np
|
|
@@ -15,7 +14,7 @@ if TYPE_CHECKING:
|
|
|
15
14
|
logger = logging.getLogger(__name__)
|
|
16
15
|
|
|
17
16
|
def read_gmsh(filename: str) -> "TetMesh":
|
|
18
|
-
"""Load GMSH tetrahedron mesh.
|
|
17
|
+
"""Load GMSH tetrahedron mesh, MSH 2 ASCII format.
|
|
19
18
|
|
|
20
19
|
Parameters
|
|
21
20
|
----------
|
|
@@ -32,101 +31,17 @@ def read_gmsh(filename: str) -> "TetMesh":
|
|
|
32
31
|
OSError
|
|
33
32
|
If file is not found or not readable.
|
|
34
33
|
ValueError
|
|
35
|
-
If
|
|
34
|
+
If the extension is not ``.msh``, the file is binary or not version 2,
|
|
35
|
+
a section is malformed, or the file holds no tetrahedra.
|
|
36
36
|
"""
|
|
37
|
-
extension = os.path.splitext(filename)[1]
|
|
38
|
-
verbose = 1
|
|
39
|
-
if verbose > 0:
|
|
40
|
-
logger.info("--> GMSH format ... ")
|
|
41
|
-
if extension != ".msh":
|
|
42
|
-
msg = "[no .msh file] --> FAILED\n"
|
|
43
|
-
logger.error(msg)
|
|
44
|
-
raise ValueError(msg)
|
|
45
|
-
try:
|
|
46
|
-
f = open(filename)
|
|
47
|
-
except OSError:
|
|
48
|
-
logger.error("[file not found or not readable]")
|
|
49
|
-
raise
|
|
50
|
-
line = f.readline()
|
|
51
|
-
if not line.startswith("$MeshFormat"):
|
|
52
|
-
msg = "[$MeshFormat keyword not found] --> FAILED\n"
|
|
53
|
-
logger.error(msg)
|
|
54
|
-
f.close()
|
|
55
|
-
raise ValueError(msg)
|
|
56
|
-
line = f.readline()
|
|
57
|
-
larr = line.split()
|
|
58
|
-
ver = float(larr[0])
|
|
59
|
-
ftype = int(larr[1])
|
|
60
|
-
datatype = int(larr[2])
|
|
61
|
-
logger.debug(
|
|
62
|
-
"Msh file ver %s, ftype %s, datatype %s",
|
|
63
|
-
ver,
|
|
64
|
-
ftype,
|
|
65
|
-
datatype,
|
|
66
|
-
)
|
|
67
|
-
if ftype != 0:
|
|
68
|
-
msg = "[binary format not implemented] --> FAILED\n"
|
|
69
|
-
logger.error(msg)
|
|
70
|
-
f.close()
|
|
71
|
-
raise ValueError(msg)
|
|
72
|
-
line = f.readline()
|
|
73
|
-
if not line.startswith("$EndMeshFormat"):
|
|
74
|
-
msg = "[$EndMeshFormat keyword not found] --> FAILED\n"
|
|
75
|
-
logger.error(msg)
|
|
76
|
-
f.close()
|
|
77
|
-
raise ValueError(msg)
|
|
78
|
-
line = f.readline()
|
|
79
|
-
if not line.startswith("$Nodes"):
|
|
80
|
-
msg = "[$Nodes keyword not found] --> FAILED\n"
|
|
81
|
-
logger.error(msg)
|
|
82
|
-
f.close()
|
|
83
|
-
raise ValueError(msg)
|
|
84
|
-
pnum = int(f.readline())
|
|
85
|
-
# read (nodes X 4) matrix as chunk
|
|
86
|
-
# drop first column
|
|
87
|
-
v = np.fromfile(f, "float32", 4 * pnum, " ")
|
|
88
|
-
v.shape = (pnum, 4)
|
|
89
|
-
v = np.delete(v, 0, 1)
|
|
90
|
-
line = f.readline()
|
|
91
|
-
if not line.startswith("$EndNodes"):
|
|
92
|
-
msg = "[$EndNodes keyword not found] --> FAILED\n"
|
|
93
|
-
logger.error(msg)
|
|
94
|
-
f.close()
|
|
95
|
-
raise ValueError(msg)
|
|
96
|
-
line = f.readline()
|
|
97
|
-
if not line.startswith("$Elements"):
|
|
98
|
-
msg = "[$Elements keyword not found] --> FAILED\n"
|
|
99
|
-
logger.error(msg)
|
|
100
|
-
f.close()
|
|
101
|
-
raise ValueError(msg)
|
|
102
|
-
tnum = int(f.readline())
|
|
103
|
-
pos = f.tell()
|
|
104
|
-
line = f.readline()
|
|
105
|
-
f.seek(pos)
|
|
106
|
-
larr = line.split()
|
|
107
|
-
if int(larr[1]) != 4:
|
|
108
|
-
logger.debug("larr: %s", larr)
|
|
109
|
-
msg = "[can only read tetras] --> FAILED\n"
|
|
110
|
-
logger.error(msg)
|
|
111
|
-
f.close()
|
|
112
|
-
raise ValueError(msg)
|
|
113
|
-
# read (nodes X ?) matrix
|
|
114
|
-
t = np.fromfile(f, "int", tnum * len(larr), " ")
|
|
115
|
-
t.shape = (tnum, len(larr))
|
|
116
|
-
t = np.delete(t, np.s_[0 : len(larr) - 4], 1)
|
|
117
|
-
line = f.readline()
|
|
118
|
-
if not line.startswith("$EndElements"):
|
|
119
|
-
logger.debug("Line: %s", line)
|
|
120
|
-
msg = "[$EndElements keyword not found] --> FAILED\n"
|
|
121
|
-
logger.error(msg)
|
|
122
|
-
f.close()
|
|
123
|
-
raise ValueError(msg)
|
|
124
|
-
f.close()
|
|
125
|
-
logger.info(" --> DONE ( V: %d , T: %d )", v.shape[0], t.shape[0])
|
|
126
37
|
from . import TetMesh
|
|
38
|
+
from ._gmsh_io import read_gmsh as _read
|
|
127
39
|
|
|
128
|
-
|
|
129
|
-
|
|
40
|
+
points, cells = _read(filename, want="tetra")
|
|
41
|
+
logger.info(
|
|
42
|
+
" --> DONE ( V: %d , T: %d )", points.shape[0], cells["tetra"].shape[0]
|
|
43
|
+
)
|
|
44
|
+
return TetMesh(points, cells["tetra"])
|
|
130
45
|
|
|
131
46
|
def read_vtk(filename: str) -> "TetMesh":
|
|
132
47
|
"""Load VTK tetrahedron mesh.
|
|
@@ -194,7 +109,7 @@ def read_vtk(filename: str) -> "TetMesh":
|
|
|
194
109
|
pnum = int(larr[1])
|
|
195
110
|
# read points as chunk
|
|
196
111
|
v = np.fromfile(f, "float32", 3 * pnum, " ")
|
|
197
|
-
v
|
|
112
|
+
v = v.reshape(pnum, 3)
|
|
198
113
|
# expect polygon or tria_strip line
|
|
199
114
|
line = f.readline()
|
|
200
115
|
larr = line.split()
|
|
@@ -207,7 +122,7 @@ def read_vtk(filename: str) -> "TetMesh":
|
|
|
207
122
|
logger.error(msg)
|
|
208
123
|
raise ValueError(msg)
|
|
209
124
|
t = np.fromfile(f, "int", ttnum, " ")
|
|
210
|
-
t
|
|
125
|
+
t = t.reshape(tnum, 5)
|
|
211
126
|
if t[tnum - 1][0] != 4:
|
|
212
127
|
msg = "[can only read tetras] --> FAILED\n"
|
|
213
128
|
logger.error(msg)
|