Common pitfalls#

NEPER is bring-your-own#

VoronoiMeshBuilder and CrystalGenerator resolve a user-installed NEPER via graintrace/neper_env.py. Precedence: explicit neper_path=/env= → NEPER env var → graintrace_tools.json "neper" key → neper on PATH. If none resolve, the builder raises a clear error. On Linux, auto_install=True performs a ~/.local source build.

CrystalGenerator seed is random by default#

CrystalGenerator defaults to seed=None, which draws a fresh random seed on every instantiation (printed to stdout), so each unseeded run is a different microstructure. Pass an explicit seed=<int> for a reproducible tessellation; the printed value reproduces a given run. (NEPER honors -id: different seed gives different grains, the same seed is byte-identical. The FF reconstruction path VoronoiMeshBuilder centroid/centroidsize is data-seeded via -morphooptiini, so its seed is inert — grains come from the measured centroids.)

Derive the cpfe_base path dynamically#

Do not hardcode it:

import graintrace as _gt
from pathlib import Path
_cpfe_base = str(Path(_gt.__file__).parent / "cpfe_base")

FF output orientations are always in degrees#

build_voronoi() always writes orientations.dat in degrees, regardless of input units. When feeding to VoxelMeshBuilder afterward, set angle_type="degrees".

orientation_tolerance units must match ori_units#

When ori_units="radians", convert orientation_tolerance before passing it to RegionBaseStitching:

if ori_units == "radians":
    orientation_tolerance = np.deg2rad(orientation_tolerance)

grid_properties bounding box should be inset#

Inset grid_bb by 0.0001 on each face to avoid mesh-boundary issues, while using the full box for the boundary conditions (see Configuration reference).

CUDA material calibration: the model must be on the device#

TaylorModel(device="cuda") works because taylor.py moves the whole nonlinear system with nsys.to(device) before wrapping it in the pyzag factory. Moving only the factory leaves the crystal-geometry buffers on CPU, which surfaces as a silent loss=inf.

Use the GPU when one is available#

The GPU-accelerated steps are CPFE, material calibration, and pole figures. When a CUDA GPU is present, use it (device="cuda:0" for CPFE, TaylorModel(device="cuda") for calibration); CPU is much slower for these NEML2-dominated workloads.

Resuming a checkpoint validates the parameters that built it#

GraphSpatialCluster.run(resume_from_checkpoint=True) loads the stored edges and weights and skips graph building, distance filtering, sigma estimation and top-k pruning. Anything that shaped those arrays is therefore compared against the checkpoint metadata on resume, and a mismatch raises rather than returning the old graph as the answer to a new question. That covers the data itself (a sha256 of the coordinate and feature arrays), the metric, the graph mode and its connectivity, max_edge_distance, reduce_edges_topweights_k, and the weighting parameters each weight_cfg.mode actually reads: sigma and power for rbf, sigma for exp, eps for inverse and log_inv.

segmenter and the NetworKit options are deliberately not compared. They act after the graph is loaded, which is what makes reusing one checkpoint across a gamma sweep worthwhile.

One case needs care. When sigma is None the value is estimated from the edge distances, and resume skips the step that computes them, so the resolved sigma is not knowable without rebuilding the graph. Such a request is compared by its sigma_auto quantile instead, which is exact because everything feeding those distances is already compared. The consequence is that resuming a sigma-pinned checkpoint with a sigma=None request is reported as a mismatch. Pin the resolved sigma (it is recorded in <base>.meta.json as weight_sigma) if you want to reuse such a checkpoint.

Checkpoints written before a given check existed lack its metadata key; those warn instead of failing.

The multiprocessing guard#

NF reconstruction (graintrace/nf/convert.py) uses multiprocess.Pool, so scripts that call it must be under an if __name__ == "__main__": guard. The graph-clustering / REI pipeline does not require the guard, but keeping it is good practice.

Unsegmented voxels must not reach the mesh writers#

A segmented grid uses 0 for void, >= 1 for a grain and -1 for a voxel that is material but belongs to no grain. -1 is not only a flood-fill leftover: graintrace.nf.segment.remove_small_segments() reassigns a sub-threshold segment to -1 when it has no kept neighbour, so a pipeline that runs the infill before the removal, as reconstruct() does, can still hold orphans at the end.

nf.mesh.write_spn raises ValueError on such a grid rather than meshing it. Run graintrace.nf.segment.infill_nearest_neighbor() after remove_small_segments, or mask the orphans to void (0) yourself — write_spn will not do it for you, because dropping them silently removes material from the mesh. nf.mesh.write_voxel_exodus skips negative ids the same way it skips the background id, so an orphan never becomes a block of its own.

Grain orientation averages need the crystal symmetry#

A cubic orientation has 24 equivalent rotation matrices, and a reconstruction may report voxels of one grain in different variants. Averaging across variants gives a mean close to no member of the set, so graintrace.nf.metrics.average_rotations() takes a symmetry argument and folds every member onto the variant closest to the first before averaging.

The default is symmetry="1" (the plain quaternion mean), which keeps direct callers of the low-level graintrace.nf.mesh writers unchanged. The NearFieldMeshBuilder and VoxelMeshBuilder pass their own symmetry (default "432") through to every orientation export, so the builder path is symmetry-aware without any extra configuration. If you call nf.mesh.write_spn, nf.mesh.mesh_sculpt or nf.mesh.write_voxel_exodus yourself, pass the crystal point group explicitly.