Source code for viperleed.calc.classes.slab.base_slab

"""Module base_slab of viperleed.calc.classes.slab.

Defines the BaseSlab class, useful to describe collections of atoms in
crystalline form. This is the abstract base class at the basis of both
BulkSlab and SurfaceSlab classes (for 3D- and 2D-periodic systems,
respectively), and contains generic functionality that is common to
both.
This module contains refactored and modified functionality that
used to be contained in the original slab module (2019-06-13) by
F. Kraushofer.
"""

__authors__ = (
    'Florian Kraushofer (@fkraushofer)',
    'Michele Riva (@michele-riva)',
    )
__copyright__ = 'Copyright (c) 2019-2024 ViPErLEED developers'
__created__ = '2023-02-21'
__license__ = 'GPLv3+'

from abc import abstractmethod
from collections import Counter
import copy
import itertools
import logging
from numbers import Real
from operator import attrgetter, itemgetter

import numpy as np
from scipy.spatial.distance import cdist as euclid_distance

from viperleed.calc.classes.atom import Atom
from viperleed.calc.classes.atom_containers import AtomContainer, AtomList
from viperleed.calc.classes.layer import Layer, SubLayer
from viperleed.calc.classes.sitetype import Sitetype
from viperleed.calc.lib import leedbase
from viperleed.calc.lib.base import COLLAPSE_EPS
from viperleed.calc.lib.base import add_edges_and_corners
from viperleed.calc.lib.base import collapse
from viperleed.calc.lib.base import collapse_fractional
from viperleed.calc.lib.base import pairwise
from viperleed.calc.lib.base import parent_name
from viperleed.calc.lib.base import rotation_matrix_order

from .errors import AlreadyMinimalError
from .errors import EmptySlabError
from .errors import InvalidUnitCellError
from .errors import MissingElementsError
from .errors import MissingLayersError
from .errors import MissingSublayersError
from .errors import SlabError
from .errors import TooFewLayersError
from .utils import _left_handed, _z_distance


_LOGGER = logging.getLogger(parent_name(__name__))


