Metadata-Version: 2.5
Name: fem-post
Version: 0.1.10
Summary: VTK post-processing API for fem-core results: views of meshes and fields, exported to .vtu/.png or shown interactively
Author-email: Gustavo Martins <gucmartins@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: fea,fem,ngsolve,post-processing,visualization,vtk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Visualization
Requires-Python: >=3.10
Requires-Dist: ngsolve>=6.2.2404
Requires-Dist: numpy>=1.24.0
Requires-Dist: vtk>=9.3.0
Description-Content-Type: text/markdown

# 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` | `"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:

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

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