# Copyright (c) 2022-2026, Yongchao Wu in Aalto University
# This file is from the mdapy project, released under the BSD 3-Clause License.
from mdapy import _repeat_cell
from mdapy.system import System
from mdapy.box import Box
import polars as pl
import numpy as np
from typing import Optional, Tuple
def _get_basispos_and_box_cubic(
structure: str, a: float, c_over_a: Optional[float] = None
) -> Tuple[np.ndarray, np.ndarray]:
"""Get basis positions and box for cubic/HCP/graphene structures (internal helper)."""
if c_over_a is None:
c_over_a = np.sqrt(8 / 3)
s = structure.lower()
if s in ("fcc", "bcc", "diamond"):
box = np.array([[a, 0, 0], [0, a, 0], [0, 0, a]], dtype=float)
if s == "fcc":
basis_pos = np.array(
[
(0.0, 0.0, 0.0),
(0.0, 0.5, 0.5),
(0.5, 0.0, 0.5),
(0.5, 0.5, 0.0),
]
)
elif s == "bcc":
basis_pos = np.array(
[
(0.0, 0.0, 0.0),
(0.5, 0.5, 0.5),
]
)
elif s == "diamond":
basis_pos = np.array(
[
(0.0, 0.0, 0.0),
(0.25, 0.25, 0.25),
(0.0, 0.5, 0.5),
(0.25, 0.75, 0.75),
(0.5, 0.0, 0.5),
(0.75, 0.25, 0.75),
(0.5, 0.5, 0.0),
(0.75, 0.75, 0.25),
]
)
elif s == "hcp":
box = np.array(
[
[a, 0.0, 0.0],
[0.0, np.sqrt(3) * a, 0.0],
[0.0, 0.0, a * c_over_a],
]
)
basis_pos = np.array(
[
[0.0, 0.0, 0.0],
[0.5, 0.5, 0.0],
[0.5, 5 / 6, 0.5],
[0.0, 1 / 3, 0.5],
]
)
elif s == "graphene":
box = np.array(
[
[3.0 * a, 0.0, 0.0],
[0.0, np.sqrt(3) * a, 0.0],
[0.0, 0.0, 3.4],
]
)
basis_pos = np.array(
[[1 / 6, 0.0, 0.0], [0.5, 0.0, 0.0], [0.0, 0.5, 0.0], [2 / 3, 0.5, 0.0]]
)
else:
raise ValueError(
f"Unsupported structure: {structure}, only support fcc, bcc, hcp, graphene, and diamond"
)
return box, basis_pos
def _gcd(a: int, b: int) -> int:
"""Calculate greatest common divisor (internal helper)."""
while b:
a, b = b, a % b
return abs(a)
def _gcd_three(a: int, b: int, c: int) -> int:
"""Calculate greatest common divisor of three integers (internal helper)."""
return _gcd(_gcd(a, b), c)
def _reduce_miller(miller: Tuple[int, int, int]) -> Tuple[int, int, int]:
"""Reduce Miller indices to simplest form (internal helper)."""
h, k, L = miller
if h == 0 and k == 0 and L == 0:
raise ValueError("Miller indices cannot be all zeros")
g = _gcd_three(h, k, L)
if g == 0:
return miller
return (h // g, k // g, L // g)
def _check_orthogonality_miller(
m1: Tuple[int, int, int], m2: Tuple[int, int, int], m3: Tuple[int, int, int]
) -> bool:
"""Check if three Miller indices are orthogonal in cubic system (internal helper)."""
# For cubic system, [h1k1l1]⊥[h2k2l2] ⟺ h1*h2 + k1*k2 + l1*l2 = 0
dot12 = m1[0] * m2[0] + m1[1] * m2[1] + m1[2] * m2[2]
dot13 = m1[0] * m3[0] + m1[1] * m3[1] + m1[2] * m3[2]
dot23 = m2[0] * m3[0] + m2[1] * m3[1] + m2[2] * m3[2]
return dot12 == 0 and dot13 == 0 and dot23 == 0
def _check_right_hand_miller(
m1: Tuple[int, int, int], m2: Tuple[int, int, int], m3: Tuple[int, int, int]
) -> bool:
"""Check if three Miller indices satisfy right-hand rule (internal helper)."""
# m1 × m2 should be parallel and in same direction as m3
cross = np.cross(np.array(m1), np.array(m2))
dot = np.dot(cross, np.array(m3))
return dot > 0
def _build_transform_matrix(
miller1: Tuple[int, int, int],
miller2: Tuple[int, int, int],
miller3: Tuple[int, int, int],
) -> np.ndarray:
"""Build transformation matrix from Miller indices (internal helper)."""
M = np.array(
[
[miller1[0], miller2[0], miller3[0]],
[miller1[1], miller2[1], miller3[1]],
[miller1[2], miller2[2], miller3[2]],
],
dtype=int,
)
return M
def _find_atoms_in_new_cell(
transform_matrix: np.ndarray, basis_positions: np.ndarray
) -> np.ndarray:
"""Find all atoms in the new unit cell (internal helper)."""
M = transform_matrix.astype(float)
M_inv = np.linalg.inv(M)
# Calculate search range
det = abs(np.linalg.det(M))
expected_atoms = int(np.round(det * len(basis_positions)))
# Search range determined by matrix elements
max_coef = int(np.max(np.abs(M)))
search_range = max_coef + 1
new_basis_list = []
# Iterate through periodic replicas of original lattice
for i in range(-search_range, search_range + 1):
for j in range(-search_range, search_range + 1):
for k in range(-search_range, search_range + 1):
for basis_atom in basis_positions:
# Fractional coordinates in original lattice
frac_old = basis_atom + np.array([i, j, k], dtype=float)
# Transform to new lattice fractional coordinates
frac_new = M_inv @ frac_old
# Wrap to [0, 1)
frac_new = frac_new - np.floor(frac_new + 1e-10)
# Check if in [0, 1)
if np.all(frac_new >= -1e-8) and np.all(frac_new < 1.0 - 1e-8):
# Check for duplicates
is_duplicate = False
for existing in new_basis_list:
diff = frac_new - existing
diff = diff - np.round(diff)
if np.linalg.norm(diff) < 1e-6:
is_duplicate = True
break
if not is_duplicate:
new_basis_list.append(frac_new.copy())
new_basis = np.array(new_basis_list)
# Verify atom count
if len(new_basis) != expected_atoms:
print(f"Warning: Expected {expected_atoms} atoms but found {len(new_basis)}")
return new_basis
def _align_box_to_axes(
box: np.ndarray, basis: np.ndarray
) -> Tuple[np.ndarray, np.ndarray]:
"""Align orthogonal lattice vectors to coordinate axes (internal helper)."""
# Extract the three lattice vectors
v1, v2, v3 = box[0], box[1], box[2]
# Compute lengths
len1 = np.linalg.norm(v1)
len2 = np.linalg.norm(v2)
len3 = np.linalg.norm(v3)
# Check orthogonality
dot12 = np.dot(v1, v2)
dot13 = np.dot(v1, v3)
dot23 = np.dot(v2, v3)
if abs(dot12) > 1e-6 or abs(dot13) > 1e-6 or abs(dot23) > 1e-6:
raise ValueError(
f"Lattice vectors are not orthogonal! "
f"v1·v2={dot12:.6f}, v1·v3={dot13:.6f}, v2·v3={dot23:.6f}"
)
# Create aligned box with vectors parallel to axes
aligned_box = np.array(
[[len1, 0.0, 0.0], [0.0, len2, 0.0], [0.0, 0.0, len3]], dtype=float
)
# Fractional coordinates don't change
aligned_basis = basis.copy()
return aligned_box, aligned_basis
def _find_minimal_cell(
box: np.ndarray, basis: np.ndarray, tolerance: float = 1e-6, max_search: int = 10
) -> Tuple[np.ndarray, np.ndarray]:
"""Find the minimal periodic cell using brute force search (internal helper)."""
# Verify box alignment
off_diag = [box[0, 1], box[0, 2], box[1, 0], box[1, 2], box[2, 0], box[2, 1]]
if any(abs(x) > tolerance for x in off_diag):
raise ValueError("Box must be aligned to axes")
a, b, c = box[0, 0], box[1, 1], box[2, 2]
n_atoms = len(basis)
if n_atoms == 0:
return box, basis
# Wrap basis to [0, 1)
basis_wrapped = basis - np.floor(basis + tolerance)
best_box = box.copy()
best_basis = basis_wrapped.copy()
min_atoms = n_atoms
# Brute force: try ALL divisor combinations
for nx in range(1, max_search + 1):
for ny in range(1, max_search + 1):
for nz in range(1, max_search + 1):
if nx == 1 and ny == 1 and nz == 1:
continue
n_div = nx * ny * nz
if n_atoms % n_div != 0:
continue # Must divide evenly
expected_atoms = n_atoms // n_div
if expected_atoms >= min_atoms:
continue # Not better
# Test this division
test_box = np.array([[a / nx, 0, 0], [0, b / ny, 0], [0, 0, c / nz]])
# Find atoms in first subcell [0, 1/nx) × [0, 1/ny) × [0, 1/nz)
small_atoms = []
for atom in basis_wrapped:
if (
atom[0] >= -tolerance
and atom[0] < 1.0 / nx - tolerance
and atom[1] >= -tolerance
and atom[1] < 1.0 / ny - tolerance
and atom[2] >= -tolerance
and atom[2] < 1.0 / nz - tolerance
):
# Convert to small cell fractional coords
frac_small = atom * np.array([nx, ny, nz])
frac_small = frac_small - np.floor(frac_small + tolerance)
# Check duplicates
is_dup = False
for existing in small_atoms:
diff = frac_small - existing
diff = diff - np.round(diff)
if np.linalg.norm(diff) < tolerance:
is_dup = True
break
if not is_dup:
small_atoms.append(frac_small)
if len(small_atoms) != expected_atoms:
continue
# Verify: replicate and check
replicated = []
for i in range(nx):
for j in range(ny):
for k in range(nz):
for atom in small_atoms:
frac_orig = (atom + np.array([i, j, k])) / np.array(
[nx, ny, nz]
)
frac_orig = frac_orig - np.floor(frac_orig + tolerance)
replicated.append(frac_orig)
if len(replicated) != n_atoms:
continue
# Match all atoms
matched = [False] * n_atoms
for rep in replicated:
for idx, orig in enumerate(basis_wrapped):
if matched[idx]:
continue
diff = rep - orig
diff = diff - np.round(diff)
if np.linalg.norm(diff) < tolerance:
matched[idx] = True
break
if all(matched):
# Valid!
if expected_atoms < min_atoms:
min_atoms = expected_atoms
best_box = test_box.copy()
best_basis = np.array(small_atoms)
return best_box, best_basis
def _build_lattice_from_miller(
structure: str,
miller1: Tuple[int, int, int],
miller2: Tuple[int, int, int],
miller3: Tuple[int, int, int],
lattice_constant: float,
) -> Tuple[np.ndarray, np.ndarray]:
"""Build lattice from Miller indices (internal helper)."""
# Reduce Miller indices
miller1 = _reduce_miller(miller1)
miller2 = _reduce_miller(miller2)
miller3 = _reduce_miller(miller3)
# Check orthogonality and right-hand rule
if not _check_orthogonality_miller(miller1, miller2, miller3):
raise ValueError(
f"Miller indices must be orthogonal. Got {miller1}, {miller2}, {miller3}"
)
if not _check_right_hand_miller(miller1, miller2, miller3):
raise ValueError("Miller indices must satisfy right-hand rule")
# Get original lattice (cubic)
box, basis = _get_basispos_and_box_cubic(structure, lattice_constant)
# Build transformation matrix
M = _build_transform_matrix(miller1, miller2, miller3)
# Calculate new lattice vectors
new_lattice = box @ M.T
# Find atoms in new cell
new_basis = _find_atoms_in_new_cell(M, basis)
# Align box to axes for rectangular shape
aligned_lattice, aligned_basis = _align_box_to_axes(new_lattice, new_basis)
return aligned_lattice, aligned_basis
[docs]
def build_crystal(
name: str,
structure: str,
a: float,
miller1: Optional[Tuple[int, int, int]] = None,
miller2: Optional[Tuple[int, int, int]] = None,
miller3: Optional[Tuple[int, int, int]] = None,
nx: int = 1,
ny: int = 1,
nz: int = 1,
c_over_a: float = np.sqrt(8 / 3),
) -> System:
"""
Build crystal structure with optional Miller indices orientation.
This function creates a crystal structure with specified lattice type and orientation.
It supports standard crystallographic structures (FCC, BCC, HCP, diamond, graphene) and
allows custom orientations via Miller indices for cubic structures.
Parameters
----------
name : str
Element symbol (e.g., 'Cu', 'Al', 'Mg', 'C').
structure : str
Crystal structure type. Supported values:
- ``'fcc'``: Face-centered cubic
- ``'bcc'``: Body-centered cubic
- ``'diamond'``: Diamond cubic
- ``'hcp'``: Hexagonal close-packed
- ``'graphene'``: Graphene layer
a : float
Lattice constant in Angstroms.
- For cubic structures (FCC/BCC/diamond): edge length of cubic unit cell
- For HCP: basal plane lattice parameter
- For graphene: C-C bond length
miller1 : tuple of int, optional
First Miller index (h, k, l) defining the x-axis direction.
Only applicable for cubic structures. If None, uses standard orientation [1,0,0].
miller2 : tuple of int, optional
Second Miller index (h, k, l) defining the y-axis direction.
Only applicable for cubic structures. If None, uses standard orientation [0,1,0].
miller3 : tuple of int, optional
Third Miller index (h, k, l) defining the z-axis direction.
Only applicable for cubic structures. If None, uses standard orientation [0,0,1].
.. note::
The three Miller indices must be mutually orthogonal and satisfy the
right-hand rule: miller1 × miller2 = miller3.
nx : int, default=1
Number of repetitions along x-axis direction.
ny : int, default=1
Number of repetitions along y-axis direction.
nz : int, default=1
Number of repetitions along z-axis direction.
c_over_a : float, default=sqrt(8/3)
Ratio of c to a for HCP structure. Default value gives ideal HCP packing.
Ignored for other structures.
Returns
-------
System
A System object containing the built crystal structure with atomic positions
and simulation box.
Raises
------
ValueError
- If an unsupported structure type is specified
- If Miller indices are provided for non-cubic structures
- If Miller indices are not mutually orthogonal
- If Miller indices don't satisfy the right-hand rule
Examples
--------
Create a standard FCC copper crystal:
>>> cu = build_crystal("Cu", "fcc", a=3.615, nx=5, ny=5, nz=5)
Create an FCC crystal with custom Miller orientation:
>>> cu_rotated = build_crystal(
... "Cu",
... "fcc",
... a=3.615,
... miller1=(1, -1, 0),
... miller2=(1, 1, -2),
... miller3=(1, 1, 1),
... nx=5,
... ny=5,
... nz=5,
... )
Create an HCP magnesium crystal:
>>> mg = build_crystal("Mg", "hcp", a=3.21, c_over_a=1.624, nx=10, ny=10, nz=10)
Create a graphene sheet:
>>> graphene = build_crystal("C", "graphene", a=1.42, nx=20, ny=20, nz=1)
Notes
-----
- For cubic structures with Miller indices, the function automatically finds
the minimal periodic cell and aligns it to coordinate axes.
- The resulting structure has an orthogonal simulation box aligned with
the Cartesian coordinate system.
- Atomic positions are generated by replicating the unit cell nx×ny×nz times.
"""
if miller1 is None and miller2 is None and miller3 is None:
# Standard orientation
old_box, old_pos = _get_basispos_and_box_cubic(structure, a, c_over_a)
else:
# Custom Miller orientation
if structure.lower() in ["fcc", "bcc", "diamond"]:
# Cubic: 3-index Miller
if len(miller1) != 3 or len(miller2) != 3 or len(miller3) != 3:
raise ValueError(
f"Cubic structures require 3-index Miller indices [h,k,l]. "
f"Got miller1={miller1}, miller2={miller2}, miller3={miller3}"
)
old_box, old_pos = _build_lattice_from_miller(
structure, miller1, miller2, miller3, a
)
else:
raise ValueError(
f"Miller indices are only supported for cubic structures (fcc, bcc, diamond). "
f"Got structure='{structure}'"
)
old_box, old_pos = _find_minimal_cell(old_box, old_pos)
old_pos = old_pos @ old_box
n_old = old_pos.shape[0]
total = n_old * nx * ny * nz * 3
new_pos = np.zeros(total, dtype=np.float64)
_repeat_cell.repeat_cell(new_pos, old_box, old_pos, nx, ny, nz)
new_pos = new_pos.reshape((-1, 3))
new_box = old_box * np.array([nx, ny, nz]).reshape((3, 1))
data = pl.from_numpy(new_pos, schema=["x", "y", "z"]).with_columns(
pl.lit(name).alias("element")
)
return System(data=data, box=Box(new_box))
[docs]
def build_hea(
element_list: Tuple[str],
element_ratio: Tuple[float],
structure: str,
a: float,
miller1: Optional[Tuple[int, int, int]] = None,
miller2: Optional[Tuple[int, int, int]] = None,
miller3: Optional[Tuple[int, int, int]] = None,
nx: int = 1,
ny: int = 1,
nz: int = 1,
c_over_a: float = np.sqrt(8 / 3),
random_seed: Optional[int] = None,
) -> System:
"""
Build a high-entropy alloy (HEA) crystal structure with an optional orientation
defined by Miller indices.
Parameters
----------
element_list : Tuple[str]
List of element symbols, e.g., ``('Cu', 'Al', 'Mg')``.
element_ratio : Tuple[float]
Corresponding atomic ratios. The sum must equal 1.
structure : str
Crystal structure type. Supported values are:
- ``'fcc'`` — Face-centered cubic
- ``'bcc'`` — Body-centered cubic
- ``'hcp'`` — Hexagonal close-packed
a : float
Lattice constant in Ångströms.
- For cubic structures (FCC/BCC): edge length of the cubic unit cell.
- For HCP: basal plane lattice parameter.
miller1, miller2, miller3 : tuple of int, optional
Miller indices ``(h, k, l)`` defining the orientation of x-, y-, and z-axes.
Only applicable to cubic structures.
If any are ``None``, the standard orthogonal orientation ([1,0,0], [0,1,0], [0,0,1]) is used.
.. note::
The three Miller indices must be orthogonal and follow the right-hand rule:
``miller1 × miller2 = miller3``.
nx, ny, nz : int, default=1
Number of unit cell repetitions along x, y, and z directions.
c_over_a : float, default=sqrt(8/3)
The ``c/a`` ratio for the HCP structure.
The default corresponds to the ideal HCP packing.
Ignored for other structures.
random_seed : int, optional
Random seed for reproducible element assignment.
Returns
-------
System
A :class:`System` object containing the generated crystal structure
with atomic positions and simulation box information.
Examples
--------
>>> system = build_hea(
... ("Cr", "Co", "Ni"),
... (0.1, 0.2, 0.7),
... "fcc",
... 3.526,
... nx=5,
... ny=5,
... nz=5,
... random_seed=1,
... )
"""
assert structure.lower() in {"fcc", "bcc", "hcp"}, (
f"Unsupported structure '{structure}'. Choose from 'fcc', 'bcc', or 'hcp'."
)
if miller1 is not None and miller2 is not None and miller3 is not None:
assert structure.lower() != "hcp", "hcp does not support miller orientations."
# --- Build base crystal ---
system = build_crystal(
"X", structure, a, miller1, miller2, miller3, nx, ny, nz, c_over_a
)
return build_hea_fromsystem(system, element_list, element_ratio, random_seed)
[docs]
def build_hea_fromsystem(
system: System,
element_list: Tuple[str],
element_ratio: Tuple[float],
random_seed: Optional[int] = None,
) -> System:
"""Generate element per atom for a system,
Parameters
----------
system : System
System need to be modified.
element_list : Tuple[str]
List of element symbols, e.g., ``('Cu', 'Al', 'Mg')``.
element_ratio : Tuple[float]
Corresponding atomic ratios. The sum must equal 1.
random_seed : int, optional
Random seed for reproducible element assignment.
Returns
-------
System
A :class:`System` object containing the generated element information.
"""
# --- Input validation ---
assert len(element_list) > 1, "At least two elements are required to form an HEA."
assert len(set(element_list)) == len(element_list), (
"Each element in element_list must be unique."
)
assert len(element_list) == len(element_ratio), (
"element_list and element_ratio must have the same length."
)
assert abs(np.sum(element_ratio) - 1.0) < 1e-6, (
f"Element ratios must sum to 1 (got {np.sum(element_ratio):.6f})."
)
# --- Assign elements by ratio ---
type_counts = np.floor(system.N * np.array(element_ratio)).astype(int)
for i in range(len(element_ratio)):
if type_counts[i] == 0 and element_ratio[i] > 1e-6:
type_counts[i] += 1
type_counts[-1] = system.N - type_counts[:-1].sum()
element_array = np.repeat(element_list, type_counts)
if random_seed is not None:
np.random.seed(int(random_seed))
np.random.shuffle(element_array)
system.update_data(system.data.with_columns(element=element_array))
return system
[docs]
def build_partial_dislocation_fcc(
name: str,
a: float,
nx: int,
ny: int,
nz: int,
element_list: Optional[Tuple[str]] = None,
element_ratio: Optional[Tuple[float]] = None,
random_seed: Optional[int] = None,
) -> System:
"""Generate a FCC strcuture with a pair partial dislocations along y axis.
The crystalline orientation is: x->[1-10], y->[11-2], z->[111]. If `element_list` and
`element_ratio` are given, it will generate a HEA.
Similar to Construct a dislocation by superimposing two crystals in atomsk: https://atomsk.univ-lille.fr/tutorial_Al_edge.php
Parameters
----------
name : str
Element symbol (e.g., 'Cu', 'Al', 'Mg', 'C').
a : float
Lattice constant in Angstroms.
nx : int, default=1
Number of repetitions along x-axis direction.
ny : int, default=1
Number of repetitions along y-axis direction.
nz : int, default=1
Number of repetitions along z-axis direction.
element_list : Tuple[str], optional
List of element symbols, e.g., ``('Cu', 'Al', 'Mg')``.
element_ratio : Tuple[float], optional
Corresponding atomic ratios. The sum must equal 1.
random_seed : int, optional
Random seed for reproducible element assignment.
Returns
-------
System
A :class:`System` object containing the dislocations.
"""
upper = build_crystal(
name,
"fcc",
a,
miller1=[1, -1, 0],
miller2=[1, 1, -2],
miller3=[1, 1, 1],
nx=nx,
ny=ny,
nz=nz,
)
lower = build_crystal(
name,
"fcc",
a,
miller1=[1, -1, 0],
miller2=[1, 1, -2],
miller3=[1, 1, 1],
nx=nx + 1,
ny=ny,
nz=nz,
)
box = upper.box.box.copy()
box[0, 0] = box[0, 0] * (1 + (0.5 / nx))
upper.update_box(box, scale_pos=True)
box = lower.box.box.copy()
box[0, 0] = box[0, 0] * (1 - (0.5 / (nx + 1)))
lower.update_box(box, scale_pos=True)
data = pl.concat(
[upper.data.with_columns(pl.col("z") + lower.box.box[2, 2]), lower.data]
)
box = lower.box.box.copy()
box[2, 2] += upper.box.box[2, 2]
system = System(data=data, box=box)
if element_list is not None and element_ratio is not None:
system = build_hea_fromsystem(system, element_list, element_ratio, random_seed)
return system
if __name__ == "__main__":
fcc = build_hea(
["Cr", "Co", "Fe", "Ni"],
[0, 0, 0, 1],
"fcc",
3.53,
nx=3,
ny=3,
nz=3,
random_seed=1,
)
print(fcc)