Source code for radtools.crystal.crystal

from math import floor, log10, sqrt
from typing import Union

import numpy as np

from radtools.crystal.atom import Atom
from radtools.crystal.bravais_lattice import bravais_lattice_from_cell
from radtools.crystal.lattice import Lattice
from radtools.crystal.properties import dipole_dipole_energy, dipole_dipole_interaction
from radtools.routines import absolute_to_relative


[docs] class Crystal: r""" Crystal class. Iterable over atoms. All attributes of the :py:class:`.Lattice` are accessible directly from the crystal or from the lattice attribute, i.e. the following lines are equivalent: .. doctest:: >>> import radtools as rad >>> cub = rad.lattice_example("CUB") >>> crystal = rad.Crystal(cub) >>> crystal.lattice.pearson_symbol 'cP' >>> crystal.pearson_symbol 'cP' For the full description of the lattice attributes and methods see :ref:`rad-tools_lattice`. Parameters ---------- lattice : :py:class:`.Lattice`, optional Lattice of the crystal. If not provided, then orthonormal lattice is used ("CUB with :math:`a = 1`). atoms : list, optional List of :py:class:`Atom` objects. Attributes ---------- atoms : list List of atoms of the crystal. """ def __init__(self, lattice: Lattice = None, atoms=None) -> None: self._lattice = None self.atoms = [] if lattice is None: lattice = Lattice([1, 0, 0], [0, 1, 0], [0, 0, 1]) self.lattice = lattice if atoms is not None: for a in atoms: self.add_atom(a) @property def cell(self): r""" Cell of the lattice. Main difference of the cell attribute of the crystal from the cell attribute of the lattice is that change of the crystal`s cell leads to the computation of the atom coordinate. Returns ------- cell : (3, 3) :numpy:`ndarray` Cell of the crystal`s lattice. Examples -------- .. doctest:: >>> import radtools as rad >>> c = rad.Crystal() >>> c.add_atom(rad.Atom("Cr", (0.5, 0.5, 0.5))) >>> c.cell array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) >>> c.atoms[0].position array([0.5, 0.5, 0.5]) >>> c.lattice.cell = [[2,0,0],[0,2,0],[0,0,2]] >>> c.cell array([[2, 0, 0], [0, 2, 0], [0, 0, 2]]) >>> c.atoms[0].position array([0.5, 0.5, 0.5]) >>> c.lattice.cell = [[1,0,0],[0,1,0],[0,0,1]] >>> c.cell = [[2,0,0],[0,2,0],[0,0,2]] >>> c.cell array([[2, 0, 0], [0, 2, 0], [0, 0, 2]]) >>> c.atoms[0].position array([1., 1., 1.]) """ return self.lattice.cell @cell.setter def cell(self, new_cell): old_cell = self.cell self.lattice.cell = new_cell for atom in self: relative = absolute_to_relative(old_cell, atom.position) atom.position = relative @ self.cell def __iter__(self): return CrystalIterator(self) def __contains__(self, atom: Union[Atom, str]): if isinstance(atom, str): try: atom = self.get_atom(atom, return_all=True) return True except ValueError: return False return atom in self.atoms def __len__(self): return self.atoms.__len__() def __getitem__(self, name) -> Atom: return self.get_atom(name, return_all=True) def __getattr__(self, name): # Fix copy/deepcopy RecursionError if name in ["__setstate__"]: raise AttributeError(name) try: atom = self.get_atom(name=name) return atom except ValueError: if "__" in name: raise AttributeError(f"'Crystal' object has no attribute '{name}'") return getattr(self.lattice, name) @property def lattice(self): r""" Lattice of the crystal. See :ref:`rad-tools_lattice` for details. Returns ------- lattice : :py:class:`.Lattice` Lattice of the crystal. """ return self._lattice @lattice.setter def lattice(self, new_lattice: Lattice): if not isinstance(new_lattice, Lattice): raise TypeError( "New lattice is not a lattice. " + f"Received {type(new_lattice)}." ) self._lattice = new_lattice
[docs] def add_atom(self, new_atom: Atom, relative=False): r""" Add atom to the crystall. If ``new_atom```s literal and index are the same as of some atom of the crystal, then ``new_atom`` is not added. If index of ``new_atom`` is not defined, it is set. Parameters ---------- new_atoms : :py:class:`.Atom` New atom. relative : bool, default False Whether ``new_atom`` position is in relative coordinates. Raises ------ TypeError If ``new_atom`` is not an :py:class:`.Atom`. ValueError If the atom is already present in the crystal. """ if not isinstance(new_atom, Atom): raise TypeError("New atom is not an Atom. " + f"Received {type(new_atom)}.") try: i = new_atom.index except ValueError: new_atom.index = len(self.atoms) + 1 if relative: new_atom.position = new_atom.position @ self.cell if new_atom not in self.atoms: self.atoms.append(new_atom) else: raise ValueError("Atom is already in the crystal.")
[docs] def remove_atom(self, atom: Union[Atom, str]): r""" Remove atom from the crystal. If type(``atom``) == ``str``, then all atoms with the name ``atom`` are removed. Parameters ---------- atom : :py:class:`.Atom` or str :py:class`.Atom` object or atom`s name. If name, then it has to be unique among atoms of the crystal. """ if isinstance(atom, str): atoms = self.get_atom(atom, return_all=True) else: atoms = [atom] for atom in atoms: self.atoms.remove(atom)
[docs] def get_atom(self, name, index=None, return_all=False): r""" Return atom object of the crystal. Notes ----- :py:attr:`.index` in combination with :py:attr:`.name` is supposed to be a unique value, however it uniqueness is not strictly checked, pay attention in custom cases. Parameters ---------- name : str Name of the atom. In general not unique. If ``name`` contains "__", then it is split into ``name`` and ``index``. index : int, optional Index of the atom. return_all : bool, default False Whether to return the list of non-unique matches or raise an ``ValueError``. Returns ------- atom : :py:class:`.Atom` or list If only one atom is found, then :py:class:`.Atom` object is returned. If several atoms are found and ``return_all`` is ``True``, then list of :py:class:`.Atom` objects is returned. Raises ------ ValueError If no match is found or the match is not unique and ``return_all`` is False. """ atoms = [] if "__" in name and index is None: name, index = name.split("__") index = int(index) for atom in self: if atom.name == name: if index is None or atom.index == index: atoms.append(atom) if len(atoms) == 0: raise ValueError(f"No match found for name = {name}, index = {index}") elif len(atoms) == 1: if return_all: return atoms return atoms[0] elif not return_all: raise ValueError( f"Multiple matches found for name = {name}, index = {index}" ) return atoms
[docs] def get_atom_coordinates(self, atom: Union[Atom, str], R=(0, 0, 0), relative=False): r""" Getter for the atom coordinates. Parameters ---------- atom : :py:class:`.Atom` or str :py:class`.Atom` object or atom`s name. If name, then it has to be unique among atoms of the crystal. R : (3,) |array_like|_, default (0, 0, 0) Radius vector of the unit cell for atom2 (i,j,k). relative : bool, default False Whether to return relative coordinates. Returns ------- coordinates : 1 x 3 array Coordinates of atom in the cell R in absolute coordinates. """ if isinstance(atom, str): atom = self.get_atom(atom) if atom not in self.atoms: raise ValueError(f"There is no {atom} in the crystal.") if relative: return np.array(R + absolute_to_relative(self.cell, atom.position)) return np.array(R) @ self.lattice.cell + atom.position
[docs] def get_vector( self, atom1: Union[Atom, str], atom2: Union[Atom, str], R=(0, 0, 0), relative=False, ): r""" Getter for vector between the atom1 and atom2. Parameters ---------- atom1 : :py:class:`.Atom` or str :py:class`.Atom` object or atom`s name in (0, 0, 0) unit cell. If name, then it has to be unique among atoms of the crystal. atom2 : :py:class:`.Atom` or str :py:class`.Atom` object or atom`s name in ``R`` unit cell. If name, then it has to be unique among atoms of the crystal. R : (3,) |array_like|_, default (0, 0, 0) Radius vector of the unit cell for atom2 (i,j,k). relative : bool, default False Whether to return the vector relative coordinates. Returns ------- v : (3,) :numpy:`ndarray` Vector from atom1 in (0,0,0) cell to atom2 in R cell. """ coord1 = self.get_atom_coordinates(atom1, relative=relative) coord2 = self.get_atom_coordinates(atom2, R, relative=relative) return coord2 - coord1
[docs] def get_distance( self, atom1: Union[Atom, str], atom2: Union[Atom, str], R=(0, 0, 0) ): r""" Getter for distance between the atom1 and atom2. Parameters ---------- atom1 : :py:class:`.Atom` or str :py:class`.Atom` object or atom`s name in (0, 0, 0) unit cell. If name, then it has to be unique among atoms of the crystal. atom2 : :py:class:`.Atom` or str :py:class`.Atom` object or atom`s name in ``R`` unit cell. If name, then it has to be unique among atoms of the crystal. R : (3,) |array_like|_, default (0, 0, 0) Radius vector of the unit cell for atom2 (i,j,k). Returns ------- distance : floats Distance between atom1 in (0,0,0) cell and atom2 in R cell. """ return sqrt(np.sum(self.get_vector(atom1, atom2, R) ** 2))
[docs] def find_primitive_cell(self): r""" Detect primitive cell. """ pass
[docs] def identify(self, find_primitive=True): r""" Identify Bravais lattice type. Parameters ---------- find_primitive : bool, default True Whether to find primitive cell before identification. """ # Define primitive cell if find_primitive: self.find_primitive_cell() self.lattice = bravais_lattice_from_cell(self.lattice.cell)
[docs] def mag_dipdip_energy(self, na, nb, nc, progress_bar=True): r""" Computes magnetic dipole-dipole energy. .. math:: E = -\frac{\mu_0}{4\pi}\sum_{i > j}\left(3(\vec{m_i} \cdot \vec{r_{ij}})(\vec{m_j} \cdot \vec{r_{ij}}) - (\vec{m_i}\cdot\vec{m_j})\right) Parameters ---------- na : int Translations along :py:attr:`.Crystal.a1`. nb : int Translations along :py:attr:`.Crystal.a2`. nc : int Translations along :py:attr:`.Crystal.a3`. progress_bar : bool, default True Whether to show progressbar. Returns ------- energy : float Dipole-dipole energy in meV. See Also -------- dipole_dipole_energy """ translations = ( np.transpose(np.indices((na, nb, nc)), (1, 2, 3, 0)).reshape( (na * nb * nc, 3) )
[docs] @ self.cell ) n_t = len(translations) magnetic_atoms = [] for atom in self: try: tmp = atom.magmom magnetic_atoms.append(atom) except ValueError: pass if len(magnetic_atoms) == 0: raise ValueError("There are no magnetic atoms in the crystal.") magnetic_centres = np.zeros((n_t * len(magnetic_atoms), 2, 3), dtype=float) for a_i, atom in enumerate(magnetic_atoms): magnetic_centres[a_i * n_t : (a_i + 1) * n_t, 0] = np.tile( atom.magmom, (n_t, 1) ) magnetic_centres[a_i * n_t : (a_i + 1) * n_t, 1] = ( translations + atom.position ) return dipole_dipole_energy(magnetic_centres, progress_bar=progress_bar)
def converge_mag_dipdip_energy( self, start=(10, 10, 10), step=(10, 10, 10), eps=10e-3, progress_bar=True, verbose=False, ): r""" Converge magnetic dipole-dipole energy. Parameters ---------- start : tuple of 3 int, default (10, 10, 10) (na, nb, nc) starting values. step : tuple of 3 int, default (10, 10, 10) (na, nb, nc) step values. If 0, then no step is applied. eps : float, default 1e-3 Convergence parameter. progress_bar : bool, default False Whether to show progressbar. verbose : bool, default False Whether to print information about each step. Returns ------- energies : list List of (na,nb,nc) and energies for each step. .. code-block:: python energies = [((na, nb, nc), energy), ...] See Also -------- dipole_dipole_energy dipole_dipole_interaction """ na, nb, nc = start da, db, dc = step translations = ( np.transpose(np.indices((na, nb, nc)), (1, 2, 3, 0)).reshape( (na * nb * nc, 3) ) @ self.cell ) magnetic_atoms = [] for atom in self: try: tmp = atom.magmom magnetic_atoms.append(atom) except ValueError: pass n_atoms = len(magnetic_atoms) n_t = len(translations) mc1 = np.zeros((n_t * n_atoms, 2, 3), dtype=float) for a_i, atom in enumerate(magnetic_atoms): mc1[a_i * n_t : (a_i + 1) * n_t, 0] = np.tile(atom.magmom, (n_t, 1)) mc1[a_i * n_t : (a_i + 1) * n_t, 1] = translations + atom.position energy1 = dipole_dipole_energy(mc1, progress_bar=progress_bar, normalize=False) energies = [(start, energy1 / len(mc1))] if verbose: n_digits = abs(floor(log10(abs(eps)))) + 2 print( f"{'Size':^{6+len(str(na))+len(str(nb))+len(str(nc))}} {'Energy':^{n_digits+4}} {'Difference':^{n_digits+4}}" ) print( f"({na}, {nb}, {nc}) " + f"{energy1 /len(mc1):<{n_digits+4}.{n_digits}f}" ) if da == 0 and db == 0 and dc == 0: return energies difference = 10 * eps while difference > eps: translations = [] if dc != 0: for k in range(nc, nc + dc): for j in range(0, nb + db): for i in range(0, na + da): translations.append((i, j, k)) for k in range(0, nc): if db != 0: for j in range(nb, nb + db): for i in range(0, na + da): translations.append((i, j, k)) for j in range(0, nb): for i in range(na, na + da): translations.append((i, j, k)) translations = np.array(translations) @ self.cell n_t = len(translations) mc2 = np.zeros((n_t * n_atoms, 2, 3), dtype=float) for a_i, atom in enumerate(magnetic_atoms): mc2[a_i * n_t : (a_i + 1) * n_t, 0] = np.tile(atom.magmom, (n_t, 1)) mc2[a_i * n_t : (a_i + 1) * n_t, 1] = translations + atom.position energy2 = dipole_dipole_energy( mc2, progress_bar=progress_bar, normalize=False ) energy_inter = dipole_dipole_interaction( mc1, mc2, progress_bar=progress_bar, normalize=False ) new_energy = energy1 + energy_inter + energy2 energy1 = new_energy nc += dc nb += db na += da energies.append(((na, nb, nc), energy1 / (len(mc2) + len(mc1)))) difference = abs(energies[-2][1] - energies[-1][1]) if verbose: print( f"({na}, {nb}, {nc}) " + f"{energy1 / (len(mc2) +len(mc1)):<{n_digits+4}.{n_digits}f} " + f"{difference:<{n_digits+4}.{n_digits}f}" ) mc1 = np.concatenate((mc1, mc2)) return energies
class CrystalIterator: def __init__(self, crystal: Crystal) -> None: self._list = crystal.atoms self._index = 0 def __next__(self) -> Atom: if self._index < len(self._list): result = self._list[self._index] self._index += 1 return result raise StopIteration def __iter__(self): return self if __name__ == "__main__": crystal = Crystal() crystal.cell = [ [3.513012, 0.000000000, 0.000000000], [0.000000000, 4.752699, 0.000000000], [0.000000000, 0.000000000, 23.5428674], ] Cr1 = Atom( "Cr1", np.array((0.2500000000, 0.7500000000, 0.1616620796)) @ crystal.cell, ) Cr2 = Atom( "Cr2", np.array((0.7500000000, 0.2500000000, 0.0737774367)) @ crystal.cell, ) crystal.add_atom(Cr1) crystal.add_atom(Cr2) crystal.Cr1.magmom = [0, 0, 3] crystal.Cr2.magmom = [0, 0, 3] z10 = crystal.mag_dipdip_energy(10, 10, 1) z15 = crystal.mag_dipdip_energy(15, 15, 1) z20 = crystal.mag_dipdip_energy(20, 20, 1) print(z10, z15, z20, sep="\n") energies = crystal.converge_mag_dipdip_energy( (10, 10, 1), (5, 5, 0), eps=0.001, verbose=1, progress_bar=False ) for e in energies: print(f"{e[0]} ({e[1]})<------")