Metadata-Version: 2.4
Name: fem-post
Version: 0.1.16
Summary: VTK post-processing API for fem-core results: views of meshes and fields, exported to .vtu/.png or shown interactively
Home-page: 
License-Expression: LicenseRef-Proprietary
Keywords: fem,fea,ngsolve,vtk,post-processing,visualization
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Microsoft :: Windows
Classifier: Topic :: Scientific/Engineering :: Visualization
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: matplotlib>=3.5
Requires-Dist: ngsolve>=6.2.2404
Requires-Dist: numpy>=1.24.0
Requires-Dist: vtk>=9.3.0
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: keywords
Dynamic: license-file
Dynamic: license-expression
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# fem-post

A Python API for post-processing [fem-core](https://github.com/gcmartins/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](https://www.paraview.org/)) 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

```bash
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

```python
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.

```python
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](#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:

```python
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:

```python
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.

```python
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:

```python
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

```bash
xvfb-run -a uv run pytest packages/fem-post/tests    # from the fem-core repository root
```
