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 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))