Ackland–Jones structure classification#

Sometimes you don’t want a continuous descriptor — you just want a label. This atom is FCC. That one is BCC. Those are unknown.

The Ackland–Jones classifier (Ackland & Jones, 2006) does exactly that. It looks at the histogram of cosine angles between every pair of neighbours around an atom and runs the result through a small decision tree, returning one of:

label

meaning

1

FCC

2

HCP

3

BCC

4

icosahedral

0

unknown / disordered

It is fast, deterministic and widely used in molecular dynamics post-processing.

import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from collections import Counter
import pyscal3 as pyscal
from pyscal3.structures import make_crystal

1. Perfect crystals — sanity check#

On a flawless lattice, every atom should be classified as the lattice type of the crystal.

for name in ['fcc', 'bcc', 'hcp']:
    lc = {'fcc': 4.05, 'bcc': 2.87, 'hcp': 3.21}[name]
    atoms = make_crystal(name, lattice_constant=lc, repetitions=(4, 4, 4))
    pyscal.find_neighbors(atoms, method='cutoff', cutoff=0)
    _, names = pyscal.identify_ackland_jones(atoms)
    print(f'{name.upper()}: {dict(Counter(names))}')
FCC: {'fcc': 256}
BCC: {'bcc': 128}
HCP: {'hcp': 256}

2. The chi-parameter fingerprint#

Under the hood, Ackland–Jones uses a 9-bin histogram of bond angle cosines (chi_params). Each crystal lattice has a characteristic chi vector. Plotting them side-by-side shows why the decision tree works.

chi_data = {}
for name in ['fcc', 'bcc', 'hcp']:
    lc = {'fcc': 4.05, 'bcc': 2.87, 'hcp': 3.21}[name]
    atoms = make_crystal(name, lattice_constant=lc, repetitions=(3, 3, 3))
    pyscal.find_neighbors(atoms, method='cutoff', cutoff=0)
    chi = pyscal.chi_params(atoms)
    chi_data[name.upper()] = np.asarray(chi[0])

labels = [f'\u03c7{i}' for i in range(9)]
fig = go.Figure()
for name, vec in chi_data.items():
    fig.add_trace(go.Bar(x=labels, y=vec, name=name))
fig.update_layout(barmode='group', xaxis_title='chi bin',
                  yaxis_title='count', height=380, template='simple_white',
                  title='Chi-parameter histogram for FCC, BCC, HCP')
fig

Each lattice has a clearly different bin profile — that is what the decision tree exploits.

3. Robustness to thermal noise#

Thermal vibrations smear out the angles. A useful diagnostic is to plot the fraction of atoms classified into each bucket as a function of noise amplitude.

import numpy as np
noise_levels = np.round(np.linspace(0, 0.20, 11), 3)
labels_set = ['FCC', 'HCP', 'BCC', 'ICO', 'unknown']
fractions = {l: [] for l in labels_set}

for sigma in noise_levels:
    atoms = make_crystal('fcc', lattice_constant=4.05,
                         repetitions=(5, 5, 5), noise=sigma)
    pyscal.find_neighbors(atoms, method='cutoff', cutoff=0)
    _, names = pyscal.identify_ackland_jones(atoms)
    counts = Counter(names)
    n = len(names)
    for k in labels_set:
        fractions[k].append(counts.get(k.lower() if k != 'unknown' else 'unknown', 0) / n)

fig = go.Figure()
for k in labels_set:
    if any(v > 0 for v in fractions[k]):
        fig.add_trace(go.Scatter(x=noise_levels, y=fractions[k],
                                 mode='lines+markers', name=k,
                                 stackgroup='one'))
fig.update_layout(xaxis_title='Gaussian noise amplitude (\u00c5)',
                  yaxis_title='fraction of atoms', yaxis=dict(range=[0, 1]),
                  title='Ackland\u2013Jones labels for noisy FCC',
                  height=400, template='simple_white')
fig

FCC remains the dominant label up to about 0.1 Å of noise. Beyond that, atoms start being mis-labelled as HCP (the closest stacking variant) and then as ‘unknown’.

4. A multi-phase configuration#

Mix two crystal blocks into one cell and watch the classifier separate them. A useful spot-check that the per-atom labels track the spatial boundary.

fcc = make_crystal('fcc', lattice_constant=4.05, repetitions=(4, 3, 3))
bcc = make_crystal('bcc', lattice_constant=2.87, repetitions=(4, 3, 3))

# Stack them along x by shifting bcc
shift = float(fcc.cell[0, 0])
bcc.translate([shift, 0, 0])
combined = fcc + bcc
combined.set_cell([fcc.cell[0,0] + bcc.cell[0,0],
                   fcc.cell[1,1], fcc.cell[2,2]])
combined.set_pbc(True)
pyscal.find_neighbors(combined, method='cutoff', cutoff=0)
_, lbls = pyscal.identify_ackland_jones(combined)
lbls = np.asarray(lbls)

pos = combined.get_positions()
color_map = {'fcc': '#1f77b4', 'bcc': '#d62728', 'hcp': '#2ca02c',
             'ico': '#9467bd', 'unknown': 'lightgrey'}
fig = go.Figure()
for lbl in np.unique(lbls):
    mask = lbls == lbl
    fig.add_trace(go.Scatter(x=pos[mask, 0], y=pos[mask, 1], mode='markers',
                             marker=dict(size=6, color=color_map.get(lbl, 'black')),
                             name=str(lbl)))
fig.update_layout(xaxis_title='x (\u00c5)', yaxis_title='y (\u00c5)',
                  title='FCC|BCC interface, atoms coloured by AJ label',
                  height=420, template='simple_white')
fig

Atoms cleanly separate into the FCC and BCC half. The interface plane shows up as a thin strip of “unknown” atoms — exactly what you’d expect where the local environment is ambiguous.

5. What is stored#

After the classifier runs, two arrays are attached to the atoms:

  • pyscal_ackland_label — integer label per atom

  • pyscal_structure — string name per atom

atoms = make_crystal('hcp', lattice_constant=3.21, repetitions=(3, 3, 3))
pyscal.find_neighbors(atoms, method='cutoff', cutoff=0)
pyscal.identify_ackland_jones(atoms)
print('  pyscal_ackland_label[:5] =', atoms.arrays['pyscal_ackland_label'][:5])
print('  pyscal_structure[:5]     =', atoms.arrays['pyscal_structure'][:5])
  pyscal_ackland_label[:5] = [2 2 2 2 2]
  pyscal_structure[:5]     = ['hcp' 'hcp' 'hcp' 'hcp' 'hcp']

Take-aways#

  • Ackland–Jones returns a single discrete label per atom — perfect for colouring snapshots, counting phase fractions, or filtering atoms.

  • It is built on top of the chi-parameter histogram, which itself is a useful continuous descriptor.

  • For warmer or more disordered systems, complement it with one of the averaged Steinhardt or CNA methods.

Reference#

G. J. Ackland and A. P. Jones, Applications of local crystal structure measures in experiment and simulation, Phys. Rev. B 73, 054104 (2006).