Skip to main content

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 own pyproject.toml, sources and tests, no fem_core import), 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 a MeshView, 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) or view.select_step("mode2") sets the step that export_png() and show() use. view.steps lists the step labels and view.step gives 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. .vtu is 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>.png for every step.
  • view.show() opens an interactive window. n/Right and p/Left flip between steps.
  • view.point_array(step=None) returns the shown values as a numpy array, and view.grid is the underlying vtkUnstructuredGrid for 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.flux_fields) in the field-extractor shape fields= takes, so a fem-core stress plot is post.add_field_view("stress", result, fields=flux_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.19

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distributions (wheels)

Table of built distributions (wheels) for fem-post 0.1.19
File Interpreter ABI Platform
fem_post-0.1.19-cp312-cp312-win_amd64.whl CPython 3.12 CPython 3.12 Windows x86-64 Details
fem_post-0.1.19-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.19-cp312-cp312-win_amd64.whl

Download URL fem_post-0.1.19-cp312-cp312-win_amd64.whl
Size 760.7 kB
Tags CPython 3.12 Windows x86-64
SHA-256 checksum
How to use checksums
4b4f38b11f10bc68e6a6a3ae66453fb7c5d95129d43f23c6e44615474d45386f
BLAKE2b-256 checksum
How to use checksums
95e20a7ff661842a5fc7a745a63481c41b24c308ae60aea6e92592570b4bafef
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 16, 2026.

Transparency log

Release files / fem_post-0.1.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl

Download URL fem_post-0.1.19-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
03933078bfff2cff85bcc0e90f20ca1fb4191abf3f87de6bd47bb87fcb1adb4f
BLAKE2b-256 checksum
How to use checksums
6181e6d17f4a0079125a3bf04b9a1b46c27b346f0b2555f9163b2c906d6fb5bc
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 16, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.19 This release

2 release files

0.1.18

2 release files

0.1.17

2 release files

0.1.16

2 release files

0.1.15

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page