fem-post
A Python API for post-processing fem-core results with VTK.
You create views of a mesh and the fields solved on it, update them, and export them to .vtu
(for ParaView) or .png, or open them in an interactive window.
fem-post lives in the fem-core repository for now (
packages/fem-post/). It is self-contained (its ownpyproject.toml, sources and tests, nofem_coreimport), so it can move to its own repository unchanged.
Installation
pip install ./packages/fem-post # standalone
uv sync --extra viz # from the fem-core repository root (uv workspace member)
Dependencies: ngsolve, numpy, vtk. fem-post does not depend on fem-core. Solver results
are accepted by shape (see fem_post.results), so fem-core's StaticResult, ModalResult and
FrequencySweepResult work directly, and so does any object with the same attributes.
Usage
from fem_post import PostProcessor
post = PostProcessor(mesh) # samples the mesh once; every view shares it
mesh_view = post.add_mesh_view("mesh") # colored by material region
mesh_view.export_png("out/mesh.png")
pressure = post.add_field_view("pressure", static.result) # GridFunction, CoefficientFunction or StaticResult
pressure.export_vtu("out/pressure") # out/pressure.vtu
pressure.export_png("out/pressure.png")
modes = post.add_modal_view("modes", modal.result, deformed=True)
modes.export_vtu("out/modes") # every mode in one file
modes.export_pngs("out") # out/mode1.png, out/mode2.png, ...
post.add_frequency_sweep_view("pressure_sweep", sweep.result)
post.export_vtu("out/everything") # several views' fields in one .vtu
Views
| Factory | View | Steps |
|---|---|---|
add_mesh_view(name="mesh", color_by_material=True) |
MeshView |
one: the geometry |
add_field_view(name, field) |
FieldView |
one: the field |
add_frequency_sweep_view(name, result) |
FrequencySweepView |
one per frequency: pressure_0, pressure_1, … |
add_modal_view(name, result) |
ModalView |
one per mode: mode1, mode2, … |
View names are unique within a PostProcessor: post["modes"], "modes" in post,
post.views, post.remove_view("modes").
Step labels are built from indices, never from frequency values, so code can rebuild them
exactly. A sweep view named pressure has steps pressure_0, pressure_1, … in the same order
as result.frequencies (0-based, like select_step(i)). A modal view has steps mode1, mode2,
… (1-based, like mode numbers). A step's label is also its .vtu array name and its PNG file name.
The frequencies are in FrequencySweepView.frequencies, ModalView.frequencies_hz and the
colorbar titles (e.g. "Mode 1 (41.93 Hz)").
Updating a view
Every view can be changed after it is created, and every change method returns the view, so calls chain:
- Field data.
view.update(new_field_or_result)re-evaluates the view with new data (e.g. after a re-solve) and keeps its settings. On aMeshView,set_color_by_material(bool)plays this role. - Display settings.
view.configure(**settings)changes how the view is drawn. See the settings below. - Current step.
view.select_step(i)orview.select_step("mode2")sets the step thatexport_png()andshow()use.view.stepslists the step labels andview.stepgives the current index.
modes.update(new_modal.result).configure(part="real", warp_scale=50.0).select_step(2).export_png("out/mode3.png")
Display settings
Pass these to any add_*_view() call or to configure(). They are stored as an immutable
DisplaySettings, available as view.settings.
| Setting | Default | Meaning |
|---|---|---|
part |
"abs" |
For a complex field: "abs" (the pointwise norm), "real" or "imag" |
edges |
True |
Draw element edges |
deformed |
False |
Warp the geometry by the shown vector field |
warp_scale |
None |
Fixed deformation factor. None auto-scales the largest displacement to 10% of the geometry's size, and the scale used is printed on the image |
value_range |
None |
Colormap (min, max). None uses the data range |
colorbar_title |
None |
Override the default title, e.g. Mode 1 (41.93 Hz) |
background |
white | RGB tuple; the bottom color when background_top is set |
background_top |
None |
RGB top color of a vertical gradient. Text (colorbar, deformation scale) is drawn dark on a light background and light on a dark one |
entry |
None |
Which field-extractor entry is shown (see Shells and plates). None shows the first; view.entries lists them |
component |
None |
What a vector or tensor is colored by: "x", "y", "z"; "xx", "xy", …; "von_mises", "max_principal", "min_principal". None is the magnitude of a vector and the von Mises value of a tensor |
colormap |
None |
"diverging" (blue-white-red) or "sequential" (viridis). None picks sequential for von Mises, diverging otherwise |
azimuth, elevation |
None |
Camera rotation in degrees. None gives 30°/20° for 3D, a head-on view for a flat 2D mesh, and a z-up oblique view for a flat mesh warped out of its plane (a deflected plate) |
Surfaces are shaded with smooth normals, split at sharp edges (folds, box corners), and lit on both sides by a key/fill/back light kit.
Shells and plates
A shell model is a surface mesh: surface elements in 3D, with no volume elements. fem-post
detects one (is_surface_mesh(mesh)) and samples its surface elements instead of volume elements.
MeshView colors it by face (mesh.GetBoundaries()), and its cells follow ngsolve's surface normal,
so VTK's normals point the same way as a shell's pressure.
Shell and plate solutions bundle several fields in one GridFunction (a product space), so they have no
single value to color by. Pass a field extractor as fields= to split each solution into named
arrays:
from fem_post import PostProcessor, plate_fields, shell_fields
post = PostProcessor(shell_mesh, subdivision=3) # 3-4 keeps a coarse curved shell smooth
roof = post.add_field_view("roof", static.result, fields=shell_fields, deformed=True)
modes = post.add_modal_view("modes", modal.result, fields=shell_fields, deformed=True)
plate = PostProcessor(plate_mesh).add_field_view("w", static.result, fields=plate_fields, deformed=True)
| Extractor | Solution components | Arrays (step s) |
Colored by | Warped by |
|---|---|---|---|---|
shell_fields |
ux, uy, uz, rx, ry, rz |
s (displacement), s_rotation |
displacement magnitude | displacement |
plate_fields |
w, theta |
s (deflection), s_displacement = (0, 0, w), s_rotation |
deflection | displacement, out of the plate's plane |
Every extractor entry is stored, so ParaView can show the rotation from the .vtu too. The first
entry is stored under the step label and colored by, every other entry as <label>_<entry>, and
deformed=True warps by the entry named displacement (for a complex field, by its real part).
An extractor is any callable gridfunction -> {name: field}, so you can write your own. The two
built-in ones select components by position, so fem-post still does not import fem-core.
Exporting and viewing
view.export_vtu(path)writes the mesh and every step's arrays to one file..vtuis appended if missing.post.export_vtu(path, views=None)writes the arrays of several views (default: all) to one file.view.export_png(path, step=None, width=1200, height=900)renders one step off-screen.view.export_pngs(directory)writes<step label>.pngfor every step.view.show()opens an interactive window.n/Right andp/Left flip between steps.view.point_array(step=None)returns the shown values as a numpy array, andview.gridis the underlyingvtkUnstructuredGridfor custom VTK work.
PNG export needs an OpenGL context but no display. On headless Linux, run under xvfb-run -a.
show() needs a real display. Do not run it under xvfb-run, which gives it an invisible display.
Embedding in your own window
show() opens a standalone window. To draw a view in a window you own, such
as a Qt-embedded QVTKRenderWindowInteractor, build a Scene from the view's
grid, attach it to your render window and draw a step:
from fem_post.scene import Scene
view = post.add_mesh_view(edges=True)
scene = Scene(view.grid)
scene.attach(render_window) # adds the surface and colorbar renderers
view.draw(scene) # current step; or view.draw(scene, "mode2")
render_window.Render()
view.draw(scene, 1, fit_camera=False) # switch step, keep the camera
scene.detach(render_window) # before attaching a different scene
A Scene is bound to one grid: after update() or set_color_by_material()
replace view.grid, build a new Scene.
Tensor fields and stress
A matrix-valued field (dims == (d, d), e.g. a stress CoefficientFunction) is stored as a
9-component tensor array, row-major with components named xx, xy, … (ParaView reads it as a
tensor). A 2D tensor is padded to 3 × 3 with zeros. Next to it fem-post stores
<name>_von_mises, <name>_max_principal and <name>_min_principal, computed from the symmetric
part. A tensor view is colored by von Mises on a sequential colormap by default. Use component= to
choose another quantity:
stress = post.add_field_view("stress", sigma, smooth=True) # sigma: a d x d CoefficientFunction
stress.export_png("out/von_mises.png")
stress.configure(component="xx").export_png("out/sigma_xx.png") # diverging colormap
stress.configure(component="max_principal", colormap="sequential").export_png("out/sigma_1.png")
A complex tensor T = A + iB (a harmonic response) describes the stress
Re(T e^{iφ}) = A cos φ − B sin φ over one cycle. With part="real" or part="imag" the derived
values are those of A or B. With part="abs" (the default) they are the peak over the
cycle:
- von Mises exactly, as the largest eigenvalue of a 2 × 2 quadratic form;
- the largest principal value from 36 phase samples, refined by a parabola;
- the smallest principal value is minus that peak, since
σ(φ + π) = −σ(φ).
The _abs tensor itself holds each component's modulus.
fem-core computes the stress of its structural models (fem_core.services.derived_fields.stress_fields)
in the field-extractor shape fields= takes, so a fem-core stress plot is
post.add_field_view("stress", result, fields=stress_fields(model), smooth=True).
Smoothing. A stress comes from derivatives of the solution, so it jumps from element to
element, and fem-post's per-element sampling shows those jumps as a patchwork. Pass smooth=True to
add_field_view, add_modal_view or add_frequency_sweep_view to average every field over the
sample points that coincide across elements. Averaging happens only within one material region
(one face, for a shell), so real jumps at material interfaces and shell folds are kept. A tensor is
averaged before its von Mises and principal values are computed. Smoothing is off by default, so the
raw element values stay visible unless you ask for it.
How fields are sampled
Each volume element (each surface element of a surface mesh) is refined subdivision times on its reference element (2^subdivision cells
per edge, default PostProcessor(mesh, subdivision=2)). The refined points are mapped through the
element transformation with ngsolve.Mesh.MapToAllElements, and each field is evaluated there in
one vectorized call. As a result:
- curved elements (
curvature_order > 1) render with their true curved geometry; - higher-order fields render smoothly rather than piecewise-linear on coarse elements;
- discontinuous fields (material index, L2 spaces) stay sharp, because points are per element.
The geometry is sampled once per PostProcessor and shared by all its views, so adding or updating
a view only evaluates its fields. Supported element types are TRIG and QUAD (2D, and surface meshes in
3D) and TET, HEX and PRISM (3D). PYRAMID is supported but always unrefined.
A complex field name is stored as name_re, name_im and name_abs, and a 2D vector field is
padded to 3 components.
The lower-level MeshSampler and mesh_to_grid() build grids directly, for custom VTK pipelines.
Development
xvfb-run -a uv run pytest packages/fem-post/tests # from the fem-core repository root
Metadata
Release files for fem-post 0.1.10
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fem_post-0.1.10.tar.gz | 43.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fem_post-0.1.10-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 83.2 kB
Release files / fem_post-0.1.10.tar.gz
| Download URL | fem_post-0.1.10.tar.gz |
|---|---|
| Size | 43.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c5f9538694e784930b032a24fbb581342d8f561f5b42b812e2e595a74550e78c
|
|
BLAKE2b-256 checksum How to use checksums |
f23405b57e9ff36dca908196c622e6840d4fc7d4e3399ba1a65e023f6c583a17
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.
Transparency logRelease files / fem_post-0.1.10-py3-none-any.whl
| Download URL | fem_post-0.1.10-py3-none-any.whl |
|---|---|
| Size | 39.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
50fedabd1d13ca4a604be437d3b004b1e92612fa0e19d9a71dcbc59ba4e67a07
|
|
BLAKE2b-256 checksum How to use checksums |
06947ea6fc74282ba08a8fe646841693567c1a78dba8d70937f4eb1937734914
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.
Transparency log