# Results

A Result is the typed outcome of one solve: its status, the forces and peak values the solver backend, Elmer today, exported, and the solve frame they are expressed in. Quantities carry their units as pint quantities. The driver writes each result to `result.json` in the run bundle, so you can read it back later by run id without the solver. The field output stays beside it as a VTU file, which Run opens as a numpy query.

## Load a result

```python
from pathlib import Path

import femforge as ff

result = ff.Result.load(Path("runs") / "calm-avocet-3f9a1c07")

print(result.status, result.frame.label)
print(result.component_forces["mover"])
print(result.axial_force("mover", ff.Axis.Z))
```

Result.load takes the bundle directory or the `result.json` file and stamps `run_dir` with where it read from. Driver.evaluate returns the same object for a fresh solve.

## Fields of a result

| Field | Unit | Holds |
| --- | --- | --- |
| `status` |  | `ok`, `diverged` or `error`. |
| `run_id` |  | The run's identity, equal to the bundle directory name; None for a bare parse. |
| `run_dir` |  | The bundle directory; never serialized, and None once retention removed the directory. |
| `frame` |  | The run's solve frame. |
| `force` | N | Net force, from Elmer's global nodal force sum. |
| `torque` | N·m | Net torque, from Elmer's global nodal torque sum. |
| `component_forces` | N | Lumped magnetic force per sif Component name. |
| `component_torques` | N·m | Lumped magnetic torque per sif Component name. |
| `flux_density_max` | T | Peak flux density magnitude. |
| `temperature_max` | K | Peak temperature, from a thermal or joule-heating solve. |
| `residual` |  | The last relative change the solver reported. |
| `wall_time` | s | Solver wall-clock time. |
| `elmer_version` |  | The ElmerSolver version from the log. |
| `raw` | as printed | Every exported scalar column by name, including the ones `Result` does not type. |

A field stays None when the solve exported nothing for it, for example `force` on a thermal run.

The status comes from `elmersolver.log`: `error` when a line contains `ERROR` or the log never reaches `ALL DONE`, `diverged` on a non-convergence marker such as `FAILED to converge` or `NaN`, and `ok` otherwise.

## Forces

Add a ForceComponent to the tag of the bodies whose force you want. Elmer lumps the magnetic force over those bodies into one Component, named by the condition's `label` or else by the tag's name. `Result.component_forces` holds one vector per Component name.

Result.axial\_force projects a Component's force onto an axis in the authored frame and returns a pint quantity in newtons:

| Frame | `axis=None` | `axis` given |
| --- | --- | --- |
| `Solid()` | raises: a solid run has no symmetry axis | the force projected onto `axis.direction` |
| `Axisymmetric()` | the force along the symmetry axis | the axial force times the cosine to `axis`; raises when `axis` is not parallel to the symmetry axis |

