fem-post 0.1.2__py3-none-any.whl
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.
- fem_post/__init__.py +41 -0
- fem_post/conversion.py +338 -0
- fem_post/export.py +46 -0
- fem_post/manager.py +131 -0
- fem_post/naming.py +37 -0
- fem_post/render.py +50 -0
- fem_post/results.py +42 -0
- fem_post/scene.py +232 -0
- fem_post/settings.py +53 -0
- fem_post/viewer.py +110 -0
- fem_post/views.py +314 -0
- fem_post-0.1.2.dist-info/METADATA +177 -0
- fem_post-0.1.2.dist-info/RECORD +15 -0
- fem_post-0.1.2.dist-info/WHEEL +4 -0
- fem_post-0.1.2.dist-info/licenses/LICENSE +21 -0
fem_post/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fem-post: VTK post-processing for fem-core results.
|
|
3
|
+
|
|
4
|
+
Create a PostProcessor for a mesh, add views to it -- the mesh, a field, a
|
|
5
|
+
frequency sweep, mode shapes -- then update them (new data, display settings,
|
|
6
|
+
current step) and export them (.vtu, .png) or show them interactively:
|
|
7
|
+
|
|
8
|
+
from fem_post import PostProcessor
|
|
9
|
+
|
|
10
|
+
post = PostProcessor(mesh)
|
|
11
|
+
post.add_mesh_view("mesh").export_png("out/mesh.png")
|
|
12
|
+
sweep = post.add_frequency_sweep_view("pressure", sweep_result)
|
|
13
|
+
sweep.export_vtu("out/sweep")
|
|
14
|
+
sweep.configure(part="real", value_range=(-1.0, 1.0)).export_pngs("out")
|
|
15
|
+
|
|
16
|
+
Solver results are consumed by shape (fem_post.results), so fem_post does not
|
|
17
|
+
depend on fem_core itself.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from fem_post.conversion import MATERIAL_FIELD, MeshSampler, mesh_to_grid
|
|
21
|
+
from fem_post.manager import PostProcessor
|
|
22
|
+
from fem_post.naming import complex_field_names, frequency_sweep_field_name, modal_field_name, modal_frequency_hz
|
|
23
|
+
from fem_post.settings import DisplaySettings
|
|
24
|
+
from fem_post.views import FieldView, FrequencySweepView, MeshView, ModalView, View
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"MATERIAL_FIELD",
|
|
28
|
+
"DisplaySettings",
|
|
29
|
+
"FieldView",
|
|
30
|
+
"FrequencySweepView",
|
|
31
|
+
"MeshSampler",
|
|
32
|
+
"MeshView",
|
|
33
|
+
"ModalView",
|
|
34
|
+
"PostProcessor",
|
|
35
|
+
"View",
|
|
36
|
+
"complex_field_names",
|
|
37
|
+
"frequency_sweep_field_name",
|
|
38
|
+
"mesh_to_grid",
|
|
39
|
+
"modal_field_name",
|
|
40
|
+
"modal_frequency_hz",
|
|
41
|
+
]
|
fem_post/conversion.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory conversion of an ngsolve mesh (plus fields on it) to a
|
|
3
|
+
vtkUnstructuredGrid -- no .vtu file round-trip needed to render.
|
|
4
|
+
|
|
5
|
+
Each volume element (a TRIG/QUAD of a 2D mesh, a TET/HEX/PRISM/PYRAMID of a
|
|
6
|
+
3D one) is refined `subdivision` times on its *reference* element into a
|
|
7
|
+
lattice of linear VTK cells. The lattice points are mapped to physical space
|
|
8
|
+
and every field is evaluated there in one vectorized call per field
|
|
9
|
+
(ngsolve.Mesh.MapToAllElements), so:
|
|
10
|
+
|
|
11
|
+
- curved (mesh_config.curvature order > 1) elements render with their true
|
|
12
|
+
curved geometry, since the coordinates themselves are evaluated through
|
|
13
|
+
the element transformation, not interpolated from the straight vertices;
|
|
14
|
+
- a higher-order field renders smoothly instead of piecewise-linear on the
|
|
15
|
+
raw (often coarse) elements -- `subdivision` is purely a rendering-density
|
|
16
|
+
knob, independent of the FEM order actually solved with;
|
|
17
|
+
- points are per element (shared vertices are duplicated), so a field that
|
|
18
|
+
is discontinuous across elements -- a material index, an L2 field --
|
|
19
|
+
renders sharply instead of being averaged.
|
|
20
|
+
|
|
21
|
+
A complex-valued field "name" is split into three real point-data arrays,
|
|
22
|
+
complex_field_names("name") -- "name_re", "name_im" and "name_abs" (the
|
|
23
|
+
pointwise Euclidean norm of the complex vector, i.e. ngsolve.Norm) -- since
|
|
24
|
+
VTK has no complex arrays. A 2-component (2D vector) field is padded to 3
|
|
25
|
+
components (z = 0): vtkWarpVector always reads 3 components, and padding
|
|
26
|
+
leaves magnitudes, and so coloring, unchanged.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from functools import lru_cache
|
|
31
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
import ngsolve as ngs
|
|
34
|
+
import numpy as np
|
|
35
|
+
import vtk
|
|
36
|
+
from vtkmodules.util.numpy_support import numpy_to_vtk, numpy_to_vtkIdTypeArray, vtk_to_numpy
|
|
37
|
+
|
|
38
|
+
from fem_post.naming import complex_field_names
|
|
39
|
+
|
|
40
|
+
MATERIAL_FIELD = "material"
|
|
41
|
+
|
|
42
|
+
IntPoint = Tuple[int, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ReferenceLattice:
|
|
47
|
+
"""A reference element refined into linear VTK cells.
|
|
48
|
+
|
|
49
|
+
points: (n_points, dim) reference coordinates (ngsolve's conventions).
|
|
50
|
+
cells: (n_cells, n_vertices) indices into points.
|
|
51
|
+
cell_type: the VTK cell type every cell in `cells` has.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
points: np.ndarray
|
|
55
|
+
cells: np.ndarray
|
|
56
|
+
cell_type: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _LatticeBuilder:
|
|
60
|
+
"""Collects integer lattice points (coordinates scaled by `scale`),
|
|
61
|
+
deduplicating them, and the cells that reference them."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, scale: int) -> None:
|
|
64
|
+
self.scale = scale
|
|
65
|
+
self._index: Dict[IntPoint, int] = {}
|
|
66
|
+
self.cells: List[List[int]] = []
|
|
67
|
+
|
|
68
|
+
def point(self, p: IntPoint) -> int:
|
|
69
|
+
if p not in self._index:
|
|
70
|
+
self._index[p] = len(self._index)
|
|
71
|
+
return self._index[p]
|
|
72
|
+
|
|
73
|
+
def add_cell(self, points: Iterable[IntPoint]) -> None:
|
|
74
|
+
self.cells.append([self.point(p) for p in points])
|
|
75
|
+
|
|
76
|
+
def build(self, cell_type: int) -> ReferenceLattice:
|
|
77
|
+
points = np.array(list(self._index), dtype=float) / self.scale
|
|
78
|
+
return ReferenceLattice(points, np.array(self.cells, dtype=np.int64), cell_type)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _mid(a: IntPoint, b: IntPoint) -> IntPoint:
|
|
82
|
+
# Exact: every coordinate is a multiple of 2**(levels left), see _refine_*.
|
|
83
|
+
return tuple((i + j) // 2 for i, j in zip(a, b))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _refine_triangle(tri: Tuple[IntPoint, ...], levels: int) -> List[Tuple[IntPoint, ...]]:
|
|
87
|
+
if levels == 0:
|
|
88
|
+
return [tri]
|
|
89
|
+
a, b, c = tri
|
|
90
|
+
ab, bc, ca = _mid(a, b), _mid(b, c), _mid(c, a)
|
|
91
|
+
children = [(a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)]
|
|
92
|
+
return [t for child in children for t in _refine_triangle(child, levels - 1)]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _refine_tetrahedron(tet: Tuple[IntPoint, ...], levels: int) -> List[Tuple[IntPoint, ...]]:
|
|
96
|
+
"""Red refinement: 4 corner tets plus the inner octahedron cut into 4
|
|
97
|
+
tets around its ac-bd diagonal."""
|
|
98
|
+
if levels == 0:
|
|
99
|
+
return [tet]
|
|
100
|
+
a, b, c, d = tet
|
|
101
|
+
ab, ac, ad, bc, bd, cd = _mid(a, b), _mid(a, c), _mid(a, d), _mid(b, c), _mid(b, d), _mid(c, d)
|
|
102
|
+
children = [
|
|
103
|
+
(a, ab, ac, ad),
|
|
104
|
+
(ab, b, bc, bd),
|
|
105
|
+
(ac, bc, c, cd),
|
|
106
|
+
(ad, bd, cd, d),
|
|
107
|
+
(ac, bd, ab, ad),
|
|
108
|
+
(ac, bd, ad, cd),
|
|
109
|
+
(ac, bd, cd, bc),
|
|
110
|
+
(ac, bd, bc, ab),
|
|
111
|
+
]
|
|
112
|
+
return [t for child in children for t in _refine_tetrahedron(child, levels - 1)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@lru_cache(maxsize=None)
|
|
116
|
+
def reference_lattice(element_type: Any, subdivision: int) -> ReferenceLattice:
|
|
117
|
+
"""The reference lattice for one ngsolve.ET element type, refined
|
|
118
|
+
`subdivision` times (2**subdivision cells per edge). PYRAMID elements
|
|
119
|
+
are never refined -- they are rare in netgen meshes and have no
|
|
120
|
+
conforming self-similar split -- and render as one linear cell."""
|
|
121
|
+
if subdivision < 0:
|
|
122
|
+
raise ValueError(f"subdivision must be >= 0, got {subdivision}")
|
|
123
|
+
n = 2**subdivision
|
|
124
|
+
builder = _LatticeBuilder(n)
|
|
125
|
+
|
|
126
|
+
if element_type == ngs.ET.TRIG:
|
|
127
|
+
for tri in _refine_triangle(((n, 0), (0, n), (0, 0)), subdivision):
|
|
128
|
+
builder.add_cell(tri)
|
|
129
|
+
return builder.build(vtk.VTK_TRIANGLE)
|
|
130
|
+
|
|
131
|
+
if element_type == ngs.ET.TET:
|
|
132
|
+
for tet in _refine_tetrahedron(((n, 0, 0), (0, n, 0), (0, 0, n), (0, 0, 0)), subdivision):
|
|
133
|
+
builder.add_cell(tet)
|
|
134
|
+
return builder.build(vtk.VTK_TETRA)
|
|
135
|
+
|
|
136
|
+
if element_type == ngs.ET.QUAD:
|
|
137
|
+
for i in range(n):
|
|
138
|
+
for j in range(n):
|
|
139
|
+
builder.add_cell([(i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)])
|
|
140
|
+
return builder.build(vtk.VTK_QUAD)
|
|
141
|
+
|
|
142
|
+
if element_type == ngs.ET.HEX:
|
|
143
|
+
for i in range(n):
|
|
144
|
+
for j in range(n):
|
|
145
|
+
for k in range(n):
|
|
146
|
+
bottom = [(i, j, k), (i + 1, j, k), (i + 1, j + 1, k), (i, j + 1, k)]
|
|
147
|
+
builder.add_cell(bottom + [(x, y, z + 1) for x, y, z in bottom])
|
|
148
|
+
return builder.build(vtk.VTK_HEXAHEDRON)
|
|
149
|
+
|
|
150
|
+
if element_type == ngs.ET.PRISM:
|
|
151
|
+
for tri in _refine_triangle(((n, 0), (0, n), (0, 0)), subdivision):
|
|
152
|
+
for k in range(n):
|
|
153
|
+
builder.add_cell([(x, y, k) for x, y in tri] + [(x, y, k + 1) for x, y in tri])
|
|
154
|
+
return builder.build(vtk.VTK_WEDGE)
|
|
155
|
+
|
|
156
|
+
if element_type == ngs.ET.PYRAMID:
|
|
157
|
+
builder = _LatticeBuilder(1)
|
|
158
|
+
builder.add_cell([(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), (0, 0, 1)])
|
|
159
|
+
return builder.build(vtk.VTK_PYRAMID)
|
|
160
|
+
|
|
161
|
+
raise NotImplementedError(f"Unsupported volume element type {element_type}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _positively_oriented(cells: np.ndarray, cell_type: int, xyz: np.ndarray) -> np.ndarray:
|
|
165
|
+
"""Swap the first two vertices of every simplex whose physical signed
|
|
166
|
+
volume (tets) or z-normal area (planar triangles) is negative, so every
|
|
167
|
+
cell follows VTK's right-hand orientation -- the lattice refinement and
|
|
168
|
+
the element maps don't preserve orientation on their own, and filters
|
|
169
|
+
that integrate signed measures (e.g. vtkIntegrateAttributes) or compute
|
|
170
|
+
normals rely on it."""
|
|
171
|
+
if cell_type == vtk.VTK_TETRA:
|
|
172
|
+
p = xyz[cells]
|
|
173
|
+
signed = np.einsum("ij,ij->i", np.cross(p[:, 1] - p[:, 0], p[:, 2] - p[:, 0]), p[:, 3] - p[:, 0])
|
|
174
|
+
elif cell_type == vtk.VTK_TRIANGLE:
|
|
175
|
+
p = xyz[cells]
|
|
176
|
+
signed = np.cross(p[:, 1] - p[:, 0], p[:, 2] - p[:, 0])[:, 2]
|
|
177
|
+
else:
|
|
178
|
+
return cells
|
|
179
|
+
flipped = cells.copy()
|
|
180
|
+
negative = signed < 0
|
|
181
|
+
flipped[negative, 0], flipped[negative, 1] = cells[negative, 1], cells[negative, 0]
|
|
182
|
+
return flipped
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _as_2d(values: Any, n_points: int) -> np.ndarray:
|
|
186
|
+
return np.asarray(values).reshape(n_points, -1)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _pad_planar(array: np.ndarray) -> np.ndarray:
|
|
190
|
+
if array.ndim == 2 and array.shape[1] == 2:
|
|
191
|
+
return np.column_stack([array, np.zeros(len(array), dtype=array.dtype)])
|
|
192
|
+
return array
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _squeeze_scalar(array: np.ndarray) -> np.ndarray:
|
|
196
|
+
return array[:, 0] if array.ndim == 2 and array.shape[1] == 1 else array
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _add_point_array(grid: "vtk.vtkDataSet", name: str, values: np.ndarray) -> None:
|
|
200
|
+
array = numpy_to_vtk(np.ascontiguousarray(values, dtype=float), deep=True)
|
|
201
|
+
array.SetName(name)
|
|
202
|
+
grid.GetPointData().AddArray(array)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _real_point_arrays(name: str, values: np.ndarray) -> Dict[str, np.ndarray]:
|
|
206
|
+
"""Split one evaluated field into the real arrays VTK stores for it."""
|
|
207
|
+
if np.iscomplexobj(values):
|
|
208
|
+
re_name, im_name, abs_name = complex_field_names(name)
|
|
209
|
+
return {
|
|
210
|
+
re_name: _squeeze_scalar(_pad_planar(values.real)),
|
|
211
|
+
im_name: _squeeze_scalar(_pad_planar(values.imag)),
|
|
212
|
+
abs_name: np.linalg.norm(values, axis=1),
|
|
213
|
+
}
|
|
214
|
+
return {name: _squeeze_scalar(_pad_planar(values))}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class MeshSampler:
|
|
218
|
+
"""Samples one ngsolve mesh at the refined reference lattice of its
|
|
219
|
+
elements, once: the mapped points, the physical coordinates and the VTK
|
|
220
|
+
cells are computed on construction and shared by every grid built from
|
|
221
|
+
it. evaluate() then evaluates any field at those points with a single
|
|
222
|
+
vectorized call, which is all it takes to add or refresh a field.
|
|
223
|
+
|
|
224
|
+
subdivision refines each element that many times (2**subdivision cells
|
|
225
|
+
per edge) -- see the module docstring. Pass 0 to see the true, unrefined
|
|
226
|
+
element boundaries.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self, mesh: Any, subdivision: int = 2) -> None:
|
|
230
|
+
self.mesh = mesh
|
|
231
|
+
self.subdivision = subdivision
|
|
232
|
+
|
|
233
|
+
elements = list(mesh.Elements(ngs.VOL))
|
|
234
|
+
element_types = [el.type for el in elements]
|
|
235
|
+
lattices = {et: reference_lattice(et, subdivision) for et in set(element_types)}
|
|
236
|
+
|
|
237
|
+
points_per_element = np.array([len(lattices[et].points) for et in element_types], dtype=np.int64)
|
|
238
|
+
point_offsets = np.concatenate([[0], np.cumsum(points_per_element)[:-1]]).astype(np.int64)
|
|
239
|
+
self.n_points = int(points_per_element.sum())
|
|
240
|
+
|
|
241
|
+
rules = {
|
|
242
|
+
et: ngs.IntegrationRule([tuple(p) for p in lattice.points], [0.0] * len(lattice.points))
|
|
243
|
+
for et, lattice in lattices.items()
|
|
244
|
+
}
|
|
245
|
+
self._mapped = mesh.MapToAllElements(rules, ngs.VOL)
|
|
246
|
+
|
|
247
|
+
coordinates = (ngs.x, ngs.y, ngs.z)[: mesh.dim]
|
|
248
|
+
xyz = _as_2d(ngs.CoefficientFunction(coordinates)(self._mapped), self.n_points)
|
|
249
|
+
if xyz.shape[1] < 3:
|
|
250
|
+
xyz = np.column_stack([xyz, np.zeros((self.n_points, 3 - xyz.shape[1]))])
|
|
251
|
+
|
|
252
|
+
# Cells, grouped by element type (cell order is irrelevant to VTK).
|
|
253
|
+
connectivity_parts: List[np.ndarray] = []
|
|
254
|
+
type_parts: List[np.ndarray] = []
|
|
255
|
+
size_parts: List[np.ndarray] = []
|
|
256
|
+
element_type_array = np.array([str(et) for et in element_types])
|
|
257
|
+
for et, lattice in lattices.items():
|
|
258
|
+
offsets = point_offsets[element_type_array == str(et)]
|
|
259
|
+
cells = (lattice.cells[None, :, :] + offsets[:, None, None]).reshape(-1, lattice.cells.shape[1])
|
|
260
|
+
cells = _positively_oriented(cells, lattice.cell_type, xyz)
|
|
261
|
+
connectivity_parts.append(cells.ravel())
|
|
262
|
+
size_parts.append(np.full(len(cells), cells.shape[1], dtype=np.int64))
|
|
263
|
+
type_parts.append(np.full(len(cells), lattice.cell_type, dtype=np.uint8))
|
|
264
|
+
|
|
265
|
+
connectivity = np.concatenate(connectivity_parts).astype(np.int64)
|
|
266
|
+
cell_offsets = np.concatenate([[0], np.cumsum(np.concatenate(size_parts))]).astype(np.int64)
|
|
267
|
+
|
|
268
|
+
self._cells = vtk.vtkCellArray()
|
|
269
|
+
self._cells.SetData(
|
|
270
|
+
numpy_to_vtkIdTypeArray(cell_offsets, deep=True), numpy_to_vtkIdTypeArray(connectivity, deep=True)
|
|
271
|
+
)
|
|
272
|
+
self._cell_types = numpy_to_vtk(np.concatenate(type_parts), deep=True, array_type=vtk.VTK_UNSIGNED_CHAR)
|
|
273
|
+
self._points = vtk.vtkPoints()
|
|
274
|
+
self._points.SetData(numpy_to_vtk(np.ascontiguousarray(xyz, dtype=float), deep=True))
|
|
275
|
+
|
|
276
|
+
# Region index into mesh.GetMaterials() at every sample point.
|
|
277
|
+
self.material_index = np.repeat([el.index for el in elements], points_per_element)
|
|
278
|
+
|
|
279
|
+
def new_grid(self) -> "vtk.vtkUnstructuredGrid":
|
|
280
|
+
"""A new grid with this mesh's geometry and no point data. Grids share
|
|
281
|
+
the (read-only) points and cells, so each one is cheap."""
|
|
282
|
+
grid = vtk.vtkUnstructuredGrid()
|
|
283
|
+
grid.SetPoints(self._points)
|
|
284
|
+
grid.SetCells(self._cell_types, self._cells)
|
|
285
|
+
return grid
|
|
286
|
+
|
|
287
|
+
def evaluate(self, name: str, field: Any) -> Dict[str, np.ndarray]:
|
|
288
|
+
"""Evaluate `field` (GridFunction or CoefficientFunction, real or
|
|
289
|
+
complex, scalar or vector) at every sample point, as the real point
|
|
290
|
+
arrays VTK stores for it: {name: values}, or name_re/name_im/name_abs
|
|
291
|
+
for a complex field."""
|
|
292
|
+
return _real_point_arrays(name, _as_2d(field(self._mapped), self.n_points))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def set_point_arrays(grid: "vtk.vtkDataSet", arrays: Mapping[str, np.ndarray]) -> None:
|
|
296
|
+
"""Add (or replace, by name) point-data arrays on `grid`."""
|
|
297
|
+
for name, values in arrays.items():
|
|
298
|
+
_add_point_array(grid, name, values)
|
|
299
|
+
grid.Modified()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def mesh_to_grid(
|
|
303
|
+
mesh: Any,
|
|
304
|
+
fields: Optional[Mapping[str, Any]] = None,
|
|
305
|
+
*,
|
|
306
|
+
subdivision: int = 2,
|
|
307
|
+
color_by_material: bool = False,
|
|
308
|
+
) -> "vtk.vtkUnstructuredGrid":
|
|
309
|
+
"""One-off vtkUnstructuredGrid of `mesh` carrying every field in `fields`
|
|
310
|
+
(name -> GridFunction/CoefficientFunction) as point data -- see
|
|
311
|
+
MeshSampler. color_by_material adds a MATERIAL_FIELD ("material") array:
|
|
312
|
+
each element's region index into mesh.GetMaterials()."""
|
|
313
|
+
sampler = MeshSampler(mesh, subdivision)
|
|
314
|
+
grid = sampler.new_grid()
|
|
315
|
+
if color_by_material and mesh.GetMaterials():
|
|
316
|
+
set_point_arrays(grid, {MATERIAL_FIELD: sampler.material_index})
|
|
317
|
+
for name, field in (fields or {}).items():
|
|
318
|
+
set_point_arrays(grid, sampler.evaluate(name, field))
|
|
319
|
+
return grid
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def field_names(grid: "vtk.vtkDataSet") -> List[str]:
|
|
323
|
+
"""Every point-data array name in `grid`, in storage order."""
|
|
324
|
+
point_data = grid.GetPointData()
|
|
325
|
+
return [point_data.GetArrayName(i) for i in range(point_data.GetNumberOfArrays())]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def pad_planar_point_arrays(grid: "vtk.vtkDataSet") -> None:
|
|
329
|
+
"""Pad every 2-component point-data array of `grid` to 3 components
|
|
330
|
+
(z = 0), in place. For grids read from a .vtu some other writer produced
|
|
331
|
+
-- ngsolve.VTKOutput writes a 2D vector field with 2 components, which
|
|
332
|
+
vtkWarpVector would misread."""
|
|
333
|
+
point_data = grid.GetPointData()
|
|
334
|
+
for name in field_names(grid):
|
|
335
|
+
array = point_data.GetArray(name)
|
|
336
|
+
if array is None or array.GetNumberOfComponents() != 2:
|
|
337
|
+
continue
|
|
338
|
+
_add_point_array(grid, name, _pad_planar(vtk_to_numpy(array))) # replaces the same-named array
|
fem_post/export.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.vtu file I/O used by the views (View.export_vtu, PostProcessor.export_vtu).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
import vtk
|
|
9
|
+
|
|
10
|
+
from fem_post.conversion import pad_planar_point_arrays
|
|
11
|
+
|
|
12
|
+
PathLike = Union[str, Path]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def vtu_path(filename: PathLike) -> Path:
|
|
16
|
+
"""`filename` with ".vtu" appended unless it already ends with it."""
|
|
17
|
+
path = Path(filename)
|
|
18
|
+
return path if path.suffix == ".vtu" else path.with_name(path.name + ".vtu")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_vtu(grid: "vtk.vtkUnstructuredGrid", filename: PathLike) -> str:
|
|
22
|
+
"""Write `grid` to `filename` (".vtu" appended unless already there) as a
|
|
23
|
+
binary XML file, creating the directory if missing. Returns the path."""
|
|
24
|
+
path = vtu_path(filename)
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
writer = vtk.vtkXMLUnstructuredGridWriter()
|
|
27
|
+
writer.SetFileName(str(path))
|
|
28
|
+
writer.SetInputData(grid)
|
|
29
|
+
writer.SetDataModeToBinary()
|
|
30
|
+
if writer.Write() != 1:
|
|
31
|
+
raise OSError(f"Failed to write {path}")
|
|
32
|
+
return str(path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_vtu(filename: PathLike) -> "vtk.vtkUnstructuredGrid":
|
|
36
|
+
"""Read a .vtu file, padding 2-component vector arrays to 3 components
|
|
37
|
+
(as written by ngsolve.VTKOutput for a 2D vector field)."""
|
|
38
|
+
path = Path(filename)
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
raise FileNotFoundError(path)
|
|
41
|
+
reader = vtk.vtkXMLUnstructuredGridReader()
|
|
42
|
+
reader.SetFileName(str(path))
|
|
43
|
+
reader.Update()
|
|
44
|
+
grid = reader.GetOutput()
|
|
45
|
+
pad_planar_point_arrays(grid)
|
|
46
|
+
return grid
|
fem_post/manager.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostProcessor: the entry point of fem_post.
|
|
3
|
+
|
|
4
|
+
One PostProcessor wraps one ngsolve mesh. It samples the mesh once (see
|
|
5
|
+
fem_post.conversion.MeshSampler) and creates named views on it -- the mesh
|
|
6
|
+
itself, a single field, a frequency sweep, a set of mode shapes -- that all
|
|
7
|
+
share that sampling. Views are updated and exported individually (see
|
|
8
|
+
fem_post.views); PostProcessor.export_vtu() writes several of them into one
|
|
9
|
+
file.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, Iterator, List, Optional, Sequence, TypeVar
|
|
13
|
+
|
|
14
|
+
from fem_post.conversion import MeshSampler, field_names
|
|
15
|
+
from fem_post.export import PathLike, write_vtu
|
|
16
|
+
from fem_post.results import FrequencySweepResultLike, ModalResultLike
|
|
17
|
+
from fem_post.settings import DisplaySettings
|
|
18
|
+
from fem_post.views import FieldView, FrequencySweepView, MeshView, ModalView, View
|
|
19
|
+
|
|
20
|
+
ViewT = TypeVar("ViewT", bound=View)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PostProcessor:
|
|
24
|
+
"""Creates, holds and exports the views of one mesh.
|
|
25
|
+
|
|
26
|
+
subdivision refines each element that many times (2**subdivision cells
|
|
27
|
+
per edge) when sampling geometry and fields, so curved elements and
|
|
28
|
+
higher-order fields render smoothly; 0 shows the raw elements.
|
|
29
|
+
|
|
30
|
+
Every add_*_view() takes display settings as keyword arguments (see
|
|
31
|
+
DisplaySettings: part, edges, deformed, warp_scale, value_range,
|
|
32
|
+
colorbar_title, background, azimuth, elevation) and returns the view.
|
|
33
|
+
View names are unique within a PostProcessor.
|
|
34
|
+
|
|
35
|
+
post = PostProcessor(mesh)
|
|
36
|
+
modes = post.add_modal_view("modes", modal.result, deformed=True)
|
|
37
|
+
modes.export_vtu("out/modes")
|
|
38
|
+
modes.export_pngs("out")
|
|
39
|
+
modes.update(new_modal.result).configure(warp_scale=50.0).select_step(2).export_png("out/mode3.png")
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, mesh: Any, *, subdivision: int = 2) -> None:
|
|
43
|
+
self._sampler = MeshSampler(mesh, subdivision)
|
|
44
|
+
self._views: Dict[str, View] = {}
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def mesh(self) -> Any:
|
|
48
|
+
return self._sampler.mesh
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def subdivision(self) -> int:
|
|
52
|
+
return self._sampler.subdivision
|
|
53
|
+
|
|
54
|
+
# -- view registry --------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def views(self) -> Dict[str, View]:
|
|
58
|
+
"""A copy of the name -> view mapping, in creation order."""
|
|
59
|
+
return dict(self._views)
|
|
60
|
+
|
|
61
|
+
def __getitem__(self, name: str) -> View:
|
|
62
|
+
if name not in self._views:
|
|
63
|
+
raise KeyError(f"No view named {name!r}; views: {list(self._views)}")
|
|
64
|
+
return self._views[name]
|
|
65
|
+
|
|
66
|
+
def __contains__(self, name: object) -> bool:
|
|
67
|
+
return name in self._views
|
|
68
|
+
|
|
69
|
+
def __iter__(self) -> Iterator[str]:
|
|
70
|
+
return iter(self._views)
|
|
71
|
+
|
|
72
|
+
def __len__(self) -> int:
|
|
73
|
+
return len(self._views)
|
|
74
|
+
|
|
75
|
+
def remove_view(self, name: str) -> View:
|
|
76
|
+
"""Forget view `name` and return it."""
|
|
77
|
+
view = self[name]
|
|
78
|
+
del self._views[name]
|
|
79
|
+
return view
|
|
80
|
+
|
|
81
|
+
def _register(self, view: ViewT) -> ViewT:
|
|
82
|
+
self._views[view.name] = view
|
|
83
|
+
return view
|
|
84
|
+
|
|
85
|
+
def _new_settings(self, name: str, settings: Dict[str, Any]) -> DisplaySettings:
|
|
86
|
+
if name in self._views:
|
|
87
|
+
raise ValueError(f"A view named {name!r} already exists")
|
|
88
|
+
return DisplaySettings().updated(**settings)
|
|
89
|
+
|
|
90
|
+
# -- view factories --------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def add_mesh_view(self, name: str = "mesh", *, color_by_material: bool = True, **settings: Any) -> MeshView:
|
|
93
|
+
"""The mesh geometry, colored by material region by default."""
|
|
94
|
+
return self._register(
|
|
95
|
+
MeshView(self._sampler, name, self._new_settings(name, settings), color_by_material=color_by_material)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def add_field_view(self, name: str, field: Any, **settings: Any) -> FieldView:
|
|
99
|
+
"""One field: a GridFunction, CoefficientFunction or StaticResult."""
|
|
100
|
+
return self._register(FieldView(self._sampler, name, field, self._new_settings(name, settings)))
|
|
101
|
+
|
|
102
|
+
def add_frequency_sweep_view(
|
|
103
|
+
self, name: str, result: FrequencySweepResultLike, **settings: Any
|
|
104
|
+
) -> FrequencySweepView:
|
|
105
|
+
"""One step per swept frequency; `name` is the field's name (e.g.
|
|
106
|
+
"pressure"), used in step labels "pressure_0", "pressure_1", ... (one per
|
|
107
|
+
entry of result.frequencies, in order)."""
|
|
108
|
+
return self._register(FrequencySweepView(self._sampler, name, result, self._new_settings(name, settings)))
|
|
109
|
+
|
|
110
|
+
def add_modal_view(self, name: str, result: ModalResultLike, **settings: Any) -> ModalView:
|
|
111
|
+
"""One step per mode shape, labeled "mode1", "mode2", ..."""
|
|
112
|
+
return self._register(ModalView(self._sampler, name, result, self._new_settings(name, settings)))
|
|
113
|
+
|
|
114
|
+
# -- exports ----------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def export_vtu(self, filename: PathLike, views: Optional[Sequence[str]] = None) -> str:
|
|
117
|
+
"""Write the arrays of several views (default: all) into one .vtu.
|
|
118
|
+
Raises ValueError if two views carry an array of the same name.
|
|
119
|
+
Returns the path written."""
|
|
120
|
+
names: List[str] = list(self._views) if views is None else list(views)
|
|
121
|
+
grid = self._sampler.new_grid()
|
|
122
|
+
point_data = grid.GetPointData()
|
|
123
|
+
owner: Dict[str, str] = {}
|
|
124
|
+
for name in names:
|
|
125
|
+
source = self[name].grid.GetPointData()
|
|
126
|
+
for array_name in field_names(self[name].grid):
|
|
127
|
+
if array_name in owner:
|
|
128
|
+
raise ValueError(f"Views {owner[array_name]!r} and {name!r} both have an array {array_name!r}")
|
|
129
|
+
owner[array_name] = name
|
|
130
|
+
point_data.AddArray(source.GetArray(array_name))
|
|
131
|
+
return write_vtu(grid, filename)
|
fem_post/naming.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Field-naming conventions shared by every fem_post exporter/renderer.
|
|
3
|
+
|
|
4
|
+
Kept in one place so an exported .vtu, a rendered PNG and an interactive
|
|
5
|
+
view of the same result all agree on what a field is called -- e.g. a
|
|
6
|
+
frequency sweep's "pressure_2_abs" -- without re-deriving the format
|
|
7
|
+
string in several modules.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def complex_field_names(name: str) -> Tuple[str, str, str]:
|
|
15
|
+
"""The (real, imaginary, magnitude) point-data field names a
|
|
16
|
+
complex-valued field "name" is split into."""
|
|
17
|
+
return f"{name}_re", f"{name}_im", f"{name}_abs"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def frequency_sweep_field_name(field_name: str, step_index: int) -> str:
|
|
21
|
+
"""The field name used for swept frequency number `step_index` (0-based,
|
|
22
|
+
the same index as in the sweep's frequency list) of `field_name`, e.g.
|
|
23
|
+
"pressure_0". Built from the index, not the frequency value, so code can
|
|
24
|
+
rebuild it without matching a formatted number."""
|
|
25
|
+
return f"{field_name}_{step_index}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def modal_field_name(mode_index: int) -> str:
|
|
29
|
+
"""The field name used for mode number `mode_index` (1-based), e.g.
|
|
30
|
+
"mode1". The natural frequency is not part of the name; it is in the
|
|
31
|
+
view's colorbar title and ModalView.frequencies_hz."""
|
|
32
|
+
return f"mode{mode_index}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def modal_frequency_hz(eigenvalue: float) -> float:
|
|
36
|
+
"""Natural frequency (Hz) from a modal eigenvalue (omega^2)."""
|
|
37
|
+
return math.sqrt(max(eigenvalue, 0.0)) / (2 * math.pi)
|
fem_post/render.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Off-screen rendering of a Scene to PNG -- the engine behind View.export_png().
|
|
3
|
+
|
|
4
|
+
Rendering needs a working OpenGL context but no visible display
|
|
5
|
+
(SetOffScreenRendering(1)); on headless Linux run under `xvfb-run -a`.
|
|
6
|
+
Without it VTK logs a "bad X server connection" warning and produces a
|
|
7
|
+
blank image instead of failing outright.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, List, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
import vtk
|
|
14
|
+
|
|
15
|
+
from fem_post.export import PathLike
|
|
16
|
+
from fem_post.scene import Scene
|
|
17
|
+
|
|
18
|
+
# (prepare the scene for one image, where to write it)
|
|
19
|
+
Shot = Tuple[Callable[[], None], PathLike]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_pngs(scene: Scene, shots: Sequence[Shot], *, width: int = 1200, height: int = 900) -> List[str]:
|
|
23
|
+
"""Render several images of one scene, reusing one off-screen window:
|
|
24
|
+
for each shot, call its prepare function (which calls scene.show() and
|
|
25
|
+
scene.fit_camera()) and write the window to its PNG path. Returns the
|
|
26
|
+
paths written."""
|
|
27
|
+
render_window = vtk.vtkRenderWindow()
|
|
28
|
+
render_window.SetOffScreenRendering(1)
|
|
29
|
+
scene.attach(render_window)
|
|
30
|
+
render_window.SetSize(width, height)
|
|
31
|
+
|
|
32
|
+
written = []
|
|
33
|
+
for prepare, output_png in shots:
|
|
34
|
+
prepare()
|
|
35
|
+
render_window.Render()
|
|
36
|
+
|
|
37
|
+
to_image = vtk.vtkWindowToImageFilter()
|
|
38
|
+
to_image.SetInput(render_window)
|
|
39
|
+
to_image.ReadFrontBufferOff()
|
|
40
|
+
to_image.Update()
|
|
41
|
+
|
|
42
|
+
path = Path(output_png)
|
|
43
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
writer = vtk.vtkPNGWriter()
|
|
45
|
+
writer.SetFileName(str(path))
|
|
46
|
+
writer.SetInputConnection(to_image.GetOutputPort())
|
|
47
|
+
writer.Write()
|
|
48
|
+
written.append(str(path))
|
|
49
|
+
render_window.Finalize()
|
|
50
|
+
return written
|
fem_post/results.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structural (duck-typed) views of the solver results fem_post post-processes.
|
|
3
|
+
|
|
4
|
+
fem_post deliberately does not import fem_core: these Protocols mirror the
|
|
5
|
+
attributes of fem_core.models.study_result's StaticResult, ModalResult and
|
|
6
|
+
FrequencySweepResult, so any object with the same shape -- fem-core's own
|
|
7
|
+
results, or a hand-built stand-in -- is accepted.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Protocol, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StaticResultLike(Protocol):
|
|
14
|
+
"""One solved field: a StaticResult, or one point of a frequency sweep."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def gridfunction(self) -> Any: ... # ngsolve.GridFunction
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ModalResultLike(Protocol):
|
|
21
|
+
"""Eigenvalues (omega^2, ascending) and their mode-shape GridFunctions."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def eigenvalues(self) -> Sequence[float]: ...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def eigenvectors(self) -> Sequence[Any]: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FrequencySweepResultLike(Protocol):
|
|
31
|
+
"""One StaticResultLike per swept frequency (Hz)."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def frequencies(self) -> Sequence[float]: ...
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def results(self) -> Sequence[StaticResultLike]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_complex(gridfunction: Any) -> bool:
|
|
41
|
+
"""True if `gridfunction` (or CoefficientFunction) is complex-valued."""
|
|
42
|
+
return bool(getattr(gridfunction, "is_complex", False))
|