Lattice Normal Form¶
This guide explains how the Lattice Normal Form (LNF), the first 7 integers of a CNF, is constructed from a crystal lattice.
from pymatgen.core import Structure, Lattice
import numpy as np
# We'll use rutile TiO2 throughout
a, c = 4.594, 2.959
lattice = Lattice.tetragonal(a, c)
species = ["Ti", "Ti", "O", "O", "O", "O"]
coords = [[0, 0, 0], [0.5, 0.5, 0.5], [0.305, 0.305, 0], [0.695, 0.695, 0], [0.805, 0.195, 0.5], [0.195, 0.805, 0.5]]
rutile = Structure(lattice, species, coords)
print(f"Rutile TiO2: a = {a} Å, c = {c} Å")
Rutile TiO2: a = 4.594 Å, c = 2.959 Å
The Superbasis¶
A conventional lattice uses 3 generating vectors. The CNF algorithm uses a superbasis of 4 vectors $\{\mathbf{v}_0, \mathbf{v}_1, \mathbf{v}_2, \mathbf{v}_3\}$ where
$$\mathbf{v}_3 = -(\mathbf{v}_0 + \mathbf{v}_1 + \mathbf{v}_2)$$
The first three vectors are simply the lattice vectors; the fourth is their negated sum. This redundant representation is central to the Selling reduction algorithm described below.
from cnf.lattice import Superbasis
superbasis = Superbasis.from_pymatgen_structure(rutile)
print("Superbasis vectors:")
for i, vec in enumerate(superbasis.superbasis_vecs):
label = "(= -[v0+v1+v2])" if i == 3 else ""
print(f" v{i}: [{vec[0]:7.3f}, {vec[1]:7.3f}, {vec[2]:7.3f}] {label}")
# Verify the constraint
v0, v1, v2, v3 = superbasis.superbasis_vecs
check = v0 + v1 + v2 + v3
print(f"\nv0 + v1 + v2 + v3 = [{check[0]:.1e}, {check[1]:.1e}, {check[2]:.1e}] ≈ 0 ✓")
Superbasis vectors: v0: [ 4.594, 0.000, 0.000] v1: [ -0.000, 4.594, 0.000] v2: [ 0.000, 0.000, 2.959] v3: [ -4.594, -4.594, -2.959] (= -[v0+v1+v2]) v0 + v1 + v2 + v3 = [0.0e+00, 0.0e+00, 0.0e+00] ≈ 0 ✓
Vonorms and Conorms¶
From the superbasis we compute two sets of quantities, following the framework developed by Conway and Sloane [1].
The vonorms are 7 squared lengths derived from the superbasis vectors. The first four are the squared lengths of the superbasis vectors themselves:
$$|\mathbf{v}_0|^2, \quad |\mathbf{v}_1|^2, \quad |\mathbf{v}_2|^2, \quad |\mathbf{v}_3|^2$$
The remaining three are squared lengths of pairwise sums:
$$|\mathbf{v}_0+\mathbf{v}_1|^2, \quad |\mathbf{v}_0+\mathbf{v}_2|^2, \quad |\mathbf{v}_0+\mathbf{v}_3|^2$$
These 7 values correspond to the Voronoi vectors of the lattice. In the Wigner-Seitz construction, each Voronoi vector defines a face of the cell via its perpendicular bisector plane. An obtuse superbasis directly reveals these vectors.
The conorms are the 6 dot products between pairs of superbasis vectors:
$$\mathbf{v}_0 \cdot \mathbf{v}_1, \quad \mathbf{v}_0 \cdot \mathbf{v}_2, \quad \mathbf{v}_0 \cdot \mathbf{v}_3, \quad \mathbf{v}_1 \cdot \mathbf{v}_2, \quad \mathbf{v}_1 \cdot \mathbf{v}_3, \quad \mathbf{v}_2 \cdot \mathbf{v}_3$$
The vonorms and conorms are related by a linear transformation, so knowing one set determines the other.
[1] J.H. Conway and N.J.A. Sloane, "Low-dimensional lattices. VI. Voronoi reduction of three-dimensional lattices," Proc. Royal Soc. London A, 436(1896), 55-68, 1992.
vonorms = superbasis.compute_vonorms()
print("Vonorms (squared lengths):")
print(f" Primary: |v0|²={vonorms[0]:.2f}, |v1|²={vonorms[1]:.2f}, |v2|²={vonorms[2]:.2f}, |v3|²={vonorms[3]:.2f}")
print(f" Secondary: |v0+v1|²={vonorms[4]:.2f}, |v0+v2|²={vonorms[5]:.2f}, |v0+v3|²={vonorms[6]:.2f}")
conorms = vonorms.conorms
print("\nConorms (dot products):")
labels = ["v0·v1", "v0·v2", "v0·v3", "v1·v2", "v1·v3", "v2·v3"]
for label, val in zip(labels, conorms.conorms):
print(f" {label} = {val:7.2f}")
Vonorms (squared lengths): Primary: |v0|²=21.10, |v1|²=21.10, |v2|²=8.76, |v3|²=50.97 Secondary: |v0+v1|²=42.21, |v0+v2|²=29.86, |v0+v3|²=29.86 Conorms (dot products): v0·v1 = 0.00 v0·v2 = 0.00 v0·v3 = -21.10 v1·v2 = 0.00 v1·v3 = -21.10 v2·v3 = -8.76
A superbasis is called obtuse if all conorms are ≤ 0, meaning all angles between superbasis vectors are ≥ 90°. Obtuse superbases have desirable canonical properties.
print(f"Is this superbasis obtuse? {superbasis.is_obtuse()}")
print(f"Positive conorms: {[f'{c:.2f}' for c in conorms.conorms if c > 0]}")
Is this superbasis obtuse? False Positive conorms: ['0.00', '0.00']
Selling Reduction¶
The Selling reduction algorithm transforms any superbasis into an obtuse one. It repeatedly finds the most positive conorm $\mathbf{v}_i \cdot \mathbf{v}_j > 0$ and applies an elementary transformation that makes this conorm non-positive. The algorithm continues until all conorms are ≤ 0.
Each transformation is a unimodular operation that preserves the lattice while changing which vectors we use to describe it.
from cnf.lattice.selling import SuperbasisSellingReducer
reducer = SuperbasisSellingReducer(verbose_logging=True)
result = reducer.reduce(superbasis)
reduced_superbasis = result.reduced_object
print(f"\nReduction complete in {result.num_steps} step(s)")
Reduction complete! (took 0) steps Reduction complete in 0 step(s)
reduced_vonorms = reduced_superbasis.compute_vonorms()
reduced_conorms = reduced_vonorms.conorms
print("Before reduction:")
print(f" Conorms: {[f'{c:.2f}' for c in conorms.conorms]}")
print(f" Obtuse: {superbasis.is_obtuse()}")
print("\nAfter reduction:")
print(f" Conorms: {[f'{c:.2f}' for c in reduced_conorms.conorms]}")
print(f" Obtuse: {reduced_superbasis.is_obtuse()}")
Before reduction: Conorms: ['0.00', '0.00', '-21.10', '0.00', '-21.10', '-8.76'] Obtuse: False After reduction: Conorms: ['0.00', '0.00', '-21.10', '0.00', '-21.10', '-8.76'] Obtuse: False
# The transformation is tracked as a unimodular matrix
transform = result.transform_matrix
print("Unimodular transformation matrix:")
print(transform.matrix)
print(f"\nDeterminant: {int(np.linalg.det(transform.matrix))} (±1 for unimodular)")
Unimodular transformation matrix: [[1. 0. 0.] [0. 1. 0.] [0. 0. 1.]] Determinant: 1 (±1 for unimodular)
Canonical Permutations¶
After Selling reduction, the superbasis is obtuse, but it is not yet unique. Multiple permutations of the superbasis vectors can describe the same lattice, and we need to select a canonical representative.
The Permissibility Condition¶
Not all permutations of the vonorms correspond to valid lattice transformations. The vonorms and conorms are related by a fixed linear transformation $T$. For a permutation $P_v$ acting on vonorms to be valid, there must exist a corresponding permutation $P_c$ acting on conorms such that
$$P_c^{-1} \cdot T \cdot P_v = T$$
This constraint ensures the permutation preserves the geometric relationship between vonorms and conorms. When a conorm is zero (meaning two superbasis vectors are orthogonal), additional permutations become valid because swapping orthogonal vectors does not change the lattice geometry.
Voronoi Classes¶
The number of zero conorms determines the Voronoi class of the lattice:
| Class | Zero Conorms | Permutations |
|---|---|---|
| V1 | 0 | 24 |
| V2 | 1 | 48 |
| V3/V4 | 2 | 72 |
| V5 | 3 | 96 |
Higher-symmetry lattices have more zero conorms and thus more permissible permutations.
zero_indices = [i for i, c in enumerate(reduced_conorms.conorms) if abs(c) < 1e-6]
print(f"Zero conorm indices: {zero_indices}")
print(f"Voronoi class: {reduced_conorms.form.voronoi_class}")
perms = reduced_vonorms.permissible_perms
print(f"Number of permissible permutations: {len(perms)}")
Zero conorm indices: [0, 1, 3] Voronoi class: 5 Number of permissible permutations: 96
Lexicographic Selection¶
To obtain a unique canonical form, we apply each permissible permutation to the vonorms and select the lexicographically smallest result. This gives the Lattice Normal Form.
candidates = []
for perm in perms:
permuted = reduced_vonorms.apply_permutation(tuple(perm.vonorm_permutation))
candidates.append(permuted.tuple)
candidates.sort()
canonical = candidates[0]
print("Sample permuted vonorms (sorted):")
for i, c in enumerate(candidates[:5]):
marker = " ← canonical (lexmin)" if i == 0 else ""
print(f" {tuple(round(v, 1) for v in c)}{marker}")
if len(candidates) > 5:
print(f" ... and {len(candidates) - 5} more")
Sample permuted vonorms (sorted): (8.8, 21.1, 21.1, 51.0, 29.9, 29.9, 42.2) ← canonical (lexmin) (8.8, 21.1, 21.1, 51.0, 29.9, 29.9, 42.2) (8.8, 21.1, 29.9, 42.2, 29.9, 21.1, 51.0) (8.8, 21.1, 29.9, 42.2, 29.9, 21.1, 51.0) (8.8, 21.1, 42.2, 29.9, 29.9, 51.0, 21.1) ... and 91 more
Discretization¶
Finally, the vonorms are discretized to integer multiples of ξ (xi). This creates a discrete lattice in vonorm space where neighbors differ by ±1 in a single coordinate.
from cnf import CNFConstructor
constructor = CNFConstructor(xi=1.0, delta=100)
result = constructor.from_pymatgen_structure(rutile)
lnf = result.cnf.lattice_normal_form
print("Canonical vonorms (continuous):")
print(f" {tuple(round(v, 2) for v in canonical)}")
print("\nLNF vonorms (discretized to xi=1.0):")
print(f" {lnf.vonorms.tuple}")
Canonical vonorms (continuous): (8.76, 21.1, 21.1, 50.97, 29.86, 29.86, 42.21) LNF vonorms (discretized to xi=1.0): (9, 21, 21, 51, 30, 30, 42)
Summary¶
The LNF construction proceeds as follows:
- Form a superbasis from the lattice vectors
- Apply Selling reduction to obtain an obtuse superbasis
- Compute vonorms from the reduced superbasis
- Apply all permissible permutations (determined by zero conorms)
- Select the lexicographically smallest vonorm tuple
- Discretize to integer multiples of ξ
The result is 7 integers that uniquely characterize the lattice geometry.
Next¶
The LNF describes the lattice. To complete the CNF, we need the Motif Normal Form which describes atomic positions. See Motif Normal Form.