Three-Body Distribution (g3)

Definition

The rooted three-body distribution \(g_3\) captures pairwise distance and angular correlations between atomic triplets. For a centre atom at position \(\mathbf r_0\) with neighbours at \(\mathbf r_1\) and \(\mathbf r_2\):

  • \(r_{01} = \lVert \mathbf r_1 - \mathbf r_0 \rVert\)

  • \(r_{02} = \lVert \mathbf r_2 - \mathbf r_0 \rVert\)

  • \(\phi = \arccos\!\left(\dfrac{(\mathbf r_1 - \mathbf r_0) \cdot (\mathbf r_2 - \mathbf r_0)}{r_{01} \, r_{02}}\right)\)

G3Distribution.measure_g3 replicates the supercell up to \(r_\text{max}\) and, for every site, accumulates counts into a 4D histogram

\[\texttt{g3count}[\text{channel}, r_{01}, r_{02}, \phi]\]

over all neighbour pairs \((j, k)\) inside the radial cutoff (with \(j \ne k\) when the two are the same species). Radial bins use width \(\Delta r = \texttt{r\_step}\); angular bins use \(\Delta \phi = \pi / \texttt{phi\_num\_bins}\).

Channel indexing

Channels are labelled by species triplets (centre | neigh_1 neigh_2) with species_idx(neigh_1) species_idx(neigh_2). For a system with \(S\) species the channel count is

\[N_\text{channels} = \frac{S^2 (S + 1)}{2}.\]

Examples: SiO₂ (\(S = 2\)) → 6 channels (Si | Si Si, Si | Si O, Si | O O, O | Si Si, O | Si O, O | O O); SrTiO₃ (\(S = 3\)) → 18 channels.

g3_index stores the (centre, neigh_1, neigh_2) species indices for each channel, and g3_lookup[c, a, b] maps any ordered species triple to its channel; both orderings of the two neighbours land on the same entry so the caller doesn’t have to sort. The atom-pair \((j, k)\) and \((k, j)\) are added separately when the two neighbour species differ (symmetrising the \((r_{01}, r_{02})\) plane); when they are the same species the diagonal is masked so no atom pairs with itself.

Acceleration backend

measure_g3(backend="auto") (the default) uses the numba-parallel kernel. numba is a hard dependency of tricor (installed by pip install tricor), so the fast path is always available; the pure-numpy reference loop remains in the source as a self-check and runs when backend="python" is passed explicitly. Both backends produce bit-identical g3count and g2count (verified by tests/test_g3_numba.py).

backend

speed (40 Å SrTiO₃, 5125 atoms)

when to use

"numba"

~1.2 s (≈ 25 × faster)

default

"python"

~30 s

reference / debugging

"auto"

numba

the default; same as "numba"

The first call pays a one-time JIT compile cost (~1–2 s); subsequent calls hit the cached kernel. The kernel uses prange over origin atoms with per-thread accumulators reduced at the end — no atomic adds, no GIL.

Reduced coordinates

The random-limit (ideal gas) g3 scales as \(r_{01}^2 \, r_{02}^2 \, \sin\phi\). The reduced g3 divides this out:

\[\tilde g_3 = \frac{g_3}{A \, r_{01}^2 \, r_{02}^2 \, \sin\phi}\]

where \(A\) is a per-channel amplitude estimated from the far-field mean. In reduced coordinates \(\tilde g_3 \to 1\) in the random limit and crystalline peaks sit as sharp islands above the background. The pairwise byproduct \(g_2\) is normalised analogously with the \(r^2\) factor divided out.

Coordination shell target

CoordinationShellTarget.from_atoms(atoms) extracts first-shell structural targets from a reference crystal using ASE’s periodic neighbor_list at an adaptive cutoff (clamped between \(2\) Å and \(3.8 \times\) the median nearest-neighbour distance). The resulting frozen dataclass exposes (per species pair \((a, b)\)):

Field

Meaning

pair_peak[a, b]

Mean first-shell radius

pair_inner[a, b] / pair_outer[a, b]

Radial boundaries of the first shell (derived from the histogram minimum)

pair_hard_min[a, b]

Hard-core overlap radius (max(0, r_inner 1.5 σ_r))

pair_sigma[a, b]

First-shell width

pair_mask[a, b]

True iff any first-shell neighbours of this pair exist

coordination_target[a, b]

Mean number of \(b\)-neighbours around an \(a\)-atom

angle_mode_deg[t]

Most probable bond angle for triplet channel \(t\)

angle_target[t, :]

Full angular histogram (width phi_num_bins)

Triplet channels on the shell target use the same (centre, neigh_1 neigh_2) indexing (angle_index, angle_lookup) as the g3 object, so downstream consumers can re-use the same channel layout.

Bond restrictions

Two helpers zero entries of coordination_target to enforce species-pair selectivity during shell_relax:

shell_target.with_cross_species_bonds_only()          # zero diagonal: only A–B bonds
shell_target.with_bonded_species_pairs([('Ti', 'O')]) # keep listed pairs, zero the rest

Both return a new CoordinationShellTarget; the original is unchanged. This is the mechanism that keeps SiO₂ / SrTiO₃ from developing a spurious Si-Si or Ti-Ti bond at the second-shell distance.

Angle-spring masking (multi-modal shells)

For shells with multiple physically valid bond angles (the 12-coord cuboctahedron at 60°/90°/120°/180°, Sr in SrTiO₃’s SrO₁₂ motif, …) the single angle_mode_deg that gets extracted picks one of the peaks and forcing it strains the others. Two helpers flip entries of angle_enabled_mask to silence specific triplet types without touching their bond-distance springs:

shell_target.with_angle_triplets([('Ti','O','O'), ('O','Ti','Ti')])  # whitelist
shell_target.without_angle_triplets([('Sr','O','O')])                # blacklist

For SrTiO₃ the whitelist form keeps Ti-centered 90° (octahedral) and O | Ti Ti 180° (linear Ti-O-Ti backbone) while silencing every Sr-centered triplet, consistent with Cu FCC’s angle_weight=0 treatment of the cuboctahedron.

Composite shell target

CoordinationShellTarget.from_targets({key: target, ...}) stacks several per-chemistry targets into one with a widened species axis. Each input contributes its own virtual-species rows of coordination_target, pair_peak, angle_mode_deg, etc. Cross-target coordination defaults to zero (no bonds form across the virtual boundary), but cross-target repulsion is preserved so different phases don’t overlap. Used for the sp²/sp³ carbon ladder where the two virtual species (sp2_C, sp3_C) share atomic number 6 but carry distinct coordination (3 vs 4) and angle targets (120° vs 109.5°). Per-atom virtual species is assigned at grain-build time via Supercell.generate(..., grain_sources=[...]); see Supercell generation for the grain mechanism.