PUMA is a high performance modeling framework to simulate powder processing. It provides a scalable tool for manufacturers to simulate powder pre- and post-processing.
Reference: https://www.sciencedirect.com/science/article/pii/S0965997826001225
The tool can predict distortion, residual stress, and (for reactive processes) reaction completion fraction of complex parts after curing/debinding, sintering, and infiltration processes. These predictions are key metrics industry uses to optimize these processes to produce dense, defect-free, stable components.
PUMA is structured into two major levels: the governing physics and the material constitutive descriptions with an initial condition generated via a random field.
The governing physics are represented by four primary partial differential equations: conservation of mass through Darcy flow in porous media with an
The material constitutive response is handled through the NEML2 constitutive modeling library, designed for complex material behavior and efficient scaling on hybrid computing architectures. The integration of MOOSE and NEML2 in PUMA supports simulation of various powder-based post-processing techniques, including sintering, curing, pyrolysis, infiltration, and solidification.
Each submodule solves a different set of equations, but the unknowns and state variables are kept consistent, e.g., displacements, temperature, volume fractions, and pore pressure, from which derived constitutive quantities (e.g., stress, composition, capillary pressure, permeability) are computed.
Displacement and temperature fields, along with their derived quantities, are transferred directly between submodules and serve as initial conditions for subsequent steps. Derived constitutive quantities are likewise transferred through the state variables. For example, in the transition from curing to pyrolysis, the cured phenolic resin becomes the binder phase in the pyrolysis model.
The transfer of fluid volume fraction depends on the process sequence. In the case of reactive infiltration followed by solidification, the infiltrating liquid phase (e.g., Si) is directly transferred as the fluid variable; subsequent reaction and solidification models convert this fluid into either product (e.g., SiC) or solidified liquid phases.
Refer to examples folder for usage demonstrations and sample input.
PUMA can seed simulations with a random porosity field generated from CT-scan
imagery — see Random porosity initial conditions (CoBRA)
and the examples/random_field_cobra/ example.
- Python environment
Use a single conda environment that carries both the MOOSE build toolchain and NEML2's Python dependencies. NEML2 supplies its own runtime/build stack (torch, pyzag 2.0.0, nmhit), so there is no separate pinned list to maintain:
conda create -n puma python=3.13 \
mpich gcc_linux-64 gxx_linux-64 gfortran_linux-64 \
cmake make ninja hdf5 netcdf4 zlib libaec bison flex m4 pkg-config
conda activate puma
pip install torch nmhit pyzag==2.0.0 scikit-build-core ninjatorch and nmhit are resolved by CMake at NEML2 configure time, so they must be installed first. neml2 itself is built from the neml2/ submodule and pip-installed --no-deps by the MOOSE build step below, keeping the C++ library and Python neml2 in lockstep.
For the CT-based random-field workflow, also install CoBRA (pip only — it is not vendored as a submodule):
pip install git+https://github.com/skounouho/puma-cobra.gitThis pulls CoBRA's dependencies (including the exodusii fork it uses for its field files); see Random porosity initial conditions (CoBRA).
pyzag. The material-calibration examples use pyzag's adjoint, which needs
pyzag2.0.0 (fromapplied-material-modeling/pyzag) for itspyzag.operatorsabstraction. The drivers useneml2.pyzag.NEML2PyzagModel— construct it, then callneml2.compile(model)for JIT.
- MOOSE with NEML2
PUMA builds against NEML2 v3 (upstream main). The exact versions are pinned as git submodules:
- MOOSE:
https://github.com/hugary1995/moose.git@neml2-v3-migration→ submodulemoose/ - NEML2:
https://github.com/applied-material-modeling/neml2.git@main→ submoduleneml2/
Clone PUMA and populate the two submodules at their pinned commits:
git clone https://github.com/applied-material-modeling/puma.git
cd puma
git checkout development # the NEML2 v3 development line
git submodule update --init # pulls moose/ and neml2/ (not moose's own submodules)git submodule update --init is intentionally non-recursive: it fetches only moose/ and neml2/. MOOSE's own submodules (PETSc, libMesh, WASP) are initialized by MOOSE's build scripts in the steps below, not by PUMA.
The moose/ submodule is picked up automatically by PUMA's Makefile. To build against a MOOSE checkout elsewhere, set MOOSE_DIR to its path — the submodule is then optional. The neml2/ submodule is linked into MOOSE via NEML2_SRC_DIR during the NEML2 build step below.
scripts/get_dependencies.sh wraps the submodule init and the NEML2 build (scripts/get_dependencies.sh --help for options). The steps below do the same manually and document the PETSc/libMesh/libtorch prerequisites.
Here are the resources to successfully compile MOOSE with NEML2
-
Built MOOSE from source:
https://mooseframework.inl.gov/getting_started/installation/hpc_install_moose.html -
Built NEML2 from source:
https://applied-material-modeling.github.io/neml2/install.htmlandhttps://applied-material-modeling.github.io/neml2/tutorials-getting-started.html -
Linking MOOSE and NEML2:
https://mooseframework.inl.gov/getting_started/installation/install_neml2.htmlandhttps://mooseframework.inl.gov/getting_started/installation/install_libtorch.html
Instructions: at least worked for Ubuntu 20.04 with the appropriate mpi and compiler packages. Check the above websites for prerequisites and dependencies.
The steps below build the full CPU stack (PETSc → libMesh → WASP → NEML2 → PUMA) on a generic Linux workstation. For GPU acceleration on an NVIDIA HPC cluster, do this first, then read Building on an NVIDIA HPC cluster (GPU) for the deltas.
Activate the puma environment from step 1 — it carries the conda toolchain (MPI, compilers, build libs) and NEML2's dependencies, so the same environment builds and runs the stack. Using the conda mpicc/mpicxx/mpif90 wrappers keeps the compilers self-consistent.
- With the environment active and the submodules populated, build MOOSE's PETSc, libMesh, and WASP from the
moose/submodule:
export CC=mpicc CXX=mpicxx FC=mpif90 F90=mpif90 F77=mpif77
export MOOSE_DIR=${PWD}/moose
export MOOSE_JOBS=12 METHODS=opt
cd moose/scripts
./update_and_rebuild_petsc.sh
./update_and_rebuild_libmesh.sh
./update_and_rebuild_wasp.sh
cd ../../- Build NEML2 for MOOSE and the Python package, then configure MOOSE against it.
NEML2_SRC_DIRpoints at theneml2/submodule (upstreamapplied-material-modeling/neml2main) and overrides MOOSE's own pinned NEML2; it is pip-installed--no-depsinto the active environment, keeping the C++ library and Pythonneml2in lockstep. The build links thetorchalready in your environment. Build NEML2 first — it prints the exact./configureline — then configure with explicit--with-neml2/--with-libtorchpaths into the conda site-packages:
export NEML2_SRC_DIR=${PWD}/neml2
cd moose
NEML2_SRC_DIR=${NEML2_SRC_DIR} ./scripts/update_and_rebuild_neml2.sh --skip-submodule-update
SP=$(python -c 'import neml2, os; print(os.path.dirname(os.path.dirname(neml2.__file__)))')
./configure --with-neml2="${SP}/neml2" --with-libtorch="${SP}/torch"
cd ..update_and_rebuild_neml2.sh prints the exact ./configure --with-neml2=… --with-libtorch=… line on success; the command above reconstructs those site-packages paths so you can paste-and-run.
Look at the last line, if it said config.status: framework/include/base/MooseConfig.h is unchanged. Then the NEML2-LIBTORCH configurations point to the correct path.
- Compile PUMA with linked MOOSE-NEML2 (from the PUMA repo root; the
moose/submodule is used automatically).
make -j 12- Compile NEML2 custom materials in PUMA
PUMA's custom materials are Python models under neml2_models/python/, with model definitions in neml2_models/models/*.i. neml2-compile turns each definition into an AOTInductor artifact under an aoti/ folder that the MOOSE input files load at runtime. The aoti/ folders are git-ignored and generated locally. The regression tests require all eight models to be compiled first (the test inputs load neml2_models/aoti/<model>/model_aoti.i, whose baked absolute paths must match this checkout):
cd neml2_models
for m in infiltration_1d infiltration_2d pyrolysis_1d pyrolysis_2d \
solidification_1d solidification_2d solid_mechanics solid_mechanics_pressure; do
neml2-compile --model model models/$m.i \
--dtype float64 --device cpu \
--output-dir aoti/$m --load python -d ":"
done
cd ..Each example under examples/ carries a *_scripts.py that compiles the models it needs with the same neml2-compile invocation. Model unit tests run via neml2_models/tests/unit/run_model_unit_tests.py.
- With the models compiled, run the test suite:
./run_testsIf all tests pass, the build is complete.
Do the full General installation on Linux (CPU) build first, then apply the deltas below to add GPU support. The MOOSE/PETSc/libMesh/WASP/NEML2/PUMA build itself is unchanged — GPU support comes from (a) a CUDA-enabled torch and (b) compiling the NEML2 material models for the cuda device.
- Load the cluster toolchain (modules). Load a compiler + MPI stack and, importantly, a CUDA toolkit — the CUDA AOTI export in step 4 needs
nvcconPATH(orCUDA_HOMEpointing at a full toolkit; a runtime-only CUDA install withoutnvccis not enough). On Cray/HPE systems the Cray wrappers (cc/CC/ftn, which wrap the loaded GNU compilers +cray-mpich) work for the MOOSE build; otherwise the condampicc/mpicxx/mpif90wrappers used in the CPU steps are fine.
module load cuda # provides nvcc (name varies by site: cuda, cudatoolkit, nvhpc, ...)
module load gcc cray-mpich # or your site's compiler + MPI (skip if using the conda toolchain)
export CUDA_HOME=${CUDA_HOME:-$(dirname "$(dirname "$(command -v nvcc)")")}- Install a CUDA-matched
torchin the build/analysis environment. NEML2's Python package (and thus PUMA) links whatevertorchis active, so this single choice makes the whole stack GPU-capable. Match the wheel's CUDA build to the cluster driver — CUDA minor-version compatibility applies, so acu126wheel runs on a CUDA 12.x driver, but a wheel newer than the driver (e.g.cu130on a 12.x driver) will reporttorch.cuda.is_available() == False:
pip install torch==2.12.1+cu126 --index-url https://download.pytorch.org/whl/cu126
python -c "import torch; print(torch.__version__, torch.cuda.is_available())" # expect TrueThen run the CPU build steps (PETSc → libMesh → WASP → NEML2 → configure → make) unchanged — they link this torch. On many clusters PETSc must be built on a compute node; submit the update_and_rebuild_*.sh steps through the scheduler if a login-node build fails.
-
Verify GPU visibility on a compute node. Grab an interactive GPU node (e.g.
salloc --gres=gpu:1 .../srun ...) and re-checktorch.cuda.is_available()there — login nodes usually have no GPU. -
Compile the NEML2 material models for the GPU (this is the step that differs from the CPU build). Pass
--device cpu cudaso both artifacts are baked; thecudaexport invokesnvcc. Use a plain hostg++/gccfor the inductor/AOTI C++ step — not the MPI or Cray compiler wrapper, which torch-inductor mis-handles:
cd neml2_models
export CC=gcc CXX=g++ # bare host compiler for AOTI (NOT mpicxx / CC)
for m in infiltration_1d infiltration_2d pyrolysis_1d pyrolysis_2d \
solidification_1d solidification_2d solid_mechanics solid_mechanics_pressure; do
neml2-compile --model model models/$m.i \
--dtype float64 --device cpu cuda \
--output-dir aoti/$m --load python -d ":"
done
cd ..If nvcc is not available on your cluster, omit cuda (use --device cpu) — the CPU artifacts are all that CPU runs need.
- Run on the GPU. Point the model at the
cudadevice: in a MOOSE input setdevice = cudain the[NEML2]block, and in theexamples/**/*_scripts.pydrivers setDEVICES = ["cpu", "cuda"](compile) and the run device accordingly. Launch on GPU nodes through the scheduler (e.g. SLURM--gres=gpu:N, one MPI rank per GPU).
The examples/ folder contains runnable demonstrations of the processes PUMA models.
Each is driven by a small Python script (*_scripts.py) that compiles the NEML2
material models it needs (via neml2-compile — see Installation Instructions)
and then launches puma-opt under MPI. With the puma environment active and PUMA
built, run one from its own directory:
cd examples/pyrolysis/1D
python pyrolysis_scripts.py # set CORES at the top of the script for parallelism| Example | Process demonstrated |
|---|---|
pyrolysis/ (1D, 2D, 3D) |
Binder pyrolysis / debinding — mass loss, gas generation, and shrinkage. |
pyrolysis/material_calibration/ |
Calibrating pyrolysis kinetics against TGA experimental data. |
solidification/ (1D, 1D_true, 2D) |
Solidification of an infiltrated liquid phase. |
lsi/ (1d_grid, 2d_grid) |
Liquid silicon infiltration (LSI) into a porous preform. |
pip_and_lsi/ (2D_grid, repeated_pyrolysis, turbine) |
Chained polymer-infiltration-pyrolysis (PIP) followed by LSI; repeated_pyrolysis runs multiple PIP cycles and turbine uses a complex meshed geometry. |
porous_flow/ (none_reactive, reactive_infiltration) |
Darcy porous flow, with and without reactive infiltration (includes material calibration). |
random_field_cobra/ |
Generate a random porosity initial condition from a CT scan (generation only) — see below. |
Several examples read a spatially-varying initial condition from a CSV (via
initial_condition_from_csv.i); that CSV can be produced with the CoBRA workflow
described next.
PUMA seeds simulations with a spatially-varying porosity initial condition,
supplied as a CSV that MOOSE reads via PropertyReadFile (read_type = 'voronoi')
and PiecewiseConstantFromCSV. That CSV can be generated from a real micro-CT scan
using CoBRA (CT-Based Random-field
Approximation), which fits a covariance kernel to CT imagery and draws new
realizations of the field with a Karhunen–Loève expansion.
CoBRA is installed with pip (it is not vendored as a submodule):
pip install git+https://github.com/skounouho/puma-cobra.gitThe workflow is:
CT scan → data/scans.npy → cobra config.yml → sampled field (.e) → initial_condition.csv → PUMA
A complete, runnable, generation-only example lives in
examples/random_field_cobra/ (it produces the CSV;
it does not run a simulation). It ships a small CT-derived field so it runs without
the raw scan:
cd examples/random_field_cobra
python generate.py --no-prepare # cobra pipeline → initial_condition.csv (+ preview PNG)The output initial_condition.csv (headerless x, y, z, phi0_poro) drops directly
into PUMA's image-based examples (examples/pip_and_lsi/2D_grid, turbine,
lsi/2d_grid), whose initial_condition_from_csv.i reads it with nprop = 4,
column_number = 3; set num_file_data to the CSV row count. See the example's
README.md for details and tuning (slice band, threshold, output grid, sample count).