# TODO: .cartpos[2] Issue #174
# TODO: too-many-instance-attributes
#   Perhaps there's a way to group attributes and methods
#   into some logical mix-in base classes?
# TODO: layer coordinates may not be up to date after we update_origin
#   This is also partly related to Issue #174. It would be solved if
#   we (i) use the same frame for atoms, layers, and slab, (ii) we
#   store topat_ori_z only right before a refcalc (with a dedicated
#   method. Perhaps save_refcalc_state), (iii) we remove all of the
#   instances of update_origin.
# TODO: a huge fraction of the time spent when dealing with slab symmetry
#       operations is actually taken up by calls to deepcopy. It would help
#       quite a bit to have a .light_copy(which_purpose) method that makes
#       appropriate copies of selected parts of the slab. Most of the times,
#       we only need ucell and some lightweight version of atlist (maybe as
#       a tuple of lightweight Atoms that do not hold references to this slab).
[docs]class BaseSlab(AtomContainer): """An abstract base class representing a solid. Contains unit cell, element information and atom coordinates. Also has a variety of convenience functions for manipulating and updating the atoms. Attributes ---------- ucell : np.array The unit cell as vectors a, b, c (columns) poscar_scaling : float The original scaling factor from POSCAR elements : tuple of str Element labels as read from POSCAR chemelem : set of str Chemical elements in the slab, including from `ELEMENT_MIX` n_per_elem : dict {str: int} The number of atoms per POSCAR element. atlist : AtomList List of all atoms in the slab. layers : tuple of Layer Each `layer` is a composite of sublayers, as in TensErLEED sublayers : tuple of SubLayer Each SubLayer contains atoms of equal element and Z coordinate sitelist : list of Sitetype List of distinct sites as Sitetype, storing information on vibration and concentration ucell_mod : list of tuples (str, numpy.ndarray) Stored modifications made to the unit cell; each is a tuple of (type, array), where type is 'lmul', 'rmul', 'add', or 'c_shift'. topat_ori_z : float Stores the original position of the topmost atom in Cartesian coordinates celltype : str # TODO: would be nicer with an Enum Unit-cell shape as string. Values: 'oblique', 'rhombic', 'rectangular', 'square', 'hexagonal' planegroup : str Symmetry group of the slab. May be reduced by the user relative to `foundplanegroup`. foundplanegroup : str Highest symmetry found. Doesn't get modified when user reduces symmetry manually. orisymplane : SymPlane Only stored if the `planegroup` is ambiguous as to which unit vector the symmetry plane at the origin is parallel to linklists : list of list of Atom List of lists of atoms which are linked by a symmetry operation """
[docs] def __init__(self): """Initialize instance.""" self.ucell = np.array([]) self.poscar_scaling = 1. self.chemelem = set() self.n_per_elem = {} self.atlist = AtomList() self.layers = () self.sublayers = () self.sitelist = [] self.ucell_mod = [] self.ucell_ori = np.array([]) self.topat_ori_z = None # TODO: SurfaceSlab only after we fix the .cartpos[2] -- Issue #174 self.celltype = 'unknown' self.planegroup = 'unknown' self.foundplanegroup = 'unknown' self.orisymplane = None self.linklists = [] self.bulkslab = None # Deleted in BulkSlab.__init__ # Remember the last value of the ELEMENT_MIX parameter that # was applied. Prevents repeated applications self._last_element_mix = None
def __contains__(self, item): """Return whether an atom, layer, site, ... is in this slab.""" if isinstance(item, Atom): return item in self.atlist if isinstance(item, SubLayer): return item in self.sublayers if isinstance(item, Layer): return item in self.layers if isinstance(item, Sitetype): return item in self.sitelist if isinstance(item, str): # Only chemical element? return item in self.chemelem return False def __iter__(self): """Return an iterator of Atoms in self.""" return iter(self.atlist) def __reversed__(self): """Return a reversed iterator over Atoms in self.""" return reversed(self.atlist) @property def ab_cell(self): """Return the 2D portion of the unit cell.""" try: return self.ucell[:2, :2] except IndexError: # Uninitialized raise InvalidUnitCellError( f'{type(self).__name__} has no unit cell defined' ) from None @property def angle_between_ucell_and_coord_sys(self): """Return angle between first unit-cell vector and coordinate system. Returns ------- angle : float Angle between first slab unit cell vector and Cartesian coordinate system in degrees. Raises ------ InvalidUnitCellError If this property is accessed before there is a unit cell defined. """ a_vec, *_ = self.ab_cell.T # NB: arctan2 requires (y, x) order return np.degrees(np.arctan2(a_vec[1], a_vec[0])) @property def bottom_atom(self): """Return the atom currently at the bottom of this slab.""" # Do it the most efficient way possible, i.e., with the # atom container that should have the fewest atoms. We # rely on the z-sorting of layers and sublayers. if self.sublayers: container = self.sublayers[-1] # TODO: this may not be right, as sublayers are also sorted by element for same-z! elif self.layers: container = self.layers[-1] else: container = self return min(container, key=lambda at: at.pos[2]) @property def bulk_atoms(self): """Return atoms in this Slab that belong to its bulk.""" return [at for at in self if at.is_bulk] @property def bulk_layers(self): """Return the layers of self that are bulk.""" return tuple(lay for lay in self.layers if lay.is_bulk) @property def c_vector(self): """Return the out-of-plane unit-cell vector.""" try: return self.ucell.T[2] except IndexError: # Uninitialized raise InvalidUnitCellError( f'{type(self).__name__} has no unit cell defined' ) from None @property def elements(self): """Return a tuple of elements in this slab, as originally read.""" return tuple(self.n_per_elem.keys()) @property def fewest_atoms_sublayer(self): """Return the sublayer with fewest atoms.""" if not self.sublayers: raise MissingSublayersError(f'{type(self).__name__} has ' 'no sublayers defined') return min(self.sublayers, key=attrgetter('n_atoms')) @property @abstractmethod def is_bulk(self): """Return whether this is a bulk slab.""" return False @property def interlayer_gaps(self): """Return the z distances between pairs of adjacent layers. Returns ------- distances : generator of floats When iterated over, it returns the z distances between the bottommost atom of the higher layer and the topmost atom of the lower one. Distances are in the same order as self.layers. This means that the first element is the gap between the topmost layer and the second layer from the top. Notice that there are self.n_layers - 1 items in `distances`. Raises ------ MissingLayersError If no layers are available. TooFewLayersError If only one layer is present. """ if not self.layers: raise MissingLayersError if self.n_layers == 1: raise TooFewLayersError(f'{type(self).__name__} ' 'has only one layer') return (lay_below.cartori[2] - lay_above.cartbotz # TODO: change when flipping .cartpos[2] -- Issue #174 for lay_above, lay_below in pairwise(self.layers)) @property def n_atoms(self): """Return the number of atoms in this slab.""" return self.atlist.n_atoms @property def n_layers(self): """Return the number of composite layers of this slab.""" return len(self.layers) @property def n_sublayers(self): """Return the number of sublayers in this slab.""" return len(self.sublayers) @property def non_bulk_layers(self): """Return a tuple of the non-bulk layers in this slab.""" return tuple(lay for lay in self.layers if not lay.is_bulk) @property def smallest_interlayer_gap(self): """Return the smallest z gap between two adjacent layers. Make sure to update_layer_coordinates() before. Returns ------- min_dist : float The smallest of the z distances between adjacent layers. Distances are calculated between the bottommost atom of the higher layer and the topmost atom of the lower one. Raises ------ MissingLayersError If no layers are available. TooFewLayersError If only one layer is present. """ return min(self.interlayer_gaps) @property def top_atom(self): """Return the atom currently at the bottom of this slab.""" # Do it the most efficient way possible, i.e., with the # atom container that should have the fewest atoms. We # rely on the z-sorting of layers and sublayers. if self.sublayers: container = self.sublayers[0] # TODO: this may not be right, as sublayers are also sorted by element for same-z! elif self.layers: container = self.layers[0] else: container = self return max(container, key=lambda at: at.pos[2]) @classmethod def from_slab(cls, other): """Return a `cls` instance with attributes deep-copied from `other`.""" if not isinstance(other, BaseSlab): raise TypeError(f'{cls.__name__}.from_slab: other is not a slab.') if other.__class__ is cls: return copy.deepcopy(other) instance = cls() memo = {id(other): instance} for attr, value in other.__dict__.items(): if not hasattr(instance, attr): # Skip attributes that do not belong to cls instances continue setattr(instance, attr, copy.deepcopy(value, memo)) return instance def _add_one_bulk_cell(self, bulk_layers, bulkc_par, # TODO: Issue #174 bulkc_par_atoms, bulkc_perp_atoms, new_atoms_start_index=None): """Add one bulk unit cell and return the new atoms. This method is intended to be used only internally in Slab objects. Use with_extra_bulk_units (for non-bulk slabs) or with_double_thickness (for bulk ones). Parameters ---------- bulk_layers : tuple of Layer The layers of the bottommost bulk cell to be repeated. bulkc_par : numpy.ndarray Component of the bulk repeat vector parallel to the c axis of the slab. This is used to expand the unit cell. Shape (3,). bulkc_par_atoms : numpy.ndarray Component of the bulk repeat vector parallel to the c axis, to be used for the Cartesian positions of the atoms. The difference with `bulkc_par` is only due to the fact that we store Cartesian coordinates of atoms with z going INTO THE SOLID, while the unit cell reference has z going TOWARD THE SURFACE. Shape (3,). bulkc_perp_atoms : numpy.ndarray Component of the bulk repeat vector perpendicular to the c axis, to be used for the Cartesian positions of the atoms. Shape (3,). new_atoms_start_index : int or None, optional The initial index to be used as num for the atoms that are added as a consequence of the duplication. Returns ------- new_atoms : list The atoms added to this slab. new_layers : tuple The Layer objects added to this slab. Raises ------ ValueError If `bulk_layers` is empty. """ if not bulk_layers: raise ValueError('_add_one_bulk_cell: no bulk_layers to add.') if new_atoms_start_index is None: new_atoms_start_index = self.n_atoms + 1 # The mess with needing three different components of the # bulk repeat vector comes from the fact that we want to # add atoms (and layers) below, but not shift the current # ones relative to the current unit cell. This is useful # because the 'old atoms' appear then at the same in-plane # position in, e.g., VESTA. self.c_vector[:] += bulkc_par # Expand unit cell new_atoms = [] new_layers = [] for layer in bulk_layers: # Add a new bulk layer and duplicate of all its atoms new_layer = Layer(self, self.n_layers, is_bulk=True) new_layers.append(new_layer) self.layers += (new_layer,) for atom in layer: new_atom = atom.duplicate(add_to_atlists=False, num=new_atoms_start_index) new_atoms_start_index += 1 new_layer.atlist.append(new_atom) new_atom.layer = new_layer self.atlist.append(new_atom) self.n_per_elem[new_atom.el] += 1 new_atoms.append(new_atom) # Shift new atoms only perpendicular to unit-cell c new_atom.cartpos -= bulkc_perp_atoms # Shift old atoms up along unit-cell c for old_atom in self: if old_atom not in new_atoms: old_atom.cartpos += bulkc_par_atoms # Finally, recalculate the positions of all layers self.update_layer_coordinates() return new_atoms, tuple(new_layers) def check_a_b_in_plane(self): """Raise InvalidUnitCellError if a, b have out-of-plane components.""" try: a_b_z_component = self.ucell[2, :2] except IndexError: raise InvalidUnitCellError(f'{type(self).__name__} has no ' 'unit cell defined') from None if any(a_b_z_component): _err = ('Unit cell a and b vectors must not ' 'have an out-of-surface (Z) component!') _LOGGER.error(_err) raise InvalidUnitCellError(_err) def clear_symmetry_and_ucell_history(self): """Set all symmetry information back to default values. This method also completely erases the history of unit-cell modifications, and sets the current unit cell as the 'original' one. This means that, if the unit cell was modified prior to a call to this method, the original unit cell **cannot** be recovered by a call to `revert_unit_cell`. Returns ------- None. """ self.ucell_mod = [] self.ucell_ori = self.ucell.copy() self.celltype = 'unknown' self.foundplanegroup = self.planegroup = 'unknown' self.orisymplane = None def _get_layer_cut_positions(self, rpars, bulk_cuts): """Return crystal-cut positions from rpars.LAYER_CUTS.""" # rpars.LAYER_CUTS is a 'list' of LayerCutTokens. Each either # .is_numeric (fractional positions) or .is_auto_cut (dc/dz). # We can collect the fractional ones immediately. cuts = [token.value for token in rpars.LAYER_CUTS if token.is_numeric] # The dc/dz need a bit of work. We use as cut # position the midpoints between atoms that are # further than the desired z-distance cut-off auto_cuts = (token for token in rpars.LAYER_CUTS if token.is_auto_cut) _c_to_z = self.c_vector[2] / np.linalg.norm(self.c_vector) atoms = sorted(self, key=lambda atom: atom.pos[2]) for token in auto_cuts: cutoff = token.value cutoff *= _c_to_z if token.is_auto_cut_dc else 1 try: lower, upper = token.get_bounds(bulk_cuts) except ValueError: # bulk_cuts > upper. This auto-cut is useless continue if lower < COLLAPSE_EPS: # Avoid mistreating atoms that are very close to c==0 lower = -2*COLLAPSE_EPS cuts.extend( sum(at.pos[2] for at in pair) / 2 for pair in pairwise(atoms) if (all(lower < at.pos[2] < upper for at in pair) and _z_distance(*pair) > cutoff) ) if bulk_cuts: cuts = [v for v in cuts if v > max(bulk_cuts) + 1e-6] + bulk_cuts cuts.sort() return cuts def create_layers(self, rpars, bulk_cuts=()): """Create a list of Layer objects based on `rpars`. After this call, the `layers` attribute contains a list of the layers created. If layers were already defined, they are overwritten. Notice that this method assumes that fractional coordinates of the atoms are **collapsed to the base cell**. Parameters ---------- rpars : Rparams The PARAMETERS to be used to create layers. The N_BULK_LAYERS and LAYER_CUTS attributes are used. bulk_cuts : Sequence, optional Sequence of fractional positions representing automatically detected bulk layer cuts. Default is an empty tuple. Returns ------- cuts : list Fractional coordinates (sorted) of the cuts performed. Notice that the number of layers created may be smaller than `len(cuts)`, as layers without atoms are deleted. """ self.check_a_b_in_plane() if self.is_bulk: bulk_cuts = () # Get a sorted list of fractional cut positions cuts = self._get_layer_cut_positions(rpars, bulk_cuts) n_cuts = len(cuts) # For checking empty layers later cuts_iter = iter(cuts) # Remember current sorting to re-sort at the end self.atlist.save_sorting() self.sort_by_z() # Bottommost atom first next_cut = next(cuts_iter, 1) newlayer = Layer(self, 0) # Layer numbers are fixed later self.layers = () for atom in self: while atom.pos[2] > next_cut: # May run multiple times if the user defined multiple # cuts between two atoms that would give empty layers next_cut = next(cuts_iter, 1) newlayer = Layer(self, 0) if newlayer not in self: newlayer.is_bulk = (self.is_bulk or self.n_layers < rpars.N_BULK_LAYERS) self.layers += (newlayer,) atom.layer = newlayer newlayer.atlist.append(atom) n_empty = (n_cuts + 1) - self.n_layers if n_empty: _plural = 's' if n_empty > 1 else '' _LOGGER.warning( f'Found {n_empty} layer{_plural} containing no atoms. ' f'Layer{_plural} will be deleted. Check LAYER_CUTS parameter.' ) rpars.setHaltingLevel(2) # Sort layers in the final order: top to bottom self.layers = tuple(reversed(self.layers)) for i, layer in enumerate(self.layers): layer.update_position() layer.num = i self.atlist.restore_sorting() return cuts def _get_sublayers_for_el(self, element, eps): """Yield SubLayer objects for atoms of `element` within `eps`.""" sublists = [[a for a in self if a.el == element]] # First, split at points where two atoms are more than eps apart i = 0 while i < len(sublists): atoms = sublists[i] for j, atom_pair in enumerate(pairwise(atoms)): if _z_distance(*atom_pair) > eps: sublists[i:i+1] = atoms[:j+1], atoms[j+1:] break i += 1 # Now go through again and split sublayers at greatest # interlayer distance, if they are too thick overall i = 0 while i < len(sublists): atoms = sublists[i] # pylint: disable-next=magic-value-comparison # The '2' if len(atoms) < 2 or _z_distance(atoms[0], atoms[-1]) <= eps: i += 1 continue distances = ((j, _z_distance(*atoms)) for j, atoms in enumerate(pairwise(atoms), start=1)) maxdistindex, _ = max(distances, key=itemgetter(1)) sublists[i:i+1] = atoms[:maxdistindex], atoms[maxdistindex:] # Finally create sublayers based on sublists for atoms in sublists: new_layer = SubLayer(self, 0) new_layer.atlist.extend(atoms) new_layer.cartbotz = atoms[0].cartpos[2] yield new_layer def create_sublayers(self, eps=0.001): """Assign the atoms in the slab to sublayers. Each sublayer contains only atoms with the same chemical element, and such that all atoms in the sublayer are closer than `eps` to one another in the z direction. After calling this method, `slab.atlist` is sorted along the z Cartesian coordinate (bottommost atom first), and the `slab.sublayers` attribute is up to date. Calling this method more than once erases previous sublayer lists. The sublayers created are stored in the following order: - Sublayers towards the top of the slab come earlier - Sublayers with the same z coordinate (within eps) are next to each other, sorted by alphabetical element order Parameters ---------- eps : float, optional Limit value for z difference to assign atoms to different sublayers. Default is 0.001. Raises ------ InvalidUnitCellError If the (a,b) unit-cell vectors have out-of-plane components EmptySlabError If there are no atoms in this slab. MissingElementsError If this slab has no elements. Normally, this means that `update_element_count` was not called yet. """ self.check_a_b_in_plane() if not self.n_atoms: raise EmptySlabError(f'{type(self).__name__} has no atoms') if not self.elements: raise MissingElementsError( f'{type(self).__name__} has no elements. ' 'Did you forget to update_element_count()?' ) self.sort_by_z() subl = [] for element in self.elements: subl.extend(self._get_sublayers_for_el(element, eps)) # subl is sorted element-first. Re-sort it as in the __doc__ sorted_sublayers = [] # Work with subl sorted from bottom to top, and pop the last # element each time (i.e., the topmost layer to be processed) subl.sort(key=attrgetter('cartbotz'), reverse=True) # TODO: Issue #174 while subl: same_z = [subl.pop()] # accumulate sublayers with same z this_z = same_z[0].cartbotz while subl: if abs(subl[-1].cartbotz - this_z) < eps: same_z.append(subl.pop()) else: break same_z.sort(key=attrgetter('element')) sorted_sublayers.extend(same_z) self.sublayers = tuple(sorted_sublayers) for i, layer in enumerate(self.sublayers): layer.num = i def collapse_cartesian_coordinates(self, update_origin=False): """Ensure all atoms are inside the unit cell. This method updates both Cartesian and fractional coordinates, using the current Cartesian coordinates as starting point. Parameters ---------- update_origin : bool, optional Whether the z component of the Cartesian origin should also be updated. Default is False. Returns ------- None. """ self.update_fractional_from_cartesian() self.collapse_fractional_coordinates() self.update_cartesian_from_fractional(update_origin=update_origin) def collapse_fractional_coordinates(self, releps=None): # TODO: would be nicer to have the public method update everything correctly, and rather make this a private one. USED ONLY IN SLABs and in iosearch """Ensure all atoms are inside the unit cell. Atoms whose fractional positions are outside the unit cell are 'back-folded' inside. Notice that calling this method DOES NOT UPDATE the Cartesian coordinates of the atoms. It must be preceded or followed by a call to `update_cartesian_from_fractional` to ensure that fractional and Cartesian coordinates are up to date. Parameters ---------- releps : float or Sequence or None, optional Fractional tolerance for collapsing coordinates. If not None, coordinates are collapsed to the (fractional) interval [-releps, 1-releps]. If a sequence, it should have three items, corresponding to the fractional tolerances along the three unit vectors. Default is None. Returns ------- None. See Also -------- collapse_cartesian_coordinates """ kwargs = {'in_place': True} if releps is not None: kwargs['eps'] = releps for atom in self: collapse_fractional(atom.pos, **kwargs) def full_update(self, rpars): """Update atoms and layers from `rpars`. This method is typically used after information of a slab is read from file (e.g., via `poscar.read`). The method ensures that all atoms are within the (0, 0) unit cell, then, if needed, uses the information from `rpars` to create layers and calculate Cartesian coordinates (absolute and per layer), and to update elements and sites. Notice that atom displacements are NOT cleared, unless rpars.fileLoaded['VIBROCC'] is True-thy. Parameters ---------- rpars : Rparams Information read from a PARAMETERS file. Returns ------- None. """ self.collapse_fractional_coordinates() self.update_cartesian_from_fractional() if not self.layers or len(self.bulk_layers) != rpars.N_BULK_LAYERS: self.create_layers(rpars) else: # It is only needed if there were layers already and # either (i) fractional coordinates had pos[2] out of # range, or (ii) topat_ori_z had not been calculated yet self.update_layer_coordinates() self._update_chem_elements(rpars) if not self.sitelist: self.initSites(rpars) if rpars.fileLoaded['VIBROCC']: for atom in self: atom.initDisp() @abstractmethod def get_bulk_repeat(self, rpars, only_z_distance=False): """Return the bulk repeat vector (with positive z). Notice that this method does **not attempt to identify an unknown bulk-repeat vector**. Parameters ---------- rpars : Rparams The PARAMETERS to be interpreted. only_z_distance : bool, optional Whether a distance in the direction perpendicular to the surface (i.e., not necessarily along the c axis) should be returned rather than a full vector. This is ignored if `rpars.BULK_REPEAT` is a vector. Default is False. Returns ------- bulk_repeat_vector : numpy.ndarray or float Bulk repeat vector pointing from the bulk to the surface, or its component along z. If `rpars.BULK_REPEAT` is a vector, a copy is returned. Otherwise, the vector is taken to be parallel to the c axis of this slab. Its length is calculated from a z distance (i.e., perpendicular to the surface). The z distance is taken as either the value of `rpars.BULK_REPEAT` (if it is not-None) or the z distance between the bottommost points of the lowest bulk and lowest non-bulk layers. """ def get_minimal_ab_cell(self, eps, epsz=None, warn_convention=False): """Check if there is a 2D unit cell smaller than the current one. Parameters ---------- eps : float Cartesian tolerance for in-plane comparisons (Angstrom) epsz : float or None, optional Cartesian tolerance for comparisons in the direction perpendicular to the surface. If not given on None, it is take equal to `eps`. Default is None. warn_convention : bool, optional If True, warnings are added to the current logger in case making the reduced unit cell stick to the conventions would result in a sub-optimal `SUPERLATTICE` matrix. Default is False. Returns ------- mincell : np.ndarray The reduced 2D unit cell. Notice that `mincell` is such that `a`, `b` = `mincell`, i.e., it is transposed with respect to `slab.ab_cell`. Raises ------ AlreadyMinimalError If the current 2D unit cell cannot be reduced. """ if epsz is None: epsz = eps # Create a test slab: C projected to Z self_copy = copy.deepcopy(self) self_copy.project_c_to_z() self_copy.sort_by_z() self_copy.create_sublayers(epsz) # Use the lowest-occupancy sublayer (the one # with fewest atoms of the same chemical element) lowocclayer = self_copy.fewest_atoms_sublayer n_atoms = lowocclayer.n_atoms if n_atoms < 2: # pylint: disable=magic-value-comparison # Cannot be smaller if there's only 1 atom raise AlreadyMinimalError( f'{type(self).__name__}.get_minimal_ab_cell: fewest-atom ' f'sublayer ({lowocclayer.num}) contains only one atom ' f'({lowocclayer.atlist[0]}).' ) # Create a list of candidate unit vectors as those connecting # atom pairs. Notice that it is enough to take any arbitrary # atom as a 'reference' as, if the unit cell can be reduced, # all atoms must have a 'copy'. We take the first one. plist = [at.cartpos[:2] for at in lowocclayer] candidate_unit_vectors = (p - plist[0] for p in plist[1:]) # TODO: since is_translation_symmetric is somewhat expensive, would it make sense to pre-filter the vectors keeping only the shortest ones among those parallel to one another? Perhaps it would even make sense to filter the candidates keeping only those that give an integer area reduction. candidate_unit_vectors = ( vec for vec in candidate_unit_vectors if self_copy.is_translation_symmetric(vec, eps) ) # Try to reduce the cell smaller, mincell = self._minimize_ab_area(self.ab_cell.T, n_atoms, candidate_unit_vectors, eps) if not smaller: raise AlreadyMinimalError( f'{type(self).__name__}.get_minimal_ab_cell: none ' f'of the translation vectors tested gave a smaller area' ) return self._reduce_mincell(mincell, eps, warn_convention) @staticmethod def _minimize_ab_area(ab_cell, n_atoms, candidate_unit_vectors, eps): """Reduce an ab_cell with n_atoms to the smallest area possible. Parameters ---------- ab_cell : numpy.ndarray The cell to be reduced n_atoms : int The number of atoms in ab_cell. Used to determine the maximum downscaling that can be expected. candidate_unit_vectors : Iterable 2D Vectors to be tested for reduction of ab_cell. eps : float Linear Cartesian tolerance to discern whether areas differ from one another. Its square is used for areas. Returns ------- smaller : bool Whether ab_cell could be reduced. mincell : numpy.ndarray The reduced unit cell. """ # Try to reduce the cell by using a pair of vectors from # [a, b, *candidate_unit_vectors]. Keep in mind that with # n_atoms, we cannot reduce the area by more than a factor # 1 / n_atoms (which would give one atom per reduced cell) ab_cell_area = abs(np.linalg.det(ab_cell)) smallest_area = ab_cell_area / n_atoms candidate_unit_vectors = itertools.chain(ab_cell, candidate_unit_vectors) candidate_ab_cells = itertools.combinations(candidate_unit_vectors, 2) candidate_ab_cells_and_areas = ((ab, abs(np.linalg.det(ab))) for ab in candidate_ab_cells) candidate_ab_cells_and_areas = ( (ab, area) for ab, area in candidate_ab_cells_and_areas if smallest_area < area + eps**2 < ab_cell_area ) # Finally, consider that the new unit cell area # must be an integer divisor of the current one candidate_ab_cells_and_area_ratios = ( (ab, ab_cell_area / area) for ab, area in candidate_ab_cells_and_areas ) acceptable_ab_cells_and_ratios = ( (ab, round(ratio)) for ab, ratio in candidate_ab_cells_and_area_ratios if abs(round(ratio) - ratio) < 1e-6 ) try: # Pick the one that realizes the largest area reduction mincell, _ = max(acceptable_ab_cells_and_ratios, key=itemgetter(1)) except ValueError: # No smaller cell return False, ab_cell return True, mincell @staticmethod def _reduce_mincell(mincell, eps, warn_convention): """Ensure `mincell` is Minkowski-reduced; enforce conventions.""" # Use Minkowski reduction to make mincell high symmetry mincell, *_ = leedbase.reduceUnitCell(mincell) # Cosmetic corrections if all(abs(np.diag(mincell)) < eps): # Swap a and b when matrix is off-diagonal mincell[[0, 1]] = mincell[[1, 0]] _is_diagonal = abs(mincell[1, 0]) < eps and abs(mincell[0, 1]) < eps if _is_diagonal: # If matrix is diagonal, make elements positive mincell = abs(mincell) # By convention, make the shorter vector the first one if np.linalg.norm(mincell[0]) > np.linalg.norm(mincell[1]) + eps: # If matrix is diagonal, DO NOT make it off-diagonal if not _is_diagonal: mincell = np.dot([[0, 1], [-1, 0]], mincell) elif _is_diagonal and warn_convention: _LOGGER.warning('The unit cell orientation does not follow ' 'standard convention: to keep SUPERLATTICE ' 'matrix diagonal, the first bulk vector must ' 'be larger than the second. Consider swapping ' 'the unit cell vectors.') # Finally, make sure it's right-handed if _left_handed(mincell): mincell = np.dot([[1, 0], [0, -1]], mincell) return mincell def initSites(self, rp): # BUG: No sites if called twice """Goes through the atom list and supplies them with appropriate SiteType objects, based on the SITE_DEF parameters from the supplied Rparams.""" atlist = self.atlist[:] # copy to not have any permanent changes atlist.sort(key=attrgetter('num')) sl = [] for el in rp.SITE_DEF: for sitename in rp.SITE_DEF[el]: newsite = Sitetype(el, sitename) sl.append(newsite) for i in rp.SITE_DEF[el][sitename]: try: if atlist[i-1].el != el: _LOGGER.warning( 'SITE_DEF tries to assign atom number ' + str(i) + ' as ' + el + ', but POSCAR has it ' 'as '+atlist[i-1].el+'. Atom will be skipped ' 'and left as default site type!') rp.setHaltingLevel(1) else: atlist[i-1].site = newsite except IndexError: _LOGGER.error('SITE_DEF: atom number out of bounds.') raise for el in self.elements: newsite = Sitetype(el, 'def') found = False for at in atlist: if at.el == el and at.site is None: at.site = newsite found = True if found: sl.append(newsite) for site in [s for s in sl if s.el in rp.ELEMENT_MIX]: site.mixedEls = rp.ELEMENT_MIX[site.el][:] self.sitelist = sl def is_equivalent(self, other, eps=0.001): """Return whether this slab is equivalent to another. Parameters ---------- other : Slab The other slab to compare to. eps : float, optional Tolerance on Cartesian atomic coordinates. Returns ------- equivalent : bool True if the Cartesian coordinates of all atoms match (with at least one other atom, if there are duplicates), False otherwise. Both slabs are copied and collapsed to the (0, 0) cell. This means that this slab is considered equivalent to other only if the atom positions differ by a translation that is a multiple of the unit-cell vectors. """ if not isinstance(other, BaseSlab): return False slabs = copy.deepcopy(self), copy.deepcopy(other) for slab in slabs: slab.collapse_cartesian_coordinates() # Always re-create sublayers as eps may differ slab.create_sublayers(eps) # Reorder sublayers by Z to then compare by index slab.sublayers = tuple(sorted(slab.sublayers, key=attrgetter('cartbotz'))) if slabs[0].n_sublayers != slabs[1].n_sublayers: return False ab_cell = self.ab_cell.T releps = eps / np.linalg.norm(ab_cell, axis=1) for this_lay, other_lay in zip(*(s.sublayers for s in slabs)): if (this_lay.n_atoms != other_lay.n_atoms or abs(this_lay.cartbotz - other_lay.cartbotz) > eps or this_lay.element != other_lay.element): return False # Prepare Cartesian coordinates for comparisons this_coords = np.array([at.cartpos[:2] for at in this_lay]) other_coords = np.array([at.cartpos[:2] for at in other_lay]) # Add extra atoms at edges/corners frac_coords = [at.pos[:2] for at in this_lay] this_coords, _ = add_edges_and_corners(this_coords, frac_coords, releps, ab_cell) # Finally compare interatomic distances distances = euclid_distance(other_coords, this_coords) if any(distances.min(axis=1) > eps): # Notice that we use axis=1 as axis=0 contains # this_coords, which has been expanded to add # atoms at edges/corners return False return True def project_c_to_z(self): """Make the c vector of the unit cell perpendicular to the surface. All atom coordinates are updated to fit the new basis. Returns ------- None. """ c_vec_xy = self.c_vector[:2] if any(c_vec_xy): # Non-zero components self.update_cartesian_from_fractional() c_vec_xy[:] = 0 self.collapse_cartesian_coordinates() # Also updates fractional def remove_duplicate_atoms(self, eps=1e-3, epsz=1e-3, other_slab=None): """Remove atoms with same positions and chemical element. Should this slab have `layers` defined, they are updated accordingly without recreating them. Sublayers are instead created from scratch. This method explicitly recalculates the correct number of atoms per each (POSCAR) element, but does not recalculate Atom.num values. Explicitly call self.update_atom_numbers() afterwards, if needed. Parameters ---------- eps : float, optional Cartesian tolerance (Angstrom) for in-plane equivalence. Default is 1e-3. epsz : float, optional Cartesian tolerance (Angstrom) for out-of-plane equivalence. Default is 1e-3. other_slab : Slab or None, optional If given and not None, atoms in `other_slab` that are removed from this slab are labelled as being duplicates of those that remain in this one. Atoms between the two slabs are compared by `num`. Default is None. Raises ------ SlabError If this slab has layers defined, but removing atoms led to an inconsistency of which atoms survived in self and which ones survived in its layers. """ try: other_slab_atlist = other_slab.atlist except AttributeError: other_slab_atlist = AtomList() # The easiest way to remove duplicates is going one sublayer # at a time, and check only the in-plane positions. Ensure we # have sublayers, and, for that, we also make sure that the # number-of-atoms-per-element is up to date. self.update_element_count() self.create_sublayers(epsz) new_atlist = AtomList() for sublayer in self.sublayers: i = 0 while i < sublayer.n_atoms: atom_i = sublayer.atlist[i] other_base_atom = other_slab_atlist.get(atom_i.num, None) j = i + 1 while j < sublayer.n_atoms: atom_j = sublayer.atlist[j] if not atom_i.is_same_xy(atom_j, eps=eps): j += 1 continue sublayer.atlist.pop(j) duplicate_atom = other_slab_atlist.get(atom_j.num, None) if duplicate_atom: duplicate_atom.duplicate_of = other_base_atom i += 1 new_atlist.extend(sublayer.atlist) self.atlist = new_atlist # Update number of atoms per element and sublayers again self.update_element_count() self.create_sublayers(epsz) # Finally, update the layers, without recreating them if not self.layers: return self._delete_removed_atoms_from_layers() def _delete_removed_atoms_from_layers(self): """Remove atoms not in self from layers. Keep only filled layers.""" # First remove the atoms that are not in self any longer for layer in self.layers: atoms = [at for at in layer if at in self] layer.atlist.clear() layer.atlist.extend(atoms) # Then keep only non-empty layers. Some layers may # be completely empty, e.g., when detecting bulk self.layers = tuple(layer for layer in self.layers if layer.n_atoms) for i, layer in enumerate(self.layers): layer.update_position() layer.num = i # Sanity check: we should now have the same atoms everywhere if set(self) != set(at for lay in self.layers for at in lay): raise SlabError( 'Inconsistent atoms in slab and its layers. Did you forget ' 'to appropriately create_layers and update rparams before?' ) def remove_vacuum_at_bottom(self, rpars): """Move all atoms along c to remove any vacuum at the bottom. This method **will update the origin of the z coordinates**. It should be called **before** a reference calculation. Parameters ---------- rpars : Rparams The current run parameters. Attributes read: SYMMETRY_EPS Returns ------- fractional_c_shift : float How much atoms have been moved down along c. Notes ----- The atoms are moved down slightly less than what would be necessary to bring the bottom-most atom(s) at c == 0 to avoid possible floating-point rounding issues when collapsing coordinates. This method does not check whether the coordinates are collapsed to the base cell. Make sure they are before calling this. """ # Skip movements if the bottom atom is closer to zero than eps eps = (rpars.SYMMETRY_EPS, rpars.SYMMETRY_EPS, rpars.SYMMETRY_EPS.z) releps = np.dot(eps, np.linalg.inv(self.ucell.T)) bottom_atom = self.bottom_atom if bottom_atom.pos[2] < releps[2]: return 0. delta_c = bottom_atom.pos[2] - 0.1 * releps[2] for atom in self: atom.pos[2] -= delta_c self.update_cartesian_from_fractional(update_origin=True) return delta_c def revert_unit_cell(self, restore_to=None): """Revert unit-cell and coordinate modifications. Notice that calling this method always collapses the atom coordinates to the base unit cell. This is independent of whether any unit-cell modification was actually reverted or not. This method will not revert the state of, e.g., layers. If an operation that can (potentially) change layers is reverted, the layers and sublayers attributes are cleared. Parameters ---------- restore_to : Sequence or None, optional If given and not None, keep the oldest `len(restore_to)` modifications of the unit cell and coordinates. Default is None. Raises ------- RuntimeError If any of the to-be-reverted operations stored in `slab.ucell_mod` has an unknown operation-type name. """ n_keep = 0 if restore_to is not None: n_keep = len(restore_to) self.update_cartesian_from_fractional() operations_to_undo = self.ucell_mod[n_keep:] for op_type, op_array in reversed(operations_to_undo): if op_type == 'add': frac_shift = np.dot(op_array, np.linalg.inv(self.ab_cell.T)) for atom in self: atom.translate_2d(-op_array, -frac_shift) elif op_type == 'c_shift': for atom in self: atom.pos[2] -= op_array collapse_fractional(atom.pos, in_place=True) self.update_cartesian_from_fractional(update_origin=True) self.layers = () # They're probably wrong self.sublayers = () # Probably wrong sorting elif op_type == 'lmul': self.ucell = np.dot(np.linalg.inv(op_array), self.ucell) elif op_type == 'rmul': self.ucell = np.dot(self.ucell, np.linalg.inv(op_array)) else: raise RuntimeError(f'Invalid unit-cell modification {op_type}') self.collapse_cartesian_coordinates() self.ucell_mod = self.ucell_mod[:n_keep] def sort_by_element(self): """Sort `slab.atlist` by element, preserving the element order.""" _map = {e: i for i, e in enumerate(self.elements)} try: self.atlist.sort(key=lambda atom: _map[atom.el]) except KeyError as missing_el: _err = ('Unexpected point encountered in ' f'{type(self).__name__}.sort_by_element: Could ' f'not find element {missing_el} in element list') _LOGGER.error(_err) raise SlabError( 'Perhaps you added some atoms and did not ' f'update_element_count()? elements={self.elements}' ) from missing_el def sort_by_z(self, bottom_to_top=True): """Sort `slab.atlist` by z coordinate.""" self.atlist.sort(key=lambda at: at.pos[2], reverse=not bottom_to_top) def sort_original(self): """Sort `slab.atlist` by original atom order from POSCAR.""" self.atlist.sort(key=attrgetter('num')) def update_atom_numbers(self): """Assign new incremental numbers to atoms in the slab. If a `bulkslab` is defined, also update the numbers there to keep the two consistent. This method is especially useful when atoms are added and/or removed from the slab. Returns ------- None. """ self.sort_original() self.sort_by_element() try: bulk_atoms = self.bulkslab.atlist except AttributeError: bulk_atoms = AtomList() for i, atom in enumerate(self, start=1): bulk_atom = bulk_atoms.get(atom.num, None) if bulk_atom: bulk_atom.num = i atom.num = i bulk_atoms.update_atoms_map() self.atlist.update_atoms_map() def update_cartesian_from_fractional(self, update_origin=False): # TODO: Issue #174 """Assign absolute Cartesian coordinates to all atoms. The frame of reference has (x, y) as defined by the a and b unit-cell vectors, z == 0 for the topmost atom and positive going down through the slab (from the surface into the solid). This method can be used, e.g., to (re)calculate all Cartesian coordinates when distortions have been applied to the unit cell (but atoms should retain their position relative to the modified cell). Notice that this method does not touch the atomic coordinates in this slab's `bulkslab`. Parameters ---------- update_origin : bool, optional Whether the z component of the Cartesian origin should be updated. If True, `topat_ori_z` is modified to be the current z coordinate of the topmost atom. This value is ignored if `topat_ori_z` was never initialized. Set this to True if the topmost atom is likely to have moved in z. Default is False. Returns ------- None. """ if update_origin or self.topat_ori_z is None: topat = max(self, key=lambda atom: atom.pos[2]) self.topat_ori_z = np.dot(self.ucell, topat.pos)[2] for atom in self: atom.cartpos = np.dot(self.ucell, atom.pos) atom.cartpos[2] = self.topat_ori_z - atom.cartpos[2] # TODO: Remove with Issue #174 def _update_chem_elements(self, rpars): # TODO: Why aren't we also taking into account ELEMENT_RENAME here? Issue #108 """Update elements based on the ELEMENT_MIX parameter. Issue warnings in case of a naming conflict. Parameters ---------- rpars : Rparams The PARAMETERS to be used. Only the ELEMENT_MIX attribute is used. Returns ------- None. """ if self._last_element_mix == rpars.ELEMENT_MIX: return # don't update if up to date # Check for overlapping element names overlapping = {e for mix in rpars.ELEMENT_MIX.values() for e in mix} overlapping &= set(self.elements) for element in overlapping: _LOGGER.warning( f'Element name {element} given in ELEMENT_MIX is ' 'also an element name in POSCAR. It is recommended ' 'you rename the element in the POSCAR file.' ) self.chemelem = set() for element in self.elements: new_els = rpars.ELEMENT_MIX.get(element, (element,)) self.chemelem.update(e.capitalize() for e in new_els) self._last_element_mix = copy.deepcopy(rpars.ELEMENT_MIX) def update_element_count(self): """Update the number of atoms per element.""" n_per_elem = Counter(at.el for at in self) # Keep original element order, if there were any elements elements = self.elements or n_per_elem self.n_per_elem = {el: n_per_elem[el] for el in elements if el in n_per_elem} try: self.bulkslab.update_element_count() except AttributeError: # BulkSlab or no bulkslab available pass def update_fractional_from_cartesian(self): """Calculate atoms' fractional coordinates from Cartesian ones. This method is commonly used when `ucell` is modified, and the atoms should retain their absolute Cartesian positions while recomputing their fractional coordinates relative to the new unit cell. Notice that this method does not touch the atomic coordinates in this slab's `bulkslab`. Returns ------- None. """ uci = np.linalg.inv(self.ucell) for atom in self: # Flip over the z Cartesian coordinate, as # we store it as 'positive going down' cartpos = atom.cartpos.copy() cartpos[2] = self.topat_ori_z - cartpos[2] # TODO: Remove when flipping .cartpos[2] -- Issue #174 atom.pos = np.dot(uci, cartpos) def update_layer_coordinates(self): """Update the Cartesian position of all `layers`.""" for layer in self.layers: layer.update_position() # ---------------------- TRANSFORMATIONS ------------------------ def apply_matrix_transformation(self, trafo_matrix): """Apply an orthogonal transformation to the unit cell and all atoms. The transformation is given as an orthogonal transformation matrix (O) which is applied to BOTH the unit cell and all Cartesian atomic coordinates. The unit cell (U, unit vectors as columns) is transformed to U' = O @ U. Atomic coordinates (v, as column vectors) are transformed to v' = O @ v. This transformation is essentially equivalent to a change of basis. This method differs from `rotate_atoms`, `mirror_atoms`, `rotate_unit_cell`, and `transform_unit_cell_2d` in that the former two only cause a transformation of the atoms but not of the unit cell, whereas the latter two transform the unit cell but not the atoms. Here both unit cell and atoms are transformed. If the transformation is an out-of-plane rotation/mirror (i.e., it changes the z components of unit vectors), layers, bulkslab, and sublayers are discarded and will need to be recalculated. Otherwise, the same coordinate transform is also applied to the `bulkslab`, if present. Parameters ---------- trafo_matrix : Sequence `trafo_matrix` must be an orthogonal 3-by-3 matrix. Contains the transformation matrix (O) describing the applied transformation. Raises ------ ValueError If `trafo_matrix` is not 3-by-3 or not orthogonal. Examples -------- Apply a rotation by 90 deg around the z axis to the unit cell (in positive direction, i.e. clockwise when looking along z) >>> theta = np.pi/2 >>> rot_mat = [[np.cos(theta), -np.sin(theta), 0], [np.sin(theta), np.cos(theta), 0], [0, 0, 1]] >>> slab.apply_matrix_transformation(rot_mat) """ trafo_matrix = np.asarray(trafo_matrix) if trafo_matrix.shape != (3, 3): raise ValueError('apply_matrix_transformation: ' 'not a 3-by-3 matrix') if not np.allclose(np.linalg.inv(trafo_matrix), trafo_matrix.T): raise ValueError('apply_matrix_transformation: matrix is not ' 'orthogonal. Consider using apply_scaling.') # Determine whether trafo_matrix will change # the z component of the unit vectors changes_z = not np.allclose(trafo_matrix[2], (0, 0, 1)) self.ucell = trafo_matrix.dot(self.ucell) self.ucell[abs(self.ucell) < 1e-5] = 0. self.update_cartesian_from_fractional(update_origin=changes_z) # Update also 'layers', sublayers and bulkslab: if the # transformation touched 'z' we invalidate everything if changes_z: self.layers = () self.sublayers = () if self.is_bulk: return if changes_z: self.bulkslab = None elif self.bulkslab: self.bulkslab.apply_matrix_transformation(trafo_matrix) def apply_scaling(self, *scaling): """Rescale the unit-cell vectors. This can be used to stretch/compress along unit cell vectors in order to change lattice constants in some direction or to apply an isotropic scaling in all directions. To apply other (orthogonal) transformations (e.g., rotation, flipping), use `apply_matrix_transformation`. The same scaling is also applied to `bulkslab`, if this slab has one. Parameters ---------- *scaling : Sequence If only one number, an isotropic scaling is applied to the unit cell and atom positions. If a sequence with three entries, the scaling will be applied along the unit-cell vectors in the given order. Returns ------- scaling_matrix : numpy.ndarray The matrix used for scaling the unit vectors. Raises ------ TypeError If `scaling` has neither 1 nor three elements, or any of the elements is not a number. ValueError If `scaling` would make the unit cell singular (i.e., reduce the length of a unit vector to zero) Examples ---------- Stretch the unit cell by a factor of 2 along a, b and c. This doubles the lattice constant and increases the volume 8-fold: >>> slab.apply_scaling(2) Compresses the unit cell by a factor of 3 along c: >>> slab.apply_scaling(1, 1, 1/3) """ if len(scaling) not in (1, 3): raise TypeError(f'{type(self).__name__}.apply_scaling: ' 'invalid number of arguments. Expected ' f'one or three, got {len(scaling)}') if not all(isinstance(s, Real) for s in scaling): raise TypeError(f'{type(self).__name__}.apply_scaling: ' f'invalid scaling factor. Expected one ' 'or three numbers') if len(scaling) == 1: scaling *= 3 if any(abs(s) < 1e-5 for s in scaling): raise ValueError(f'{type(self).__name__}.apply_scaling: cannot ' 'reduce unit vector(s) to zero length') # Apply to unit cell (basis). Notice the inverted order, # because the unit cell is stored with unit vectors as # columns (i.e., a = ucell[:, 0]) scaling_matrix = np.diag(scaling) self.ucell = self.ucell.dot(scaling_matrix) self.update_cartesian_from_fractional(update_origin=scaling[2] != 1) try: self.bulkslab.apply_scaling(*scaling) except AttributeError: pass return scaling_matrix def mirror_atoms(self, symplane, glide=False): """Apply a mirror or glide transform across `symplane`. Notice that the unit cell stays unchanged. Coordinates are collapsed to the base cell after transformation. Parameters ---------- symplane : SymPlane The mirror/glide plane. If `symplane.is_glide` a glide operation is performed rather than a mirror. glide : bool, optional Whether a translation should also be applied, to realize a glide-symmetry transformation. This is ignored if `symplane.is_glide`. Default is False. Returns ------- None. """ mirror = symplane.point_operation(n_dim=3) glide_vec = np.zeros(2) if symplane.is_glide or glide: glide_vec = symplane.par.dot(self.ab_cell.T) / 2 self._transform_atoms_2d(mirror, center=symplane.pos, shift=glide_vec) def rotate_atoms(self, order, axis=(0., 0.)): """Apply an `order`-fold 2D rotation around axis. Notice that the unit cell stays unchanged. Coordinates are collapsed to the base cell after transformation. Parameters ---------- order : int Order of rotation, as accepted by `rotation_matrix_order`. When looking down at the slab from vacuum, atoms will be rotated counter-clockwise by 2pi/`order` radians. axis : Sequence, optional In-plane Cartesian position of the rotation axis. Shape should be (2,). Default is (0, 0) (i.e., the origin). Returns ------- None. """ rotation = rotation_matrix_order(order, dim=3) self._transform_atoms_2d(rotation, center=axis, shift=0) def rotate_unit_cell(self, order, append_ucell_mod=True): """Rotate the unit cell around the origin. All atomic Cartesian coordinates are left unchanged. Note that this rotates the fractional atomic coordinates in the opposite direction as `rotate_atoms`. Parameters ---------- order : int Order of rotation. A rotation by 2pi/`order` radians around the z axis will be performed. append_ucell_mod : bool, optional Whether the rotation applied should be registered (to allow reverting). Default is True. Returns ------- None. """ transform = rotation_matrix_order(order, dim=3) self.transform_unit_cell_2d(transform, on_row_vectors=False, append_ucell_mod=append_ucell_mod) def _transform_atoms_2d(self, transform, center, shift): """Apply matrix `transform` at `center` to all atoms, then `shift`. Fractional coordinates are collapsed (in 2D) at the end. Parameters ---------- transform : Sequence Shape (3, 3). 2D transformation matrix. Will be applied to the left to the Cartesian coordinates of each atom, taken as a column vector. center : Sequence Shape (2,). Atoms are translated to `center` before applying `transform`, then translated back. shift : float or Sequence Extra translation to be applied to all atoms at the end of the centred transform `transform`. If a sequence, should have shape (2,). Returns ------- None. """ # All the transformations are done on the in-plane # coordinates only. Make sure we have the out-of-plane # right as well. if self.topat_ori_z is None: self.update_cartesian_from_fractional() # Let's work with fractional coordinates, as we can # collapse them. Also, we will work with coordinates # as row vectors. Cartesians transform as: # c' = (c - center) @ T + center + shift # = c @ T + center @ (I - T) + shift # and # c = p @ u_cell, or p = c @ u_inv # Thus # p' = c' @ u_inv = p @ (u_cell @ T @ u_inv) + frac_offset # where # frac_offset = (center @ (I - T) + shift) @ u_inv # Important note: All the operations above are in principle # meant to be done using the full 3D unit cells, fractional # and Cartesian positions. However we can stick to using only # the in-plane components for the frac_offset, as both center # and shift are only in 2D. ab_inv = np.linalg.inv(self.ab_cell.T) transform = transform.T # Coordinates as row vectors frac_offset = np.dot(center, np.identity(2) - transform[:2, :2]) frac_offset += shift frac_offset = frac_offset.dot(ab_inv) # Now the part that needs to be done in 3D. This is critical # for systems that have c axis not orthogonal to the surface, # as both the frac_transform and the fractional-to-Cartesian # conversions mix the third component into the in-plane ones. ucell = self.ucell.T frac_transform = ucell.dot(transform).dot(np.linalg.inv(ucell)) for atom in self: atom.pos[:2] = atom.pos.dot(frac_transform)[:2] + frac_offset collapse_fractional(atom.pos[:2], in_place=True) atom.cartpos[:2] = atom.pos.dot(self.ucell.T)[:2] def transform_unit_cell_2d(self, transform, on_row_vectors=True, append_ucell_mod=True): """Apply a 2D-transformation matrix to the unit cell. All atomic Cartesian coordinates are left unchanged. Fractional coordinates are recalculated for the new unit cell, but are **not** collapsed. Parameters ---------- transform : Sequence Shape (2, 2) or (3, 3). The matrix transformation to be applied to the unit cell. The transform is always applied 'from the left' to the unit cell. `on_row_vectors` decides how the unit cell vectors are to be transformed. on_row_vectors : bool, optional Whether `transform` should be applied to a unit cell where the unit vectors are rows, or whether it should be applied on one with unit vectors as columns. Default is True. append_ucell_mod : bool, optional Whether the transformation applied should be registered (to allow reverting). Default is True. Notes ----- Typical applications include transforming the unit cell with SUPERLATTICE/SYMMETRY_CELL_TRANSFORM (`on_row_vectors`=True), or applying a rotation or mirror (i.e., Hausholder) matrix to the unit cell (`on_row_vectors`=False). Returns ------- None. """ if self.topat_ori_z is None: # It makes sense to run this only if we do not yet # have the information about the Cartesian origin, # as the Cartesian coordinates stay unaltered. self.update_cartesian_from_fractional() if np.shape(transform) == (2, 2): transform_3d = np.identity(3) transform_3d[:2, :2] = transform else: transform_3d = np.asarray(transform) if on_row_vectors: # TODO: this has to be turned around when switching ucell.T -- Issue #175 # transform_3d is to be multiplied to the left for a unit # cell with unit vectors as rows. However, we use columns: # ucell.T = transform_3d @ ucell.T # ucell = (transform_3d @ ucell.T).T # = ucell @ transform_3d.T side = 'rmul' transform_3d = transform_3d.T self.ucell = np.dot(self.ucell, transform_3d) else: side = 'lmul' self.ucell = np.dot(transform_3d, self.ucell) if append_ucell_mod: self.ucell_mod.append((side, transform_3d)) self.update_fractional_from_cartesian() def translate_atoms_2d(self, shift): """Add a 2D Cartesian shift to all atomic coordinates. Parameters ---------- shift : Sequence Two elements. The 2D Cartesian rigid shift to apply to all atoms. Notes ----- This method **does not collapse** the atom coordinates to the base unit cell. Explicitly follow a call to this method with .collapse_cartesian_coordinates() if the atoms should be back-folded into the base unit cell. This operation can be undone with a call to `revert_unit_cell`, as the shift is registered as one of the `ucell_mod`. """ shift = np.asarray(shift) # Since fractional positions and Cartesian positions are # frac = cart @ ab_inv, # we can move Cartesian by shift, and fractional by: frac_shift = shift.dot(np.linalg.inv(self.ab_cell.T)) for atom in self: atom.translate_2d(shift, frac_shift) self.ucell_mod.append(('add', shift)) def translate_atoms_c(self, c_fraction): """Move all atoms up by `c_fraction` along the c vector. Parameters ---------- c_fraction : float The rigid shift in fractional coordinates to be added to all the atoms. Notes ----- At variance with other atom- and unit-cell-transforming operations, this method **does collapse** the atom coordinates to the base unit cell and **updates the stored position of the topmost atom**. As translating atoms along c has the potential to modify layers and sublayers, the `.layers` ans `.sublayers` attributes are always cleared. Call `create_(sub)layers` again to recreate them. This operation can be undone with a call to `revert_unit_cell`, as the shift is registered as one of the `ucell_mod`. The clearing of layers cannot be automatically undone. """ for atom in self: atom.pos[2] += c_fraction collapse_fractional(atom.pos, in_place=True) self.update_cartesian_from_fractional(update_origin=True) self.ucell_mod.append(('c_shift', c_fraction)) self.layers = () self.sublayers = () # ----------------- SYMMETRY UPON TRANSFORMATION ------------------ def _is_2d_transform_symmetric(self, matrix, center, translation, eps): """Return whether `self` is identical under a 2D symmetry transform. Parameters ---------- matrix : numpy.ndarray Shape (2, 2). The point operation `matrix` to be applied to all Cartesian coordinates (as row vectors, on the right) center : Sequence Shape (2,). The point of application of the operation in `matrix`. translation : Sequence Shape (2,). Translation vector to be applied. eps : float Tolerance (Cartesian) for position equivalence Returns ------- is_symmetric : bool Whether the slab is self-similar under the operation. Raises ------ MissingSublayersError If called before sublayers were created """ # Use the version of the unit cell with unit vectors as rows ab_cell = self.ab_cell.T ab_inv = np.linalg.inv(ab_cell) releps = eps / np.linalg.norm(ab_cell, axis=1) # Run the comparison sublayer-wise if not self.sublayers: raise MissingSublayersError( '2d-transform invariance check requires sublayers. ' 'Call create_sublayers(epsz), then try again.' ) for layer in self.sublayers: # Collapse fractional coordinates before transforming cart_coords = np.array([atom.cartpos[:2] for atom in layer]) cart_coords, frac_coords = collapse(cart_coords, ab_cell, ab_inv) # Create a transformed copy: shift, transform, # shift back, and apply rigid translation transf_coords = cart_coords - center transf_coords = transf_coords.dot(matrix) + center + translation # Collapse again transf_coords, _ = collapse(transf_coords, ab_cell, ab_inv) # Add extra atoms close to edges/corners for comparing cart_coords, _ = add_edges_and_corners(cart_coords, frac_coords, releps, ab_cell) # Finally compare interatomic distances distances = euclid_distance(transf_coords, cart_coords) if any(distances.min(axis=1) > eps): return False return True def is_mirror_symmetric(self, symplane, eps, glide=False): """Return if this slab is unchanged when applying a 2D mirror/glide. Parameters ---------- symplane : SymPlane The plane across which a mirror/glide should be performed. If `symplane.is_glide`, a glide operation is applied. eps : float Cartesian tolerance for position equivalence. glide : bool, optional Whether a glide translation is applied. This value is ignored if `symplane.is_glide`. Returns ------- symmetric : bool Whether slab is unchanged when 'mirrored' across `symplane` """ matrix = symplane.point_operation(n_dim=2) glide_vec = np.zeros(2) if symplane.is_glide or glide: glide_vec = symplane.par.dot(self.ab_cell.T) / 2 return self._is_2d_transform_symmetric(matrix, symplane.pos, glide_vec, eps) def is_rotation_symmetric(self, axis, order, eps): """Return if this slab is unchanged when applying a 2D rotation. Parameters ---------- axis : Sequence The 2D Cartesian coordinates of the axis around which the rotation should be tested. order : int The order of the rotation to be tested. eps : float Cartesian tolerance for position equivalence. Returns ------- symmetric : bool Whether slab is unchanged when rotated around `axis`. """ matrix = rotation_matrix_order(order, dim=2) return self._is_2d_transform_symmetric(matrix, axis, 0, eps) def is_translation_symmetric(self, translation, eps, # too-many-locals z_periodic=True, z_range=None): """Return if this slab is unchanged when translated. Parameters ---------- translation : Sequence 2- or 3-dimensional Cartesian translation vector for which translational symmetry is to be tested. The coordinate frame is the same as for atoms. eps : float Error tolerance for positions (Cartesian). z_periodic : bool, optional Whether the c unit vector should be considered as a repeat vector or not. Should be True for checking periodicity of a bulk slab, False otherwise. Default is True. z_range : Sequence or None, optional When a Sequence, `min(z_range)` and `max(z_range`) are used as Cartesian limits. Only atoms with min_z <= z <= max_z are compared. `z_range` is used only if `z_periodic` is False. Default is None. Returns ------- is_symmetric : bool Whether this slab is unchanged (within `eps`) upon `translation`. Raises ------ ValueError If `translation` is not a 2- or 3-D vector. """ translation = np.array(translation) if translation.shape not in ((2,), (3,)): raise ValueError( f'{type(self).__name__}.is_translation_symmetric: ' 'not a 2D or 3D vector' ) if translation.shape == (2,): # 2D. Use zero for z component. translation = np.append(translation, 0.) # Let's work in the coordinate frame of the unit cell, # TODO: change when flipping .cartpos[2] -- Issue #174 # i.e., with z axis directed from bulk to surface. This # requires updating the translation vector and z_range translation[2] *= -1 if z_range is not None: z_range = (self.topat_ori_z - min(z_range), self.topat_ori_z - max(z_range)) # Use the better version of the unit cell, with a,b,c = uc ucell = self.ucell.T ucell_inv = np.linalg.inv(ucell) releps = eps / np.linalg.norm(ucell, axis=1) # Collapse the translation vector toward the origin. This is # useful to shortcut the check if the vector is close to zero translation, _ = collapse(translation, ucell, ucell_inv, 'round') if np.linalg.norm(translation) < eps: return True # Prepare Cartesian coordinates, collapsed to # the base cell, and their translated version frac_coords = collapse_fractional(np.array([at.pos for at in self])) cart_coords = frac_coords.dot(ucell) shifted_coords = cart_coords + translation # Unlike in-plane operations, the comparison cannot be # done one sublayer at a time. We will compare element # by element. Build atom-to-element index mappings for # both unshifted and shifted coordinates. Use a list # for element_per_atom for now, convert it to array later # to use as mask. The shifted one can be an array already element_per_atom = [at.el for at in self] element_per_atom_shifted = np.array(element_per_atom.copy()) # Discard shifted atoms that moved out of range in z... if not z_periodic: if z_range is None: z_range = np.min(cart_coords[:, 2]), np.max(cart_coords[:, 2]) in_range = np.all((shifted_coords[:, 2] >= min(z_range) - eps, shifted_coords[:, 2] <= max(z_range) + eps), axis=0) shifted_coords = shifted_coords[in_range] element_per_atom_shifted = element_per_atom_shifted[in_range] # ...and collapse also the shifted coordinates shifted_coords, _ = collapse(shifted_coords, ucell, ucell_inv) # Include replicas of atoms close to edges/corners, then # convert element_per_atom to array to use it as a mask (cart_coords, element_per_atom) = add_edges_and_corners(cart_coords, frac_coords, releps, ucell, props=element_per_atom) element_per_atom = np.array(element_per_atom) # Finally, perform the element-wise comparison for element in self.elements: distances = euclid_distance( shifted_coords[element_per_atom_shifted == element], cart_coords[element_per_atom == element], ) if any(distances.min(axis=1) > eps): return False return True