Microstructure-scale hydrogen transport modelling built on FESTIM.
festim-microstructure is a companion package to FESTIM, similar to
other FESTIM add-ons such as festim-gui and
festim-niuq. festim-microstructure is
alongside festim in FESTIM in scripts that use it:
import festim as F
import festim_microstructure as fm| Area | Contents |
|---|---|
| Microstructure generation | 2D and 3D Voronoi polycrystals via Gmsh; Neper-backed tessellations |
| EBSD import | Conversion of EBSD orientation maps into conforming grain meshes |
| FESTIM subdomains and fields | Tagged grains, the boundary network as one codim-1 subdomain, per-grain surface patches, lattice tensor and per-boundary diffusivity fields |
| Post-processing | Grain-plus-network inventories and flux averages, homogenisation and short-circuit estimates, network topology checks |
| Benchmarks | Diaz-Rodriguez baseline, dimensional and non-dimensionalised |
The transport model itself is FESTIM's. Every grain is a
festim.VolumeSubdomain with a festim.Species of its own, the grain-boundary
network is one codimension-one VolumeSubdomain carrying one species, and each
grain exchanges with it through one ParticleFluxBC/ParticleSource pair --
exactly the pattern in FESTIM's manifold documentation. festim_microstructure
creates and interprets microstructures, converts them into FESTIM-compatible
subdomains and coefficient fields, and supplies specialised analysis; FESTIM
owns species, reactions, boundary conditions, settings, solving and standard
exports.
The stack is split across two package managers and two conda environments:
- conda-forge supplies DOLFINx, the Gmsh Python API, FESTIM's compiled
dependencies (
scifem,io4dolfinx), and Neper. - PyPI supplies FESTIM itself. Versions 2.1 and 2.2rc* are not yet on
conda-forge, where the newest 2.x build is
2.0b2.post2. - Neper lives in its own environment. conda-forge's
neperis built againstscotch 6.1.x, which pinszlib <1.3, whilefenics-dolfinx >=0.10needslibzlib >=1.3.2(throughlibadios2). No single solve can contain both;conda env createon a combined file fails withLibMambaUnsatisfiableErroron exactly that pair. Since Neper is only ever run as a subprocess -- nothing in the Python stack links against it -- the split costs nothing: the FEniCSx environment just needs to know the path.
Platforms: linux-64, osx-64, osx-arm64. There is no Windows build of Neper on conda-forge, and DOLFINx is easiest to obtain on Linux/macOS, so Windows users should work inside WSL2. FESTIM's own installation guide makes the same recommendation.
Install Miniforge (or Miniconda), then:
git clone https://github.com/ee-nn/FESTIM-Microstructure.git
cd FESTIM-Microstructure
# 1. the FEniCSx / FESTIM environment (this is the one you work in)
conda env create -f environment.yml
conda activate festim-microstructure
pip install -e .
# 2. Neper, in its own environment (never activated)
conda env create -f environment-neper.yml
# 3. tell environment 1 where environment 2's executables are
tools/link-neper-env.sh
conda activate festim-microstructure # re-activate so the variables load
fm-checkStep 3 records FM_NEPER_BIN, FM_GMSH_BIN and FM_POVRAY_BIN as conda
environment variables of festim-microstructure (conda env config vars), so
they are exported on activate and cleared on deactivate; nothing is written to
your shell profile. If you prefer, skip the script and export the same three
variables yourself -- or pass paths explicitly (NeperSettings(neper_bin=...)).
Resolution order is always explicit argument, then the variable, then PATH.
The Neper environment is optional. Without it, the Gmsh-based Voronoi route
(VoronoiMicrostructure.create, examples/voronoi_polycrystal_*.py), the EBSD .ctf converter,
and the homogenisation study all work; only Neper-backed tessellations and EBSD
meshing need it. The POV-Ray render checks are optional within that: on
linux-64, conda install -n neper-env conda-forge::povray and re-run step 3.
pip install -e . is required -- without it the modules under src/ are not
on the import path and none of the examples will run. For a development install
including test and lint extras:
pip install -e ".[test,lint]"For VS Code, select the festim-microstructure Conda environment with
Python: Select Interpreter. The project configures src/ as an analysis
search path for Pylance/Pyright; running the examples still requires the
editable install in the selected environment. If imports stay underlined after
changing environments, run Developer: Reload Window.
fm-checkprints the versions of the Python-side stack and where each external program resolves to, and exits non-zero if the FEniCSx side is broken. It looks like:
python-side stack
festim_microstructure 0.1.dev15
...
dolfinx 0.10.0
festim 2.2rc2
gmsh (python-gmsh) 4.13.1
external programs (explicit path > FM_*_BIN > PATH)
neper /home/you/miniforge3/envs/neper-env/bin/neper [env var] neper 5.0.0
gmsh /home/you/miniforge3/envs/neper-env/bin/gmsh [env var] 4.13.1
povray not found (FM_POVRAY_BIN unset)
For MPI, additionally:
mpirun -n 2 python -c "from mpi4py import MPI; print(MPI.COMM_WORLD.rank)"The gmsh (python-gmsh) line matters: on conda-forge the gmsh package
installs only the executable and shared library, and the Python module comes
from the separate python-gmsh package. environment.yml lists python-gmsh;
environment-neper.yml lists gmsh, because the executable is what neper -M
calls, and it should be the one Neper was built alongside. Keeping them apart
means the two Gmsh builds never mix.
Paths with whitespace are rejected (find_binary raises): Neper re-tokenizes
its arguments, so a conda prefix such as ~/my envs/neper-env is torn into
fragments by the time Neper sees it. Put the environments somewhere without a
space, or symlink them.
conda env update -f environment.yml --prune
pip install -e .
conda env update -f environment-neper.yml --prune
tools/link-neper-env.sh # only needed if an env was recreated or movedFESTIM is pinned in environment.yml, and DOLFINx is pinned to a minor range
alongside it. These two must be bumped together: FESTIM moved to DOLFINx 0.11
and replaced adios4dolfinx with io4dolfinx in
festim-dev/FESTIM#1173,
merged one day before the 2.1 release. An unpinned DOLFINx will drift out from
under a pinned FESTIM and fail at import or at assembly.
If pip attempts to build scifem from source during installation, the
conda-forge scifem build is older than the pinned FESTIM requires. Either
relax the FESTIM pin or install it without resolving dependencies:
pip install --no-deps festim==2.2rc2
pip checkFESTIM-Microstructure/
├── environment.yml # FEniCSx / FESTIM env (the one you work in)
├── environment-neper.yml # Neper + gmsh executable, kept separate (see Requirements)
├── tools/link-neper-env.sh # records the Neper paths on the FEniCSx env
├── pyproject.toml
├── README.md
├── docs/
│ └── gb_homogenisation.md # the homogenisation study, with figures
├── src/festim_microstructure/
│ ├── __init__.py # flat API; solver-dependent exports are lazy
│ ├── _lazy.py # import support for optional solver dependencies
│ ├── _binaries.py # Neper, Gmsh and POV-Ray executable discovery
│ ├── check.py # fm-check environment diagnostic
│ ├── microstructure.py # microstructure protocols, TaggedPolycrystal
│ ├── materials.py # lattice tensor field, short-circuit estimates
│ ├── plotting.py # raster colours, scale bars, image helpers
│ ├── formats/ # file I/O
│ │ ├── ctf.py
│ │ ├── tesr.py
│ │ ├── msh4.py # mesh readers and Neper StatFile
│ │ └── provenance.py
│ ├── ebsd/ # measured map -> raster tessellation
│ │ ├── orientation.py
│ │ ├── segmentation.py
│ │ ├── morphology.py
│ │ ├── settings.py
│ │ ├── convert.py
│ │ └── diagnostics.py
│ ├── voronoi/ # generated 2D/3D tessellations and Gmsh meshes
│ │ ├── geometry.py # dimension-dispatched tessellation geometry
│ │ ├── gmsh_builder.py
│ │ └── polycrystal.py
│ ├── meshing/ # tessellation -> mesh -> BoundaryNetwork
│ │ ├── neper.py # NeperMesh: generate, mesh, read, describe
│ │ ├── ebsd.py
│ │ └── diagnostics.py
│ ├── fem/
│ │ ├── solvers.py # tune_direct_solver: the MUMPS workspace workaround
│ │ └── subdomains.py # Grain, GrainBoundaryNetwork, GrainSurface + factories
│ └── exports/
│ ├── measures.py # submesh measures and network topology checks
│ └── averages.py # grain+network averages, inventory, fields, VTX output
├── examples/ # runnable workflows; not part of the library API
│ ├── gb_homogenisation.py # RVE identification, validation and figures
│ ├── li2022_fig4*.py # Li et al. (2022) reproduction scripts
│ └── ... # usage examples and data/
└── test/ # pytest; the FEniCS-dependent tests skip without it
Nothing importable sits at the repository root. The pure-NumPy parts of the package -- the EBSD converter, the orientation algebra, the Voronoi tessellation geometry, the Neper stat readers -- import and test without dolfinx or FESTIM installed; the mesh builders, subdomains and fields import them lazily.
A microstructure comes from the package; the model is declared with FESTIM. The names you are most likely to want sit on the package itself:
import dolfinx
import festim as F
import numpy as np
import festim_microstructure as fm
micro = fm.VoronoiMicrostructure.create(size=100e-6, n_seeds=64)
# The same entry point builds 3D structures with dim=3.
micro_3d = fm.VoronoiMicrostructure.create(size=100e-6, n_seeds=64, dim=3)
NETWORK_ID, SURFACE_ID_0 = 1_000_000, 2_000_000 # above every grain id
D_bulk, c0 = 1e-11, 1.0
# Coefficients: one lattice tensor per grain in a parent-mesh field, and an
# ordinary FESTIM material for the boundaries.
D_lattice, tensors = fm.materials.crystal_diffusivity_field(micro, D_bulk)
grains = fm.fem.subdomains.grain_subdomains(micro, F.Material(D=D_lattice))
network = fm.fem.subdomains.grain_boundary_network(
NETWORK_ID, micro, F.Material(D_0=2.85e-7, E_D=0.12)
)
grain_species = [F.Species(f"c_{g.id}", subdomains=[g]) for g in grains]
c_gb = F.Species("c_gb", subdomains=[network])
# Each grain exchanges k (c_grain - c_gb) with the boundary slab; the network
# equation is written per unit slab width delta, hence k / delta on its side.
k = dolfinx.fem.Constant(micro.mesh, 3.0)
width = dolfinx.fem.Constant(micro.mesh, 1e-9)
sources, bcs = [], []
for c_grain in grain_species:
exchange = {"c_g": c_grain, "c_n": c_gb}
sources.append(F.ParticleSource(
value=lambda c_g, c_n: (k / width) * (c_g - c_n), species=c_gb,
volume=network, species_dependent_value=exchange))
bcs.append(F.ParticleFluxBC(
subdomain=network, species=c_grain,
value=lambda c_g, c_n: k * (c_n - c_g), species_dependent_value=exchange))
# A charged surface: the patch each grain owns, and the network's mouths on it.
patches, mouths = fm.fem.subdomains.grain_surfaces(
micro.mesh, grains, lambda x: np.isclose(x[1], micro.size), SURFACE_ID_0
)
species_of = dict(zip((g.id for g in grains), grain_species))
bcs += [F.FixedConcentrationBC(subdomain=p, value=c0, species=species_of[p.grain_id])
for p in patches]
bcs.append(F.FixedConcentrationBC(subdomain=mouths, value=c0, species=c_gb))
model = F.HydrogenTransportProblemDiscontinuous(
mesh=F.Mesh(micro.mesh),
subdomains=[*grains, network, *patches, mouths],
species=[*grain_species, c_gb],
sources=sources,
boundary_conditions=bcs,
temperature=600.0,
settings=F.Settings(atol=1e-25, rtol=1e-10, transient=False),
)
model.initialise()
fm.fem.solvers.tune_direct_solver(model) # MUMPS workspace; see its docstring
model.run()
total = fm.exports.averages.inventory(grains, grain_species, network, c_gb, 1e-9)There is one transport model. Every grain carries a lattice field of its own and
exchanges with a single codimension-one boundary network at the rate k; the
classical Fisher picture, one continuous lattice field for the whole
polycrystal, is the limit of it in which the boundary offers no resistance to
permeation, reached when fm.materials.interface_resistance_ratio is small.
A boundary-dependent diffusivity is one value per tessellation entity,
grain_boundary_network(..., diffusivity_by_entity=...), which the network
turns into a field on its submesh when FESTIM creates it.
dir(fm) lists the curated top-level API. Geometry and network names load
eagerly with NumPy and SciPy; solver-dependent names such as fm.Grain and
fm.GrainBoundaryNetwork load FESTIM/DOLFINx on first access. This keeps
standalone EBSD conversion and fm-check usable without the solver stack.
The same exports are declared explicitly for type checkers, so completion,
constructor signatures and go-to-definition work with fm.TaggedPolycrystal,
fm.GrainBoundaryNetwork and the subpackage helpers. The installed
package includes a py.typed marker for editors using it outside this checkout.
Everything else stays reachable through the subpackages, which resolve the same way:
fm.voronoi.network_tensor
fm.ebsd.CtfConversion
fm.exports.averages.averages
fm.fem.subdomains.GrainBoundaryNetwork
fm.exports.measures.submesh_measureThe examples use a single package import, with curated names at the top level and specialised helpers under their modules:
import festim_microstructure as fm
fm.TaggedPolycrystal
fm.GrainBoundaryNetwork
fm.fem.subdomains.grain_surfaces
fm.materials.crystal_diffusivity_field
fm.voronoi.build_mesh
fm.voronoi.near
fm.voronoi.tessellateExamples are executable workflows, intentionally kept outside the installed API. Edit their setup values or copy the relevant package-level building blocks into your own script:
python examples/voronoi_polycrystal_2d.py # in-process Gmsh tessellation
python examples/voronoi_polycrystal_3d.py
python examples/neper_voronoi_network.py # needs neper + gmsh executables
python examples/ebsd_ctf_to_tesr.py # EBSD conversion + SI mesh
python examples/ebsd_gb_diffusion.py # transport using the saved SI mesh
python examples/fisher_grain_boundary.py # single boundary vs Le Claire
python examples/gb_homogenisation.py --sizes 2e-6 3e-6 4e-6
python examples/li2022_fig4.py # volumetric-band reproduction
python examples/li2022_fig4_codim.py # codim-1 reproductionGenerated files go to examples/results/<script_name>/; see
example output locations.
Generate meshes through Python functions:
from pathlib import Path
import festim_microstructure as fm
micro = fm.VoronoiMicrostructure.create(
size=100e-6, n_seeds=64, aspect=4, cells_per_grain=10, msh_path="poly2d.msh"
)
print(micro.report())
workdir = Path("results")
workdir.mkdir(parents=True, exist_ok=True)
tesr = workdir / "poly.tesr"
result = fm.ebsd.convert.convert(
"examples/data/D7 PBF SS316L.ctf",
str(tesr),
min_pixels=15,
max_mad=1.5,
allow_error=True,
crop="0,306,0,306",
diagnostics=True,
)
base = fm.meshing.ebsd.run_ebsd_pipeline(
fm.EbsdOptions(tesr=str(tesr)), workdir=workdir
)Configure EBSD meshing with fm.EbsdOptions(mesh=fm.TesrMeshOptions(...))
(TesrMeshOptions is in meshing.neper). Pass binary paths with
convert(..., neper=...) and
run_ebsd_pipeline(..., neper_bin=..., gmsh_bin=...).
The returned Voronoi mesh is in metres; the optional Gmsh file uses units of
size. The fm-check environment diagnostic remains a console command.
Neper, Gmsh and POV-Ray are found through FM_NEPER_BIN, FM_GMSH_BIN,
FM_POVRAY_BIN (set by tools/link-neper-env.sh), falling back to PATH;
fm-check shows what resolved. Every Neper subprocess runs with those
directories prepended to its PATH, so Neper finds Gmsh and POV-Ray by itself.
Parallel runs use MPI directly, as with any DOLFINx program:
mpirun -n 8 python examples/gb_homogenisation.pypytest
pytest --cov=festim_microstructure --cov-report=term-missing
ruff check src test
ruff format --check src testIf this package contributes to published work, please cite FESTIM alongside it —
see CITATION.cff in the FESTIM
repository.
Issues and pull requests are welcome. Run ruff check and pytest before
opening a PR. FESTIM's own developer
guide is a
reasonable reference for style and review conventions.
For FESTIM questions rather than microstructure questions, use the FESTIM Discourse or Slack channel.
Apache-2.0, matching FESTIM.