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 |
A Colormap, each sampled from matplotlib: DIVERGING (coolwarm, blue-white-red, for signed values), SEQUENTIAL (viridis), PLASMA, INFERNO, RAINBOW (turbo) or GRAYSCALE (gray); its value ("rainbow") works too. None picks sequential for von Mises, diverging otherwise |
reverse_colormap |
False |
Run the colormap from its high end to its low end (e.g. red-white-blue) |
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.
Plots: several drawings of one view
A view can carry any number of named plots of its data, each with its own display settings. A
PlotDefinition says what a plot draws, and view.add_plot(definition) attaches it:
PlotKind |
Draws | Options |
|---|---|---|
SURFACE |
the outer surface, like view.draw() |
|
REGIONS |
some regions only: materials, faces or edges | regions=RegionSelection(kind, names) |
CUT_PLANE |
the section of a 3D volume mesh by a plane | origin (None: the bounding-box centre), normal |
CLIP |
the part of the mesh on the side the plane's normal points to | origin, normal, invert |
context=True also draws the whole mesh's surface, translucent, around a regions, cut or clip
plot. Regions are chosen by kind (RegionKind.SOLID, FACE or EDGE) and mesh region names. A
kind means the same in a 3D, a 2D and a surface (shell) mesh: fem-post finds where ngsolve keeps such
regions from the mesh's dimension (MeshSampler.codim_of(kind)), e.g. a face is a boundary of a 3D
mesh but a material region of a 2D one. Names are whatever the mesh calls its regions.
from fem_post import PlotDefinition, PlotKind, RegionKind, RegionSelection
sweep = post.add_frequency_sweep_view("pressure", sweep_result)
outlet = sweep.add_plot(PlotDefinition("outlet", PlotKind.REGIONS, regions=RegionSelection(RegionKind.FACE, ("f2",))))
mid = sweep.add_plot(PlotDefinition("mid", PlotKind.CUT_PLANE, normal=(0, 0, 1), context=True))
mid.configure(part="real") # this plot's settings only
sweep.select_step(3) # every plot of the view shows the view's current step
outlet.export_png("out/outlet.png")
mid.export_pngs("out") # mid_pressure_0.png, mid_pressure_1.png, ...
mid.export_vtu("out/mid") # what the plot draws, with its arrays
A plot draws into a Scene of view.grid like a view (plot.draw(scene, step, fit_camera=...)),
opens in its own window with plot.show(), and plot.output(step) returns the drawn surface without
rendering. The colormap spans the plot's own values, not the whole mesh's: for solids and a clip,
those of their part of the volume, inside included; for faces, edges and a cut plane, those drawn.
The deformation scale is the whole view's, so every plot is warped alike.
Solids, clips and cuts are drawn from view.merged_grid(names): the view's grid with the points
that elements of one material share merged, carrying only the arrays shown. Their surface is then
the part's true surface rather than every element face inside it (a field may still jump between
materials, whose points stay apart), and a plot keeps its clip or cut filter between draws.
Faces and edges are sampled at the same reference lattice as the elements
(MeshSampler.facets), and their points take the values of the coincident element sample
points. So a face or edge shows exactly what the surface shows, including stresses and other fields
from derivatives, which ngsolve cannot evaluate on an edge.
fem-core's examples/result_plots_example.py draws every plot kind of one result, and
examples/project_plots_example.py stores plots in a project and draws them with fem-core-view.
Definitions serialize to JSON-ready dicts (to_dict()/from_dict(), also on DisplaySettings). A
PostDocument holds every plot definition of a project by study name, which is what an application
stores.
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.16
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fem_post-0.1.16-cp312-cp312-win_amd64.whl | CPython 3.12 | CPython 3.12 | Windows x86-64 | Details |
| fem_post-0.1.16-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl | CPython 3.12 | CPython 3.12 | Linux glibc 2.17+ x86-64 | Details |
Total release size: 6.4 MB
Release files / fem_post-0.1.16-cp312-cp312-win_amd64.whl
| Download URL | fem_post-0.1.16-cp312-cp312-win_amd64.whl |
|---|---|
| Size | 760.7 kB |
| Tags | CPython 3.12 Windows x86-64 |
|
SHA-256 checksum How to use checksums |
14b6b6c2a9810ce2273892b9b4355772eca2ee0574f945f76e6d8d5c8ac5aa5b
|
|
BLAKE2b-256 checksum How to use checksums |
6c1c13c208886140a8d040b75be99b44508cac9397ae2b555e05fa4df328f4ba
|
| 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 15, 2026.
Transparency logRelease files / fem_post-0.1.16-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
| Download URL | fem_post-0.1.16-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl |
|---|---|
| Size | 5.6 MB |
| Tags | CPython 3.12 Linux glibc 2.17+ x86-64 |
|
SHA-256 checksum How to use checksums |
09021b2ba7cb4f5cb98078d4bb3487a7e1147c322bc112354a12ca89dc1c33c7
|
|
BLAKE2b-256 checksum How to use checksums |
d3bd2176679cf965e13af60bb29d4994f15af50f7b34610257b47b8dec771627
|
| 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 15, 2026.
Transparency log