Source code for abtem.bloch.utils

from __future__ import annotations

import itertools
import warnings
from typing import Optional, Sequence

import numpy as np
import pandas as pd  # type: ignore
from ase import Atoms
from ase.cell import Cell
from numba import njit  # type: ignore

from abtem.core.backend import cp
from abtem.core.energy import energy2wavelength


[docs] def reciprocal_cell(cell: np.ndarray | Cell) -> np.ndarray: """ Calculate the reciprocal cell of a unit cell. Parameters ---------- cell : 3x3 numpy.ndarray The unit cell. Returns ------- 3x3 numpy.ndarray The reciprocal cell. """ return np.linalg.pinv(cell).transpose()
[docs] def calculate_g_vec(hkl: np.ndarray, cell: np.ndarray | Cell) -> np.ndarray: g_vec: np.ndarray = hkl @ reciprocal_cell(cell) return g_vec
[docs] def calculate_g_vec_length(hkl: np.ndarray, cell: np.ndarray | Cell) -> np.ndarray: return np.linalg.norm(calculate_g_vec(hkl, cell), axis=-1)
[docs] def hkl_strings_to_array(hkl: list[str]) -> np.ndarray: return np.array([tuple(map(int, hkli.split(" "))) for hkli in hkl])
[docs] def generate_linear_combinations( vectors: np.ndarray, coefficients: Sequence[int], exclude_zero: bool = False ) -> np.ndarray: """ Generate all possible linear combinations of the given vectors with the given coefficients. Parameters ---------- vectors : numpy.array Array of vectors. coefficients : sequence of int Coefficients to use in the linear combinations. exclude_zero : bool, optional Whether to exclude the zero vector from the output. Returns ------- numpy.array Array of linear combinations. """ combinations = np.array( [ sum(c * v for c, v in zip(coef_comb, vectors)) for coef_comb in itertools.product(coefficients, repeat=len(vectors)) ] ) if exclude_zero: combinations = combinations[(combinations == 0).all(axis=1) == 0] return combinations
[docs] def get_shortest_g_vec_length(cell: Cell) -> float: """ Get the length of the shortest reciprocal space vector in the given unit cell. Parameters ---------- cell : Cell Unit cell. Returns ------- float Length of the shortest reciprocal space vector [1/Å]. """ coefficients = [-1, 0, 1] combinations = generate_linear_combinations( reciprocal_cell(cell), coefficients, exclude_zero=True ) return np.min(np.linalg.norm(combinations, axis=1))
[docs] def cell_bounds(cell: np.ndarray | Cell) -> np.ndarray: cell = np.array(cell) origin = np.zeros(3) vertices = np.array( [ origin, cell[0], cell[1], cell[2], cell[0] + cell[1], cell[0] + cell[2], cell[1] + cell[2], cell[0] + cell[1] + cell[2], ] ) min_bounds = np.min(vertices, axis=0) max_bounds = np.max(vertices, axis=0) return max_bounds - min_bounds
[docs] def reciprocal_space_gpts( cell: np.ndarray | Cell, g_max: float, ) -> tuple[int, int, int]: dk = 1 / cell_bounds(cell) gpts = ( int(np.ceil(g_max / dk[0])) * 2 + 1, int(np.ceil(g_max / dk[1])) * 2 + 1, int(np.ceil(g_max / dk[2])) * 2 + 1, ) return gpts
[docs] def make_hkl_grid( cell: np.ndarray | Cell, g_max: float, axes: tuple[int, ...] = (0, 1, 2), ) -> np.ndarray: gpts = reciprocal_space_gpts(cell, g_max) freqs = tuple(np.fft.fftfreq(n, d=1 / n).astype(int) for n in gpts) freqs = tuple(freqs[axis] for axis in axes) hkl_grids = np.meshgrid(*freqs, indexing="ij") hkl = np.stack(hkl_grids, axis=-1) hkl = hkl.reshape((-1, len(axes))) g_vec = calculate_g_vec(hkl, cell) hkl = hkl[(g_vec**2).sum(-1) <= g_max**2] return hkl
[docs] def excitation_errors( g: np.ndarray, energy: float, use_wave_eq: bool = False ) -> np.ndarray: """ Calculate excitation errors for a set of reciprocal space vectors. Parameters ---------- g : numpy.ndarray Reciprocal space vectors [1/Å], as an array of shape (N, 3). energy : float Electron energy [eV]. use_wave_eq : bool, optional Whether to use the excitation errors derived from the wave equation. Default is False. Returns ------- numpy.ndarray Excitation errors [1/Å]. """ assert g.shape[-1] == 3 wavelength = energy2wavelength(energy) if use_wave_eq: sg = (-2 * g[..., 2] - wavelength * (g[..., 0] ** 2 + g[..., 1] ** 2)) / 2.0 else: sg = (-2 * g[..., 2] - wavelength * np.sum(g * g, axis=-1)) / 2.0 return sg
[docs] def get_reflection_condition(hkl: np.ndarray, centering: str) -> np.ndarray: """ Returns a boolean mask indicating which reflections satisfy the reflection condition based on the given lattice centering. Parameters ---------- hkl : numpy.ndarray Array of shape (N, 3) representing the Miller indices of reflections. centering : str The lattice centering type. Must be one of "P", "I", "F", "A", "B", or "C". Returns ------- numpy.ndarray Boolean mask indicating which reflections satisfy the reflection condition. """ if centering.lower() == "f": all_even = (hkl % 2 == 0).all(axis=1) all_odd = (hkl % 2 == 1).all(axis=1) return all_even + all_odd elif centering.lower() == "i": return hkl.sum(axis=1) % 2 == 0 elif centering.lower() == "a": return (hkl[:, [1, 2]].sum(axis=1) % 2 == 0).all(axis=1) elif centering.lower() == "b": return (hkl[:, [0, 2]].sum(axis=1) % 2 == 0).all(axis=1) elif centering.lower() == "c": return (hkl[:, [0, 1]].sum(axis=1) % 2 == 0).all(axis=1) elif centering.lower() == "p": return np.ones(len(hkl), dtype=bool) else: raise ValueError("Invalid crystal centering type.")
[docs] @njit(nogil=True) def fast_filter_excitation_errors( mask: np.ndarray, g: np.ndarray, orientation_matrices: np.ndarray, wavelength: float, sg_max: float, ) -> None: g_length_2 = g[:, 0] ** 2 + g[:, 1] ** 2 + g[:, 2] ** 2 b = 0.5 * wavelength * g_length_2 for i in range(len(orientation_matrices)): R = orientation_matrices[i] sg = -g[:, 0] * R[2, 0] - g[:, 1] * R[2, 1] - g[:, 2] * R[2, 2] - b mask += np.abs(sg) < sg_max
[docs] def filter_reciprocal_space_vectors( hkl: np.ndarray, cell: Cell, energy: float, sg_max: float, g_max: float, centering: str = "P", orientation_matrices: Optional[np.ndarray] = None, ) -> np.ndarray: """ Filter reciprocal space vectors based on excitation errors and reflection conditions. Parameters ---------- hkl : numpy.ndarray Reciprocal space vectors. cell : Cell Unit cell. energy : float Electron energy [eV]. sg_max : float Maximum excitation error [1/Å]. g_max : float Maximum scattering vector length [1/Å]. centering : str, optional Crystal centering must be one of 'P', 'I', 'A', 'B', 'C' or 'F'. Default is 'P'. orientation_matrices : numpy.ndarray, optional Orientation matrices for each crystallographic direction. Returns ------- numpy.ndarray Mask for the reciprocal space vectors. """ g = hkl @ reciprocal_cell(cell) g_length = np.linalg.norm(g, axis=-1) if orientation_matrices is None: mask = np.abs(excitation_errors(g, energy, use_wave_eq=False)) <= sg_max else: if len(orientation_matrices.shape) == 2: orientation_matrices = orientation_matrices[None] orientation_matrices = orientation_matrices.reshape((-1, 3, 3)) # if not len(orientation_matrices.shape) == 3: # raise ValueError( # "'orientation_matrices' must have shape (3, 3) or (n, 3, 3)" # ) mask = np.zeros(len(g), dtype=bool) fast_filter_excitation_errors( mask, g, orientation_matrices, energy2wavelength(energy), sg_max ) mask *= get_reflection_condition(hkl, centering) mask *= g_length <= g_max return mask
[docs] def ravel_hkl(hkl: np.ndarray, gpts: tuple[int, int, int]) -> np.ndarray: hkl = np.asarray(hkl) shift = np.array((gpts[0] // 2, gpts[1] // 2, gpts[2] // 2)) hkl = hkl + shift multi_index = (hkl[..., 0], hkl[..., 1], hkl[..., 2]) return np.ravel_multi_index(multi_index, gpts)
[docs] def retrieve_structure_factor_values( array: np.ndarray, hkl_source: np.ndarray, hkl_destination: np.ndarray, gpts: tuple[int, int, int], ) -> np.ndarray: """ Convert a raveled array to a 3D array with the shape of the structure factor. Parameters ---------- array : numpy.ndarray The raveled array. hkl_source : numpy.ndarray The reciprocal space vectors as Miller indices for the source array. hkl_destination : numpy.ndarray The reciprocal space vectors as Miller indices for the destination array. gpts : tuple of ints The number of grid points in the 3D structure factor. Returns ------- numpy.ndarray The 3D array. """ hkl_source = ravel_hkl(hkl_source, gpts) hkl_destination = ravel_hkl(hkl_destination, gpts) if cp is not None and isinstance(array, cp.ndarray): convert_to_numpy = True else: convert_to_numpy = False if convert_to_numpy: array = cp.asnumpy(array) df = pd.Series(array, index=hkl_source) array = df.loc[hkl_destination].to_numpy() if convert_to_numpy: array = cp.asarray(array) return array
[docs] def are_vectors_orthogonal(v1: np.ndarray, v2: np.ndarray, tol: float = 1e-9) -> bool: """ Check if two vectors are orthogonal within a given tolerance. Parameters ---------- v1 : numpy.ndarray The first vector. v2 : numpy.ndarray The second vector. tol : float The tolerance for floating-point comparison. Returns -------- bool True if the vectors are orthogonal within the given tolerance, False otherwise. """ dot_product = np.dot(v1, v2) return bool(np.isclose(dot_product, 0, atol=tol))
[docs] def check_orthogonality(vectors: np.ndarray, tol: float = 1e-9) -> bool: """ Check if three vectors are pairwise orthogonal within a given tolerance. Parameters ---------- vectors : numpy.ndarray A 2D array where each row is a vector. tol : float The tolerance for floating-point comparison. Returns -------- bool True if all pairs of vectors are orthogonal within the given tolerance, False otherwise. """ if vectors.shape[1] != 3: raise ValueError("Each vector must be 3-dimensional.") num_vectors = vectors.shape[0] for i in range(num_vectors): for j in range(i + 1, num_vectors): if not are_vectors_orthogonal(vectors[i], vectors[j], tol): return False return True
[docs] def relative_positions_for_centering() -> dict[str, np.ndarray]: """ Returns the relative positions for each lattice centering type. Returns -------- dict A dictionary where the keys are the centering types and the values are the relative positions. """ return { "F": 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], ] ), "I": np.array( [ [0.0, 0.0, 0.0], [0.5, 0.5, 0.5], ] ), "A": np.array( [ [0.0, 0.0, 0.0], [0.5, 0.0, 0.0], ] ), "B": np.array( [ [0.0, 0.0, 0.0], [0.0, 0.5, 0.0], ] ), "C": np.array( [ [0.0, 0.0, 0.0], [0.0, 0.0, 0.5], ] ), "P": np.array([[0.0, 0.0, 0.0]]), }
[docs] def wrapped_is_close(a, b): differences = (a[None] - b[:, None]) % 1.0 is_close_to_zero = np.isclose(differences, 0.0) is_close_to_one = np.isclose(differences, 1.0) position_is_close = is_close_to_zero | is_close_to_one return position_is_close
[docs] def all_positions_have_relative_periodic_pair( positions: np.ndarray, relative_positions: np.ndarray ) -> bool: """ Check if all positions have a relative periodic pair. Parameters ---------- positions : numpy.ndarray The positions to check. relative_positions : numpy.ndarray The possible relative shifts of each position. Returns -------- bool True if all positions have a relative periodic pair, False otherwise. """ basis_size = len(positions) / (len(relative_positions)) if not np.isclose(basis_size, np.round(basis_size), atol=1e-6): return False num_match_total = 0 for position in positions: shifted_position = (position + relative_positions) % 1.0 position_is_close = wrapped_is_close(shifted_position, positions) num_match_total += position_is_close.all(axis=2).sum() if num_match_total >= len(relative_positions) * len(positions): return True else: return False
[docs] def auto_detect_centering( atoms: Atoms, centerings_to_check: Optional[set] = None ) -> str: """ Automatically detect the lattice centering of a crystal structure. Parameters ---------- atoms : Atoms The crystal structure. centerings_to_check : set, optional The centering types to check. If None, all centering types are checked. Returns -------- str The detected lattice centering type. """ if centerings_to_check is None: centerings_to_check = set(relative_positions_for_centering().keys()) if "P" in centerings_to_check: centerings_to_check.remove("P") if not check_orthogonality(atoms.cell): centerings_to_check.remove("F") centerings_to_check.remove("I") if not check_orthogonality(atoms.cell[[0, 1]]): centerings_to_check.remove("A") if not check_orthogonality(atoms.cell[[0, 2]]): centerings_to_check.remove("B") if not check_orthogonality(atoms.cell[[1, 2]]): centerings_to_check.remove("C") positions = atoms.get_scaled_positions() relative_positions = relative_positions_for_centering() for number in np.unique(atoms.numbers): centerings_to_check = { centering for centering in centerings_to_check if all_positions_have_relative_periodic_pair( positions[atoms.numbers == number], relative_positions[centering] ) } if len(centerings_to_check) == 1: return next(iter(centerings_to_check)) elif len(centerings_to_check) == 0: return "P" else: warnings.warn( "Something went wrong with the centering detection" " using primitive. Set manually to mute warning." ) return "P"