Source code for radtools.crystal.identify

# RAD-tools - Sandbox (mainly condense matter plotting).
# Copyright (C) 2022-2024  Andrey Rybakov
#
# e-mail: anry@uv.es, web: rad-tools.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

r"""
Crystal/lattice identification.
"""

from math import acos, cos, floor, log10, sqrt

import numpy as np
from termcolor import cprint

import radtools.crystal.cell as Cell
from radtools.constants import TODEGREES, TORADIANS
from radtools.decorate.array import print_2d_array
from radtools.geometry import volume
from radtools.numerical import compare_numerically

__all__ = ["niggli", "lepage"]


[docs] def niggli( a=1, b=1, c=1, alpha=90, beta=90, gamma=90, eps_rel=1e-5, verbose=False, return_cell=False, max_iter=10000, ): r""" Computes Niggli matrix form. Parameters ---------- a : float, default 1 Length of the :math:`a_1` vector. b : float, default 1 Length of the :math:`a_2` vector. c : float, default 1 Length of the :math:`a_3` vector. alpha : float, default 90 Angle between vectors :math:`a_2` and :math:`a_3`. In degrees. beta : float, default 90 Angle between vectors :math:`a_1` and :math:`a_3`. In degrees. gamma : float, default 90 Angle between vectors :math:`a_1` and :math:`a_2`. In degrees. eps_rel : float, default 1e-5 Relative epsilon as defined in [2]_. verbose : bool, default False Whether to print the steps of an algorithm. return_cell : bool, default False Whether to return cell parameters instead of Niggli matrix form. max_iter : int, default 100000 Maximum number of iterations. Returns ------- result : (3,2) :numpy:`ndarray` Niggli matrix form as defined in [1]_: .. math:: \begin{pmatrix} A & B & C \\ \xi/2 & \eta/2 & \zeta/2 \end{pmatrix} If return_cell == True, then return Niggli cell: (a, b, c, alpha, beta, gamma). Raises ------ ValueError If the niggli cell is not found in ``max_iter`` iterations. ValueError If the provided cell`s volume is zero. References ---------- .. [1] Křivý, I. and Gruber, B., 1976. A unified algorithm for determining the reduced (Niggli) cell. Acta Crystallographica Section A: Crystal Physics, Diffraction, Theoretical and General Crystallography, 32(2), pp.297-298. .. [2] Grosse-Kunstleve, R.W., Sauter, N.K. and Adams, P.D., 2004. Numerically stable algorithms for the computation of reduced unit cells. Acta Crystallographica Section A: Foundations of Crystallography, 60(1), pp.1-6. Examples -------- Example from [1]_ (parameters are reproducing :math:`A=9`, :math:`B=27`, :math:`C=4`, :math:`\xi` = -5, :math:`\eta` = -4, :math:`\zeta = -22`): .. doctest:: >>> import radtools as rad >>> from radtools.constants import TODEGREES >>> from math import acos, sqrt >>> a = 3 >>> b = sqrt(27) >>> c = 2 >>> print(f"{a} {b:.3f} {c}") 3 5.196 2 >>> alpha = acos(-5 / 2 / b / c) * TODEGREES >>> beta = acos(-4 / 2 / a / c) * TODEGREES >>> gamma = acos(-22 / 2 / a / b) * TODEGREES >>> print(f"{alpha:.2f} {beta:.2f} {gamma:.2f}") 103.92 109.47 134.88 >>> niggli_matrix_form = rad.niggli(a, b, c, alpha, beta, gamma, verbose=True) #doctest: +NORMALIZE_WHITESPACE A B C xi eta zeta start: 9.00000 27.00000 4.00000 -5.00000 -4.00000 -22.00000 2 appl. to 9.00000 27.00000 4.00000 -5.00000 -4.00000 -22.00000 1 appl. to 9.00000 4.00000 27.00000 -5.00000 -22.00000 -4.00000 4 appl. to 4.00000 9.00000 27.00000 -22.00000 -5.00000 -4.00000 5 appl. to 4.00000 9.00000 27.00000 -22.00000 -5.00000 -4.00000 4 appl. to 4.00000 9.00000 14.00000 -4.00000 -9.00000 -4.00000 6 appl. to 4.00000 9.00000 14.00000 -4.00000 -9.00000 -4.00000 4 appl. to 4.00000 9.00000 9.00000 -8.00000 -1.00000 -4.00000 7 appl. to 4.00000 9.00000 9.00000 -8.00000 -1.00000 -4.00000 3 appl. to 4.00000 9.00000 9.00000 -9.00000 -1.00000 4.00000 5 appl. to 4.00000 9.00000 9.00000 9.00000 1.00000 4.00000 3 appl. to 4.00000 9.00000 9.00000 -9.00000 -3.00000 4.00000 result: 4.00000 9.00000 9.00000 9.00000 3.00000 4.00000 >>> niggli_matrix_form array([[4. , 9. , 9. ], [4.5, 1.5, 2. ]]) """ cell_volume = volume(a, b, c, alpha, beta, gamma) if cell_volume == 0: raise ValueError("Cell volume is zero") eps = eps_rel * volume(a, b, c, alpha, beta, gamma) ** (1 / 3.0) n = abs(floor(log10(abs(eps)))) # 0 A = a**2 B = b**2 C = c**2 xi = 2 * b * c * cos(alpha * TORADIANS) eta = 2 * a * c * cos(beta * TORADIANS) zeta = 2 * a * b * cos(gamma * TORADIANS) N = ( max( len(str(A).split(".")[0]), len(str(B).split(".")[0]), len(str(C).split(".")[0]), len(str(xi).split(".")[0]), len(str(eta).split(".")[0]), len(str(zeta).split(".")[0]), ) + 1 + n ) def summary_line(): return ( f"{A:{N}.{n}f} {B:{N}.{n}f} {C:{N}.{n}f} " + f"{xi:{N}.{n}f} {eta:{N}.{n}f} {zeta:{N}.{n}f}" ) if verbose: print( f" {'A':^{N}} {'B':^{N}} {'C':^{N}} " + f"{'xi':^{N}} {'eta':^{N}} {'zeta':^{N}}" ) cprint( f"start: {summary_line()}", color="yellow", ) phrase = "appl. to" iter_count = 0 while True: if iter_count > max_iter: raise ValueError(f"Niggli cell not found in {max_iter} iterations") iter_count += 1 # 1 if compare_numerically(A, ">", B, eps) or ( compare_numerically(A, "==", B, eps) and compare_numerically(abs(xi), ">", abs(eta), eps) ): if verbose: print(f"1 {phrase} {summary_line()}") A, xi, B, eta = B, eta, A, xi # 2 if compare_numerically(B, ">", C, eps) or ( compare_numerically(B, "==", C, eps) and compare_numerically(abs(eta), ">", abs(zeta), eps) ): if verbose: print(f"2 {phrase} {summary_line()}") B, eta, C, zeta = C, zeta, B, eta # go to 1 continue # 3 if compare_numerically(xi * eta * zeta, ">", 0, eps): if verbose: print(f"3 {phrase} {summary_line()}") xi, eta, zeta = abs(xi), abs(eta), abs(zeta) # 4 if compare_numerically(xi * eta * zeta, "<=", 0, eps): if verbose: print(f"4 {phrase} {summary_line()}") xi, eta, zeta = -abs(xi), -abs(eta), -abs(zeta) # 5 if ( compare_numerically(abs(xi), ">", B, eps) or ( compare_numerically(xi, "==", B, eps) and compare_numerically(2 * eta, "<", zeta, eps) ) or ( compare_numerically(xi, "==", -B, eps) and compare_numerically(zeta, "<", 0, eps) ) ): if verbose: print(f"5 {phrase} {summary_line()}") C = B + C - xi * np.sign(xi) eta = eta - zeta * np.sign(xi) xi = xi - 2 * B * np.sign(xi) # go to 1 continue # 6 if ( compare_numerically(abs(eta), ">", A, eps) or ( compare_numerically(eta, "==", A, eps) and compare_numerically(2 * xi, "<", zeta, eps) ) or ( compare_numerically(eta, "==", -A, eps) and compare_numerically(zeta, "<", 0, eps) ) ): if verbose: print(f"6 {phrase} {summary_line()}") C = A + C - eta * np.sign(eta) xi = xi - zeta * np.sign(eta) eta = eta - 2 * A * np.sign(eta) # go to 1 continue # 7 if ( compare_numerically(abs(zeta), ">", A, eps) or ( compare_numerically(zeta, "==", A, eps) and compare_numerically(2 * xi, "<", eta, eps) ) or ( compare_numerically(zeta, "==", -A, eps) and compare_numerically(eta, "<", 0, eps) ) ): if verbose: print(f"7 {phrase} {summary_line()}") B = A + B - zeta * np.sign(zeta) xi = xi - eta * np.sign(zeta) zeta = zeta - 2 * A * np.sign(zeta) # go to 1 continue # 8 if compare_numerically(xi + eta + zeta + A + B, "<", 0, eps) or ( compare_numerically(xi + eta + zeta + A + B, "==", 0, eps) and compare_numerically(2 * (A + eta) + zeta, ">", 0, eps) ): if verbose: print(f"8 {phrase} {summary_line()}") C = A + B + C + xi + eta + zeta xi = 2 * B + xi + zeta eta = 2 * A + eta + zeta # go to 1 continue break if verbose: cprint( f"result: {summary_line()}", color="green", ) if return_cell: a = sqrt(A) b = sqrt(B) c = sqrt(C) alpha = acos(xi / 2 / b / c) * TODEGREES beta = acos(eta / 2 / a / c) * TODEGREES gamma = acos(zeta / 2 / a / b) * TODEGREES return a, b, c, alpha, beta, gamma return np.array([[A, B, C], [xi / 2, eta / 2, zeta / 2]])
def check_cub(angles: np.ndarray, axes: np.ndarray, eps): target_angles = np.array( [ [0, 45, 45, 45, 45, 90, 90, 90, 90], [0, 45, 45, 45, 45, 90, 90, 90, 90], [0, 45, 45, 45, 45, 90, 90, 90, 90], [0, 45, 45, 60, 60, 60, 60, 90, 90], [0, 45, 45, 60, 60, 60, 60, 90, 90], [0, 45, 45, 60, 60, 60, 60, 90, 90], [0, 45, 45, 60, 60, 60, 60, 90, 90], [0, 45, 45, 60, 60, 60, 60, 90, 90], [0, 45, 45, 60, 60, 60, 60, 90, 90], ] ) conventional_axis = np.array([0, 45, 45, 45, 45, 90, 90, 90, 90]) axes = np.array([i[0] for i in axes]) if 9 <= angles.shape[0]: sub_angles = angles[:9, :9] sub_axes = axes[:9] if ( np.abs(np.sort(sub_angles.flatten()) - np.sort(target_angles.flatten())) < eps ).all(): xyz = [] for i in range(9): if (np.abs(np.sort(sub_angles[i]) - conventional_axis) < eps).all(): xyz.append(sub_axes[i]) det = np.abs(np.linalg.det(xyz)) if det == 1: result = "CUB" elif det == 4: result = "FCC" elif det == 2: result = "BCC" return result, False return None, True return None, True def check_hex(angles: np.ndarray, eps): target_angles = np.array( [ [0, 90, 90, 90, 90, 90, 90], [0, 30, 30, 60, 60, 90, 90], [0, 30, 30, 60, 60, 90, 90], [0, 30, 30, 60, 60, 90, 90], [0, 30, 30, 60, 60, 90, 90], [0, 30, 30, 60, 60, 90, 90], [0, 30, 30, 60, 60, 90, 90], ] ) angles = angles[:7] if 7 <= angles.shape[0]: sub_angles = angles[:7, :7] if ( np.abs(np.sort(sub_angles.flatten()) - np.sort(target_angles.flatten())) < eps ).all(): return "HEX", False return None, True return None, True def check_tet(angles: np.ndarray, axes: np.ndarray, eps, cell): target_angles = np.array( [ [0, 90, 90, 90, 90], [0, 45, 45, 90, 90], [0, 45, 45, 90, 90], [0, 45, 45, 90, 90], [0, 45, 45, 90, 90], ] ) conventional_axis = np.array([0, 90, 90, 90, 90]) axes = np.array([i[0] for i in axes]) angles = angles[:5] if 5 <= angles.shape[0]: sub_angles = angles[:5, :5] sub_axes = axes[:5] if ( np.abs(np.sort(sub_angles.flatten()) - np.sort(target_angles.flatten())) < eps ).all(): xy = [] for i in range(5): if (np.abs(np.sort(sub_angles[i]) - conventional_axis) < eps).all(): z = sub_axes[i] else: xy.append(sub_axes[i]) xy.sort(key=lambda x: np.linalg.norm(x @ cell)) xyz = [z, xy[0], xy[1]] det = np.abs(np.linalg.det(xyz)) if det == 1: result = "TET" elif det == 2: result = "BCT" return result, False return None, True return None, True def check_rhl(angles: np.ndarray, eps): target_angles = np.array( [ [0, 60, 60], [0, 60, 60], [0, 60, 60], ] ) angles = angles[:3] if 3 <= angles.shape[0]: sub_angles = angles[:3, :3] if ( np.abs(np.sort(sub_angles.flatten()) - np.sort(target_angles.flatten())) < eps ).all(): return "RHL", False return None, True return None, True def check_orc(angles: np.ndarray, axes: np.ndarray, eps): target_angles = np.array( [ [0, 90, 90], [0, 90, 90], [0, 90, 90], ] ) angles = angles[:3] axes = np.array([i[0] for i in axes]) if 3 <= angles.shape[0]: sub_angles = angles[:3, :3] sub_axes = axes[:3] if ( np.abs(np.sort(sub_angles.flatten()) - np.sort(target_angles.flatten())) < eps ).all(): C = np.array(sub_axes, dtype=float).T det = np.abs(np.linalg.det(C)) if det == 1: result = "ORC" if det == 4: result = "ORCF" if det == 2: v = C @ [1, 1, 1] def gcd(p, q): while q != 0: p, q = q, p % q return p if ( gcd(abs(v[0]), abs(v[1])) > 1 and gcd(abs(v[0]), abs(v[2])) > 1 and gcd(abs(v[1]), abs(v[2])) > 1 ): result = "ORCI" else: result = "ORCC" return result, False return None, True return None, True def get_perpendicular_shortest(v, cell, eps): perp_axes = [] miller_indices = (np.indices((3, 3, 3)) - 1).transpose((1, 2, 3, 0)).reshape(27, 3) for index in miller_indices: if (index != [0, 0, 0]).any(): if abs((index @ cell) @ (v @ cell)) < eps: perp_axes.append(index) perp_axes.sort(key=lambda x: np.linalg.norm(x @ cell)) # indices 0 and 2 (not 0 and 1), since v and -v are present in miller_indices return perp_axes[0], perp_axes[2] def check_mcl(angles: np.ndarray, axes: np.ndarray, eps, cell): axes = np.array([i[0] for i in axes]) angles = angles[:1] if 1 <= angles.shape[0]: b = axes[0] a, c = get_perpendicular_shortest(b, cell, eps) C = np.array( [ a, b, c, ], dtype=float, ).T det = np.abs(np.linalg.det(C)) if det == 1: return "MCL", False if det == 2: return "MCLC", False return None, True return None, True
[docs] def lepage( a=1, b=1, c=1, alpha=90, beta=90, gamma=90, eps_rel=1e-4, verbose=False, very_verbose=False, give_all_results=False, delta_max=0.01, ): r""" Le Page algorithm [1]_. Parameters ---------- a : float, default 1 Length of the :math:`a_1` vector. b : float, default 1 Length of the :math:`a_2` vector. c : float, default 1 Length of the :math:`a_3` vector. alpha : float, default 90 Angle between vectors :math:`a_2` and :math:`a_3`. In degrees. beta : float, default 90 Angle between vectors :math:`a_1` and :math:`a_3`. In degrees. gamma : float, default 90 Angle between vectors :math:`a_1` and :math:`a_2`. In degrees. eps_rel : float, default 1e-4 Relative epsilon. verbose : bool, default False Whether to print the steps of an algorithm. very_verbose : bool, default False Whether to print the detailed steps of an algorithm. give_all_results : bool, default False Whether to give the whole list of consecutive results. delta_max : float, default 0.01 Maximum angle tolerance, in degrees. Returns ------- result : str Bravais lattice type. If give_all_results == True, then return list of all consecutive results. References ---------- .. [1] Le Page, Y., 1982. The derivation of the axes of the conventional unit cell from the dimensions of the Buerger-reduced cell. Journal of Applied Crystallography, 15(3), pp.255-259. """ if very_verbose: verbose = True if volume(a, b, c, alpha, beta, gamma) == 0: raise ValueError("Cell volume is zero") limit = max(1.5, delta_max * 1.1) eps_volumetric = eps_rel * volume(a, b, c, alpha, beta, gamma) ** (1 / 3.0) decimals = abs(floor(log10(abs(eps_volumetric)))) if delta_max is None: delta_max = eps # Niggli reduction try: a, b, c, alpha, beta, gamma = niggli( a, b, c, alpha, beta, gamma, return_cell=True ) except: import warnings warnings.warn( "LePage algorithm: Niggli reduction failed, using input parameters", RuntimeWarning, ) cell = Cell.from_params(a, b, c, alpha, beta, gamma) rcell = Cell.reciprocal(cell) if very_verbose: print("Cell:") print_2d_array(cell, fmt=f"{4+decimals}.{1+decimals}f") print("Reciprocal cell:") print_2d_array(rcell, fmt=f"{4+decimals}.{1+decimals}f") # Find all axes with twins miller_indices = (np.indices((5, 5, 5)) - 2).transpose((1, 2, 3, 0)).reshape(125, 3) axes = [] for U in miller_indices: for h in miller_indices: if abs(U @ h) == 2: t = U @ cell tau = h @ rcell delta = ( np.arctan(np.linalg.norm(np.cross(t, tau)) / abs(t @ tau)) * TODEGREES ) if delta < limit: axes.append([U, t / np.linalg.norm(t), abs(U @ h), delta]) # Sort and filter axes.sort(key=lambda x: x[-1]) keep_index = np.ones(len(axes)) for i in range(len(axes)): if keep_index[i]: for j in range(i + 1, len(axes)): if ( (axes[i][0] == axes[j][0]).all() or (axes[i][0] == -axes[j][0]).all() or (axes[i][0] == 2 * axes[j][0]).all() or (axes[i][0] == 0.5 * axes[j][0]).all() ): keep_index[i] = 0 break new_axes = [] for i in range(len(axes)): if keep_index[i]: if set(axes[i][0]) == {0, 2}: axes[i][0] = axes[i][0] / 2 new_axes.append(axes[i]) axes = new_axes if very_verbose: print(f"Axes with delta < {limit}:") print(f" U {'delta':>{4+decimals}}") for ax in axes: print( f" ({ax[0][0]:2.0f} " + f"{ax[0][1]:2.0f} " + f"{ax[0][2]:2.0f}) " + f"{ax[-1]:{4+decimals}.{1+decimals}f}" ) # Compute angles matrix n = len(axes) angles = np.zeros((n, n), dtype=float) for i in range(n): for j in range(i, n): angles[i][j] = ( acos(abs(np.clip(np.array(axes[i][1]) @ np.array(axes[j][1]), -1, 1))) * TODEGREES ) angles += angles.T # Main check cycle delta = None separator = lambda x: "=" * 20 + f" Cycle {x} " + "=" * 20 cycle = 0 if give_all_results: results = [] while delta is None or delta > delta_max: if verbose: cycle += 1 print(separator(cycle)) try: delta = max(axes, key=lambda x: x[-1])[-1] except ValueError: delta = 0 eps = max(eps_volumetric, delta) decimals = abs(floor(log10(abs(eps)))) if very_verbose: decimals = abs(floor(log10(abs(eps)))) print( f"Epsilon = {eps:{4+decimals}.{1+decimals}f}\n" + f"delta = {delta:{4+decimals}.{1+decimals}f}" ) print("Axes:") print(f" U {'delta':>{4+decimals}}") for ax in axes: print( f" ({ax[0][0]:2.0f} " + f"{ax[0][1]:2.0f} " + f"{ax[0][2]:2.0f}) " + f"{ax[-1]:{4+decimals}.{1+decimals}f}" ) print("Angles:") print_2d_array(angles, fmt=f"{4+decimals}.{1+decimals}f") continue_search = True n = len(axes) result = None # CUB result, continue_search = check_cub(angles, axes, eps) # HEX if continue_search: result, continue_search = check_hex(angles, eps) # TET if continue_search: result, continue_search = check_tet(angles, axes, eps, cell) # RHL if continue_search: result, continue_search = check_rhl(angles, eps) # ORC if continue_search: result, continue_search = check_orc(angles, axes, eps) # MCL if continue_search: result, continue_search = check_mcl(angles, axes, eps, cell) # TRI if continue_search: result = "TRI" if verbose: print( f"System {result} with the worst delta = {delta:{4+decimals}.{1+decimals}f}" ) if len(axes) > 0: # remove worst axes while len(axes) >= 2 and axes[-1][-1] == axes[-2][-1]: axes = axes[:-1] angles = np.delete(angles, -1, -1)[:-1] axes = axes[:-1] angles = np.delete(angles, -1, -1)[:-1] if give_all_results: results.append((result, delta)) if give_all_results: return results return result