CNF Overview¶
Crystal Normal Form (CNF) is a unique integer representation for any 3D crystal structure. This guide introduces the representation and shows how it works in practice.
Motivation¶
Crystal structures are traditionally described using three lattice vectors plus fractional atomic coordinates. While intuitive, this representation is highly redundant: infinitely many choices of unit cell can describe the same lattice, the origin can be placed anywhere, and atoms of the same element can be labeled in any order.
CNF addresses this by providing a canonical representation. Every crystal maps to a unique tuple of integers, and two crystals have the same CNF if and only if they are geometrically equivalent. This enables exact comparison of structures and systematic exploration of crystal configuration space.
from pymatgen.core import Structure, Lattice
from cnf import CNFConstructor
import numpy as np
# Create a rutile TiO2 structure
a, c = 4.594, 2.959
lattice = Lattice.tetragonal(a, c)
species = ["Ti", "Ti", "O", "O", "O", "O"]
coords = [
[0.0, 0.0, 0.0],
[0.5, 0.5, 0.5],
[0.305, 0.305, 0.0],
[0.695, 0.695, 0.0],
[0.805, 0.195, 0.5],
[0.195, 0.805, 0.5],
]
rutile = Structure(lattice, species, coords)
# Convert to CNF
constructor = CNFConstructor(xi=1.0, delta=100)
result = constructor.from_pymatgen_structure(rutile)
cnf = result.cnf
print("Rutile TiO2 Crystal Normal Form:")
print(f" {cnf.coords}")
print(f"\nThis tuple of {len(cnf.coords)} integers uniquely identifies this crystal.")
Rutile TiO2 Crystal Normal Form: (9, 21, 21, 51, 30, 30, 42, 50, 50, 50, 0, 30, 30, 0, 70, 70, 50, 20, 80, 50, 80, 20) This tuple of 22 integers uniquely identifies this crystal.
Structure of the CNF Tuple¶
The CNF tuple has two parts. The first 7 integers form the Lattice Normal Form (LNF), which describes the lattice geometry. The remaining integers form the Motif Normal Form (MNF), which describes atomic positions. For a structure with N atoms, the MNF contains 3×(N-1) integers, since one atom is fixed at the origin by convention.
lnf = cnf.lattice_normal_form
mnf = cnf.motif_normal_form
print("Lattice Normal Form (LNF):")
print(f" Coordinates: {lnf.vonorms.tuple}")
print()
print("Motif Normal Form (MNF):")
print(f" Coordinates: {mnf.coord_list}")
print(f" Elements: {mnf.elements}")
print()
print(f"The structure has {len(mnf.elements)} atoms.")
print(f"The first atom is at the origin (implicit), so the MNF stores")
print(f"{len(mnf.elements)-1} atoms × 3 coordinates = {len(mnf.coord_list)} integers.")
Lattice Normal Form (LNF): Coordinates: (9, 21, 21, 51, 30, 30, 42) Motif Normal Form (MNF): Coordinates: (50, 50, 50, 0, 30, 30, 0, 70, 70, 50, 20, 80, 50, 80, 20) Elements: ['Ti', 'Ti', 'O', 'O', 'O', 'O'] The structure has 6 atoms. The first atom is at the origin (implicit), so the MNF stores 5 atoms × 3 coordinates = 15 integers.
What are LNF coordinates?¶
The 7 LNF coordinates are called vonorms. They derive from a construction involving the Wigner-Seitz cell of the lattice.
The Wigner-Seitz cell (also known as the Voronoi cell) is the region of space closer to one lattice point than to any other. In 3D, this cell is a convex polyhedron whose faces are perpendicular bisector planes of vectors connecting the origin to neighboring lattice points. The vectors that define these faces are called Voronoi vectors.
Conway and Sloane showed that any 3D lattice can be described by an obtuse superbasis of 4 vectors, from which at most 7 Voronoi vectors can be derived. The squared lengths of these 7 vectors are the vonorms. They uniquely characterize the lattice geometry up to rotation and reflection.
The LNF coordinates are these vonorms, discretized to integer multiples of the resolution parameter ξ (xi).
What are MNF coordinates?¶
The MNF coordinates are fractional atomic positions, discretized to integer multiples of 1/δ (delta). By convention, one atom (the one with lowest atomic number, sorted lexicographically by position) is fixed at the origin, and the remaining N-1 atoms have their 3 fractional coordinates stored.
The MNF is made canonical by considering all equivalent ways to represent the same motif (using lattice symmetries and different origin choices) and selecting the lexicographically smallest coordinate tuple.
Discretization Parameters¶
CNF uses two resolution parameters. The parameter ξ (xi) controls lattice resolution and has units of Ų. The parameter δ (delta) controls motif resolution, with coordinates stored as multiples of 1/δ.
Smaller values give finer resolution but result in larger coordinate values and more neighbors.
# Compare different resolutions
for xi, delta in [(1.0, 100), (0.5, 200), (2.0, 50)]:
c = CNFConstructor(xi=xi, delta=delta)
result = c.from_pymatgen_structure(rutile)
print(f"xi={xi}, delta={delta}: {result.cnf.coords}")
xi=1.0, delta=100: (9, 21, 21, 51, 30, 30, 42, 50, 50, 50, 0, 30, 30, 0, 70, 70, 50, 20, 80, 50, 80, 20) xi=0.5, delta=200: (18, 42, 42, 102, 60, 60, 84, 100, 100, 100, 0, 61, 61, 0, 139, 139, 100, 39, 161, 100, 161, 39) xi=2.0, delta=50: (4, 11, 11, 25, 15, 15, 21, 25, 25, 25, 0, 15, 15, 0, 35, 35, 25, 10, 40, 25, 40, 10)
Reconstruction¶
A CNF can be converted back to a pymatgen Structure. Small differences from the original arise from discretization.
reconstructed = cnf.reconstruct()
print(f"Original volume: {rutile.volume:.4f} ų")
print(f"Reconstructed volume: {reconstructed.volume:.4f} ų")
print(f"Difference: {abs(rutile.volume - reconstructed.volume):.4f} ų")
Original volume: 62.4492 ų Reconstructed volume: 63.0000 ų Difference: 0.5508 ų
The Neighbor Graph¶
A key property of the CNF representation is that every crystal has well-defined neighbors: structures whose CNF coordinates differ by ±1 in a single position.
Lattice neighbors (changes to the first 7 coordinates) correspond to small strains. Motif neighbors (changes to the remaining coordinates) correspond to small atomic displacements. Together, these define a graph connecting all possible crystals of a given composition.
from cnf.navigation import find_neighbors, NeighborFinder
neighbors = find_neighbors(cnf)
print(f"This CNF has {len(neighbors)} neighbors.")
print("\nFirst 5 neighbors (showing coordinate changes):")
orig = np.array(cnf.coords)
for i, neighbor in enumerate(neighbors[:5]):
diff = np.array(neighbor.coords) - orig
changes = [f"[{j}]: {int(diff[j]):+d}" for j in np.where(diff != 0)[0]]
print(f" {i+1}. {', '.join(changes)}")
This CNF has 22 neighbors. First 5 neighbors (showing coordinate changes): 1. [16]: -1 2. [3]: -1, [4]: -1 3. [14]: -1 4. [7]: -1, [8]: -1, [9]: -1 5. [0]: +1, [3]: -1
# Separate lattice and motif neighbors
finder = NeighborFinder.from_cnf(cnf)
lattice_neighbors = finder.find_lattice_neighbor_cnfs(cnf)
motif_neighbors = finder.find_motif_neighbor_cnfs(cnf)
print(f"Lattice neighbors (strain): {len(lattice_neighbors)}")
print(f"Motif neighbors (displacement): {len(motif_neighbors)}")
Lattice neighbors (strain): 11 Motif neighbors (displacement): 11
Applications¶
The CNF representation enables several capabilities. Two crystals can be compared for exact geometric equivalence by checking if their CNFs match. Transformation paths between any two crystals of the same composition can be found by walking through the neighbor graph. Energy landscape exploration becomes possible by systematically visiting crystal configurations to find transition barriers between phases.
The neighbor graph connects all possible crystals of a given composition, allowing navigation from any structure to any other through a series of small steps.
Further Reading¶
For details on how CNF is constructed, see the Lattice Normal Form and Motif Normal Form guides.