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 atompyscal_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).