Example: Wurtzite Ferroelectric (ScAlN)

This example computes the out-of-plane cation displacement (Dz) in a wurtzite-type ferroelectric such as ScxAl1-xN.

In wurtzite, each cation (Ga/Sc/Al) is tetrahedrally coordinated by 4 N atoms: 1 along the c-axis and 3 in the basal plane. The ferroelectric polarization is governed by the cation’s displacement relative to its N neighbors along z. The key challenge is that we only want the 3 in-plane N neighbors (not the axial one) to define the coordination center, so we need a custom neighbor list. For core concepts, see Introduction to ferrodispcalc.

Complete Script

import numpy as np
from ase.io import read
from ferrodispcalc.neighborlist import build_neighbor_list
from ferrodispcalc.compute import calculate_displacement

atoms = read("avg.xsf")

# --- Custom neighbor list for wurtzite ---
# Start with all 4 N neighbors per cation
nl_full = build_neighbor_list(atoms, ["Ga", "Sc"], ["N"], cutoff=4, neighbor_num=4)

center_id = nl_full[:, 0]
neighbor_id = nl_full[:, 1:]

# Filter: keep only the 3 neighbors in the basal plane (small dz)
box = atoms.get_cell().array
box_inv = np.linalg.inv(box)
center_pos = atoms.get_positions()[center_id - 1]
nl = np.full((nl_full.shape[0], 4), -1, dtype=int)  # 1 center + 3 neighbors
nl[:, 0] = center_id

for i, neighbors in enumerate(neighbor_id):
    diff = atoms.get_positions()[neighbors - 1] - center_pos[i]
    diff_frac = diff @ box_inv
    diff_frac[diff_frac > 0.5] -= 1.0
    diff_frac[diff_frac < -0.5] += 1.0
    diff = diff_frac @ box
    in_plane = np.abs(diff[:, 2]) < 1.0  # small z-component = basal plane
    nl[i, 1:] = neighbors[in_plane]

# --- Compute displacement ---
disp = calculate_displacement(atoms, nl)
mean_Dz = np.mean(disp[:, 2])
print(f"Mean Dz: {mean_Dz:.4f} Angstrom")

Step-by-Step Breakdown

Why a Custom Neighbor List?

build_neighbor_list returns the 4 nearest N neighbors for each cation. In wurtzite, these 4 neighbors split into two groups:

  • 3 in the basal plane (roughly the same z as the cation)

  • 1 along the c-axis (large dz)

For computing Dz, we want the displacement of the cation relative to the 3 in-plane neighbors only. So we filter by |dz| < 1 Angstrom after applying the minimum image convention:

nl_full = build_neighbor_list(atoms, ["Ga", "Sc"], ["N"], cutoff=4, neighbor_num=4)

# For each center, compute MIC-corrected dz to each neighbor
for i, neighbors in enumerate(neighbor_id):
    diff = atoms.get_positions()[neighbors - 1] - center_pos[i]
    diff_frac = diff @ box_inv
    diff_frac[diff_frac > 0.5] -= 1.0
    diff_frac[diff_frac < -0.5] += 1.0
    diff = diff_frac @ box
    in_plane = np.abs(diff[:, 2]) < 1.0
    nl[i, 1:] = neighbors[in_plane]

The resulting nl has shape (n_cations, 4) — column 0 is the center index, columns 1-3 are the 3 in-plane N neighbors.

Compute Dz

With the filtered neighbor list, calculate_displacement gives the cation offset from the center of its 3 basal-plane N neighbors. The z-component is Dz:

disp = calculate_displacement(atoms, nl)  # shape (n_cations, 3)
Dz = disp[:, 2]  # z-component only

See calculate_displacement() and build_neighbor_list() for API details.