Under `Axisymmetric()` the typed force and torque fields are full-revolution totals; see [Frames](<https://docs.femforge.io/user-guide/frames/#results-in-the-solve-frame>). `Run.components` lists each Component with its sif block number, force and torque.

## Scalars

The numbers come from SaveScalars, which writes `scalars.dat` and its `scalars.dat.names` sidecar. parse\_result reads the last row of every `.dat` file in the bundle, because a sweep or a transient solve appends one row per step and the last is the converged state.

A partitioned solve writes one `.dat.<rank>` file per rank instead. Elmer reduces the exported values globally, so every rank writes the same row, and femforge reads rank 0 alone, never a sum. When the ranks disagree, it logs a warning and still uses rank 0: a SaveScalars operator without a global reduction is partition-local.

The solver log supplies the rest: the residual is the last relative change on a `RELC` line, and the wall time is the real time on the `SOLVER TOTAL TIME(CPU,REAL)` line.

For a conduction solve, `Run.electrode_currents` gives the terminal current per named electrode boundary and `Run.phase_currents` the current per phase.

## Field output

result\_vtu and `Run.vtu_path` return the run's ParaView output: the `.pvtu` master when the solve was partitioned, else the newest `.vtu`. Use them instead of a `*.vtu` glob, which also matches a partitioned run's per-rank pieces. Open the file in ParaView, or read it with pyvista. Its `GeometryIds` cell array holds each cell's mesh id, and `Run.bodies` maps those ids to body names.

`Run.mesh()` reads the VTU into a FieldMesh with the body names attached. `get()` starts a FieldQuery over one array; filters return refined copies, and terminals return numpy values:

```python
import femforge as ff

run = ff.Run("runs/calm-avocet-3f9a1c07")
flux = run.mesh().get(ff.elmer.ElmerVariable.MAGNETIC_FLUX_DENSITY)

magnets = flux.where_material("N52").magnitude()
print(len(magnets), magnets.max(), magnets.mean())

gap = flux.where_tag(ff.TagKey.NAME, "gap").where_pos(z=(None, 0.0))
print(gap.integrate(), gap.total_measure())
```

| Filter | Keeps |
| --- | --- |
| `where_body(*bodies)` | The named bodies' own cells. |
| `where_material(tag)` | The cells of every body with this material tag. |
| `where_tag(key, value)` | The cells of every body tagged `key=value`. |
| `where_ids(ids)` | Cells by raw mesh id, boundary cells included. |
| `where_pos(x, y, z)` | Cells whose center lies inside per-axis `(min, max)` bounds; None leaves a side open. |
| `where_mask(mask)` | Cells an arbitrary boolean mask flags. |
| `magnitude()` | The same cells, as the vector norm or the absolute value. |

| Terminal | Returns |
| --- | --- |
| `values`, `measures`, `cell_indices` | The selected cells' values, volumes (areas in 2D) and grid indices. |
| `sum()`, `min()`, `max()` | Plain reductions, per component for a vector. |
| `mean()` | The measure-weighted mean. |
| `integrate()` | ∫ value dV, or the plane-slice ∫ value dA in 2D. |
| `integrate_revolved()` | ∫ value 2πr dA, the full-revolution integral of an axisymmetric run. |
| `total_measure()` | The selection's volume, or area in 2D. |
| `cells()` | The selected cells as a pyvista grid, for plotting. |

Array names resolve loosely: the exact name first, then the elemental `"<name> e"` array, then any name that contains it. When retention purged the VTU, `Run.mesh()` raises and names the cleanup pass that removed it.

## Visualization

The viz helpers need the `viz` extra (`pip install "femforge[viz]"`), which brings pyvista, VTK, matplotlib, meshio and magpylib. They import pyvista only when they draw, so the models stay importable without it.

```python
import femforge as ff

run = ff.Run("runs/calm-avocet-3f9a1c07")
vtu = run.vtu_path
if vtu is not None:
    ff.viz.render_field_lines(vtu, ff.FieldLineOptions(line_count=8), off_screen=True, screenshot="field-lines.png")
```

| Helper | Draws |
| --- | --- |
| render\_field\_lines | The flux-density field lines of a VTU, in a window or headless to a PNG. |
| FieldLines | The same lines as a renderable that integrates once and draws on demand. |
| Scene | Several renderables in one plotter refresh, later items on top. |
| FieldScene | One run's solved bodies as a recolorable field display, driven by a `DisplaySpec`. |

## Validation against magpylib

The validation package cross-checks a magnetostatic solve against magpylib, which computes the field of uniformly magnetized cuboids analytically. It needs the `viz` extra. The comparison is a sanity bound, so expect some disagreement. It catches a field wrong by an order of magnitude or pointing the wrong way.

| Step | Function |
| --- | --- |
| Describe the magnets | sources\_from\_assembly reads each magnet's box and its magnetization from the same labels and domain the mesher uses. |
| Sample the solve | extract\_vtu\_b or sample\_vtu\_b reads B from the VTU. |
| Sample magpylib | sample\_magpylib\_b evaluates B at the same points. |
| Compare | compare\_fields returns a FieldComparison; `rel_rms_error` is the headline metric. |
| Draw | MagpylibField draws the analytical field lines, and it fits in the same `Scene` as `FieldLines`. |

## Display state

DisplaySpec is the complete, serializable state of one field display: the array, how it reduces (value, a component, the magnitude), the colormap and its limits, hidden bodies, per-body styles and opacity. FieldLineOptions is the same for field lines: seeding, line count, step lengths, colors. Both are plain pydantic models, so they round-trip through JSON, and `DisplaySpec` has a published schema on the [JSON schemas](<https://docs.femforge.io/reference/schemas/index.md>) page. The workbench field viewer starts from a `DisplaySpec` you pass it.
