Source code for pyprocar.pyposcar.poscarUtils

#!/usr/bin/env python
from __future__ import annotations
import argparse
import numpy as np
from .poscar import Poscar
from . import latticeUtils


[docs] def poscarDiff(poscar1 : Poscar | str, poscar2 : Poscar | str, tolerance : float = 0.01) -> dict: """It compares two different Poscar objects. Small numerical errors up to `tolerance` are ignored. Return: a dictionary with the differences found. If the files are the same, it returns an empty dictionary (i.e. a False value) The comparison does: -comparison between elements -comparison between lattices (distances and angles) -comparison of the relative distances between atoms Parameters ---------- poscar1 : Poscar | str the first Poscar filename or object poscar2 : Poscar | str the first Poscar filename or object tolerance : float numerical difference to consider both files equal. Default 0.01 Returns ------- dict: the differences are stored with keys 'Elements', 'lattices', 'distances'. If no differences are found an empty dict is returned """ # If poscar1, poscar2 are string a Poscar-object needs to be created if isinstance(poscar1, str): poscar1 = Poscar(poscar1) if isinstance(poscar2, str): poscar2 = Poscar(poscar2) if poscar1.loaded is False: poscar1.parse() if poscar2.loaded is False: poscar2.parse() differences = {} #Checking for type of elements if(list(poscar1.elm) != list(poscar2.elm)): differences['Elements'] = (list(poscar1.elm),list(poscar2.elm)) return differences #The rest only makes sense to check if elements are the same #Checking lattice lat_delta = np.zeros((3,3)) for i in [0,1,2]: for j in [0,1,2]: #We compare the module of all lattice vectors lat_1 = np.dot(poscar1.lat[i], poscar1.lat[j]) lat_2 = np.dot(poscar2.lat[i], poscar2.lat[j]) lat_delta[i,j] = np.abs(lat_1 - lat_2) for delta in lat_delta: if(any([x > tolerance for x in delta])): differences['lattices'] = lat_delta #Checking distances #We get the distance matrix, wich includes distances between atoms for all atoms d1 = latticeUtils.distances(poscar1.cpos, lattice=poscar1.lat) d2 = latticeUtils.distances(poscar2.cpos, lattice=poscar2.lat) delta = d1 - d2 #We take the norm of the difference between the distances delta = np.linalg.norm(delta) if(delta > tolerance): differences['distances'] = delta return differences
[docs] class poscar_modify: """ High-level class to change properties of a Poscar-object. Methods ------- write(filename, cartesian, xyz) # write the poscar with `filename`, in `cartesian`? pos_multiply(factor, cartesian) # multiply the position of each atoms by `factor` pos_sum(factor, cartesian) # sums `factor` to each position remove(self, atoms, human) # removes a list of `atoms` add(element, position, cartesian) # add a single atom with `element` at `position` shift(amount, cartesian) # shift all the positions by `amount` scale_lattice(factor, cartesian) # scale the lattice by `factor`. are `cartesian` fixed? """
[docs] def __init__(self, poscar:Poscar|str, verbose:bool=False): """High-level class to change properties of a Poscar-object. Parameters ---------- poscar : Poscar|str Filename or Poscar instance to be modified verbose : bool verbosity. Default is False """ if isinstance(poscar, str): self.p = Poscar(poscar) else: self.p = poscar if self.p.loaded is False: self.p.parse() self.verbose = verbose
[docs] def write(self, filename:str, cartesian:bool=False, xyz:bool=False): """Writes the content of this class into a file. It just invokes the write method from the `Poscar` class. Is here just for convenience. Parameters ---------- filename : str the name of the file to be written cartesian: bool: Cartesian (True) or direct (False) coordinates. Default is True xyz: bool should a xyz be written too? The '.xyz' extension will be added automatically to `filename` Default is False. """ direct = True if cartesian: direct=False self.p.write(filename, direct=direct) if xyz: self.p.xyz(filename + '.xyz') if self.verbose: print("new POSCAR:") print(self.p.poscar) if xyz: print('XYZ file written')
[docs] def pos_multiply(self, factor:np.ndarray|list[float], cartesian:bool = True): """ Multiplies each (x,y,z) position by the Factor (Fx,Fy,Fz) Parameters ---------- factor : np.ndarray|list[float] array or list with 3 numbers, the x,y,z factors to scale each position cartesian: bool should the operation be done in Cartesian (False) or direct (True) coordinates? Default is True """ factor = np.array(factor, dtype=float) if verbose: print("Multiply positions, factor = ", factor) print("old positions:") if cartesian: print(self.p.cpos) else: print(self.p.dpos) if cartesian is True: self.p.cpos = self.p.cpos*factor self.p._set_direct() else: self.p.dpos = self.p.dpos*factor self.p._set_cartesian() if verbose: print("\nnew positions:") if cartesian: print(self.p.cpos) else: print(self.p.dpos) return
[docs] def pos_sum(self, factor:np.ndarray|list[float], cartesian=False): """ Add the Factor (Fx,Fy,Fz) to each position Parameters: factor : np.ndarray|list[float] 3 numbers, the factor to add to each position cartesian : bool should the operation be done in Cartesian (True) or direct coordinates (False). Default is False """ factor = np.array(factor, dtype=float) if verbose: print("summing to positions, factor = ", factor) print("old positions:") if cartesian: print(self.p.cpos) else: print(self.p.dpos) if cartesian is True: self.p.cpos = self.p.cpos + factor self.p._set_direct() else: self.p.dpos = self.p.dpos + factor self.p._set_cartesian() if verbose: print("\nnew positions:") if cartesian: print(self.p.cpos) else: print(self.p.dpos) return
[docs] def change_elements(self, indexes : np.ndarray|List[int]|int, newElements : np.ndarray|List[str]|str, human : bool = False): """It changes the Element of one or more atoms in this poscar object. Parameters ---------- indexes : np.ndarray | List[int] | int the 0-based index(es) of the atom to be replaced. newElements : np.ndarray | List[str] | str the element(s) to replace. Same size of `indexes` human : bool if True, the index(es) will be one-based, as humans like to count. Default is False """ # converting anything to arrays if isinstance(indexes, int): indexes = np.array([indexes], dtype=int) # I need a np.ndarray indexes = np.array(indexes, dtype=int) if human: indexes = indexes - 1 if isinstance(newElements, str): newElements = [newElements] # first retrive the positions dpos = self.p.dpos[indexes] # then removing self.remove(indexes, human=False) # and finally adding a new atoms, one at a time for pos,elem in zip(dpos,newElements): self.add(elem, pos, cartesian=False) if self.verbose: print('Added element ', newElement, 'at direct coord:', dpos)
[docs] def remove(self, atoms : List[int] | np.ndarray, human : bool = False): """Removes a list of atoms from the Poscar object. The order of removal is not trivial, and it is equivalent to removing all the desired atoms at once. Parameters ---------- atoms : List[int] | np.ndarray a list with the indexes of the atoms to remove human : bool does `atoms` start from 1 (True) or 0 (False)? Default is False """ atoms = np.array(atoms) # the atoms list could be disordered, Poscar.remove is safe if human: atoms = atoms - 1 self.p.remove(atoms) if self.verbose: print('removing the following atoms (0-based indexes):', atoms) print(self.p.numberSp, self.p.typeSp)
[docs] def add(self, element : str, position : List[float] | np.nadarray, cartesian : bool = False): """Adds a single atom to the Poscar object. Parameters ---------- element : str a string with the atomic specie, e.g. 'Cu' position : List[float] | np.nadarray [X, Y, Z] cartesian : bool are the positions in Cartesian (True) or direct coordiantes (False)? Default is False """ position = np.array(position, dtype=float) if self.verbose: print('New atom:', element, position) direct = True if cartesian is True: direct = False self.p.add(position=position, element=element, direct=direct)
[docs] def shift(self, amount : List[float] | np.ndarray, cartesian : bool = False ): """Shift all the positions by `amount`, given in Cartesian or direct coordinates. The PBCs are always enforced (i.e. [0,1] in direct coords). If amount = [0,0,0] it just applies the perodic boundary conditions. Parameters ---------- amount : List[float] | np.ndarray [X,Y,Z] the shift along each basis vector or along Cartesian axis. cartesian : bool is the `amount` given in Cartesian (True) or direct (False) coords? Default False """ amount = np.array(amount, dtype=float) if cartesian: if self.verbose: print('\nOriginal Cartesian coords:') print(p.cpos) self.p.cpos = self.p.cpos + amount self.p._set_direct() if self.verbose: print('\nShifted Cartesian coords:') print(p.cpos) else: if self.verbose: print('\nOriginal Direct coords:') print(p.dpos) self.p.dpos = self.p.dpos + amount self.p._set_cartesian() if self.verbose: print('\nShifted Cartesian coords:') print(p.cpos) # enforcing the PBCs self.p.dpos = np.mod(self.p.dpos, 1.0) self.p._set_cartesian() return
[docs] def scale_lattice(self, factor : np.ndarray, keep_cartesian : bool = False): """Scale the lattice vectors by factor [a,b,c] Parameters: factor : np.ndarray [A,B,C], the first lattice vector is multiplied by A, etc. keep_cartesian : bool What cooddinates should remain constant? Cartesian or direct? Default is False """ if self.verbose: print("Old lattice") print(self.p.lat) self.p.lat = (self.p.lat.T * factor).T if self.verbose: print("New lattice") print(self.p.lat) # setting the new volume self.p.volume = np.linalg.det(self.p.lat) if keep_cartesian: # if cartesian positions are to remain constant, the direct ones # needs to be updated self.p._set_direct() else: self.p._set_cartesian() return
[docs] class poscar_supercell: """ class to generate a supercell by providing a supercell matrix. """
[docs] def __init__(self, poscar : Poscar | str, verbose : bool = False): """This class created a supercell of `poscar`, see the `supercell` method Parameters __________ poscar : Poscar | str Filename or Poscar instance to be modified verbose : bool verbosity. Default is False """ if isinstance(poscar, str): self.poscar = Poscar(poscar) else: import copy self.poscar = copy.deepcopy(poscar) if self.poscar.loaded is False: self.poscar.parse() self.verbose = verbose
[docs] def supercell(self, size : np.ndarray) -> Poscar: """Creates a supercell of the given size. The content of the original Poscar is overwritten size = [[b1x, b1y, b1z], [b2x, b2y, b2z], [b3x, b3y, b3z]] Parameters ---------- size : ndarray (3x3) array of integers with the supercell vectors in term of the original lattice vectors. The order is [[b1x, b1y, b1z], [b2x, ...] ...] Returns ------- Poscar A Poscar object with the desired supercell. It is the same instance stored in this class. Note, the creation of `poscar_supercell` makes a deep copy of the `Poscar` instance provided """ lat = self.poscar.lat pos = self.poscar.dpos elem = self.poscar.elm scell = np.array(size, dtype=int) if self.verbose: print("original lattice:\n", lat, "\n") print("New lattice (in terms of the original vectors)\n", scell, "\n") print("Cartesian coordinates new lattice:\n", np.dot(scell, lat)) # inverse: a=ocell*b ocell = np.linalg.inv(scell) if self.verbose: print( "inverse transformation:\n", ocell) print( "atoms, direct\n", pos) print( "atoms in cart\n") print( np.einsum('ij,jk', pos, lat)) print( "in terms of new lattice\n") spos = np.einsum('ij,jk', pos, ocell) # I need to find the values of n_i, a_i *inside* the supercell. # n_i*a_i = n_i*ocell_ij*b_j # then, the condition is : 0 < n_i*ocell_ij < 1 b = np.ones(3) n = np.einsum('j,ji', b, scell ) n = int(np.max(np.abs(n))) if self.verbose: print( "maximum value of n to search for repetitions : ", n) n = np.arange(-n,n) n = np.array([(x,y,z) for z in n for y in n for x in n]) nuseful = [] #checking which of the previous repetitions works for trial in n: value = np.einsum('i,ij', trial, ocell) if value.min() >= 0 and value.max() < 1: nuseful.append(trial) if self.verbose: print( "set of new coords\n", nuseful) npos = [] for nn in nuseful: npos.append(spos + np.einsum('i,ij', nn, ocell)) if self.verbose: print( "positions:") npos = np.concatenate(npos) npos = np.mod(npos, 1) if self.verbose: print( npos, npos.shape) # I can have repeated elements, such as '0 0 1', and '0 0 0' # (the 1 can be 0.9999999 and fail the previous filter) tol = 0.001 temp = [] for i in range(len(npos)): repeated = False for j in range(i): d = np.abs(npos[i] - npos[j]) for k in range(len(d)): if abs(d[k]-1) < d[k]: d[k]=d[k]-1 #print d if np.linalg.norm(d) < tol and i != j: repeated = True if self.verbose: print( i, j, npos[i], npos[j]) if not repeated: temp.append(npos[i]) temp = np.concatenate(temp) temp.shape = (-1,3) if self.verbose: print( temp.shape) npos = temp[:] elem = list(elem)*len(nuseful) #print elem self.poscar.elm = elem self.poscar.lat = np.dot(scell, lat) self.poscar.dpos = npos self.poscar._set_cartesian() self.poscar.sort() return self.poscar
[docs] def write(self, filename : str, cartesian : bool = False, xyz : bool = False): """Just a convenience method to save the content into a file. Parameters ---------- filename : str the name of the file to be written cartesian : bool Do you prefer the output position in Cartesian coords (True)? Default is False xyz : bool Do you want to write an .xyz file? the .xyz extension will be added to filename. Default is False """ pm = poscar_modify(self.poscar, verbose=False) pm.write(filename=filename, cartesian=cartesian, xyz=xyz) return
def p_atoms_f(args): print('Operations related with atomic positions') if args.verbose: print('Input: ', args.input) print('Output: ', args.output) print('sum: ', args.sum) print('multiply: ', args.multiply) print('xyz: ', args.xyz) print('cartesian: ', args.cart) print('save_cart: ', args.sc) print('remove: ', args.remove) print('human: ', args.human) print('add: ', args.add) p = Poscar(args.input, verbose=False) p.parse() Modifier = poscar_modify(p, verbose=args.verbose) # first dealing with the maths if args.multiply: Modifier.pos_multiply(factor, cartesian=args.cart) if args.sum: Modifier.pos_sum(factor, cartesian=args.cart) if args.remove: Modifier.remove(args.remove, human=args.human) if args.add: # parsing the string: 'C 1.2 4 -5.0' # args.add = args.add.split() element = args.add[0] position = args.add[1:] if len(position) != 3: raise RuntimeError("the --add parameter has a wrong format, " + args.add) position = np.array(position, dtype=float) Modifier.add(element=element, position=position, cartesian=args.cartesian) # Now we are done with all modifications Modifier.write(args.output, cartesian=args.sc, xyz=args.xyz) return def p_pbc_f(args): print('PBC-related utilities') if args.verbose: print('Input: ', args.input) print('Output: ', args.output) print('shift: ', args.shift) print('xyz: ', args.xyz) print('cartesian: ', args.cart) print('save_cart: ', args.sc) p = Poscar(args.input, verbose=False) p.parse() Modifier = poscar_modify(p, verbose=args.verbose) if args.shift: Modifier.shift(args.shift, args.cart) Modifier.write(args.output, cartesian=args.sc, xyz=args.xyz) return def p_lattice_f(args): print('Lattice stuff') if args.verbose: print('Input: ', args.input) print('Output: ', args.output) print('scale: ', args.scale) print('factor: ', args.factor) print('xyz: ', args.xyz) print('cartesian: ', args.cart) print('save_cart: ', args.sc) p = Poscar(args.input, verbose=False) p.parse() Modifier = poscar_modify(p, verbose=args.verbose) # first dealing with the factors, if any if args.factor == None: args.factor = 1.0 if args.scale == None: scale = np.array([1.0, 1.0, 1.0]) factor = factor*scale # Now changing the lattice vectors Modifier.scale_lattice(factor=factor, cartesian=args.cart) # and writing Modifier.write(args.output, cartesian=args.sc, xyz=args.xyz) return def p_scell_f(args): print('Supercell creation') if args.verbose: print('Input: ', args.input) print('Output: ', args.output) print('b1: ', args.b1) print('b2: ', args.b2) print('b3: ', args.b3) print('xyz: ', args.xyz) print('save_cart: ', args.sc) p = Poscar(args.input) p.parse() supercell = poscar_supercell(p, verbose=args.verbose) supercell.supercell(size=[args.b1, args.b2, args.b3]) supercell.write(args.output,) supercell.write(filename=args.output, cartesian=args.sc, xyz=args.xyz) return if __name__ == "__main__": parser = argparse.ArgumentParser(description='Utilities related to poscar.') subparsers = parser.add_subparsers(help='sub-command') # defining subparsers p_atoms = subparsers.add_parser('atoms', help='operations related with ' 'atomic positions, leaving the lattice unchanged') p_atoms.set_defaults(func=p_atoms_f) p_pbc = subparsers.add_parser('pbc', help='boundaries-related operations. It always' ' applies PCBs at the atomic positions. ') p_pbc.set_defaults(func=p_pbc_f) p_lattice = subparsers.add_parser('lattice', help='lattice-related operations. ' 'The positions may/may not be modified with the ' 'lattice') p_lattice.set_defaults(func=p_lattice_f) p_scell = subparsers.add_parser('supercell', help='Creates a supercell') p_scell.set_defaults(func=p_scell_f) # filing the subparser options p_atoms.add_argument('input', type=str, help='Input POSCAR file ') p_atoms.add_argument('output', type=str, help='Output POSCAR file') p_atoms.add_argument('-s', '--sum', type=float, nargs=3, help='Sum (adds) [X Y Z] to each ' 'position. This is in Direct coordinates by default, see `--cart`.' ' The PBC are NOT taken in to account, i.e. [0.5 0.5 0.5] + ' '[0.0 0.0 0.7] = [0.5 0.5 1.2]. Multiplication precedes addition.' ' Nevertheless it is recommended to separate the operation in ' 'different calls') p_atoms.add_argument('-m', '--multiply', type=float, nargs=3, help='Multiplies [X Y Z]' ' element-wise each postion. This is in Direct coords by default,' ' see `--cart`. PBC are not taken into account, i.e. [0.1 0.2 0.7]' ' * [0.5 1.0 2.0] = [0.05 0.2 1.4]. Multiplication precedes addition.' ' Nevertheless it is recommended to separate the operation in ' 'different calls') p_atoms.add_argument('--xyz', action='store_true', help='writes an xyz file') p_atoms.add_argument('-c', '--cart', action='store_true', help='set to perform the ' 'operations in the cartesian positions. By default Direct coords' ' are used' ) p_atoms.add_argument('--sc', action='store_true', help='set to save the file in ' 'Cartesian. By default Direct coords are used, even if the ' 'operations are done in cartesians') p_atoms.add_argument('-v', '--verbose', action='store_true') p_atoms.add_argument('--remove', '-r', nargs='+', type=int, help='removes the atoms' ' with the given indexes. They indexes start from 0, unless you' ' set `--human`') p_atoms.add_argument('--human', '-u', action='store_true', help='All the indexes ' 'from the input are put in `human` format, starting from 1 (i.e' '.: the first atom is 1, and so on)') p_atoms.add_argument('--add', '-a', nargs=4, type=str, help='`--add C 0.5 0.3 0.1`,' ' adds an C atom at the given direct coordinate. The coordinates' ' can be direct (default) or Cartesian, see `--cart`') # p_atoms.add_argument('--duplicates', action='store_true', help="removes the duplicate " # "atoms, i.e. Those that are almost in the same position. Mind the" # "program will keep only the first occurrence. See `duplicate_tol`") # p_atoms.add_argument('--duplicate_tol', type=float, default=0.1, help='Tolerance ' # 'critetion for setting an atom as `duplicate`') ######### p_pbc.add_argument('input', type=str, help='input file') p_pbc.add_argument('output', type=str, help='output file') # p_pbc.add_argument('-p', '--pbc', action='store_true', help='it actually imposes' # ' PBCs, by moving all the atoms into the [0, 1) interval. ') p_pbc.add_argument('-s', '--shift', type=float, nargs=3, help='it adds [X, Y ,Z] ' 'to each coordinate, and then applies PBCs. see `--cart`, `--sc`') p_pbc.add_argument('--cart', action='store_true', help='The shift is given and made' ' in Cartesian coords. ') p_pbc.add_argument('--sc', action='store_true', help='sets the output POSCAR file in' ' cartesian coords. The default is direct') p_pbc.add_argument('--xyz', action='store_true', help='also saves a XYZ file') p_pbc.add_argument('-v', '--verbose', action='store_true') ######### p_lattice.add_argument('input', type=str, help='Input POSCAR file ') p_lattice.add_argument('output', type=str, help='Output POSCAR file') p_lattice.add_argument('-s', '--scale', type=float, nargs=3, help='multiplies each ' 'lattice vector by the [X Y Z] factor. The positions may/may ' 'not be affeted. See `cart`') p_lattice.add_argument('-f', '--factor', type=float, default=1.0, help='multiplies all the lattice' ' by the given factor. The position may/may not be affected, ' 'see `--cart`') p_lattice.add_argument('-c', '--cart', action='store_true', help='The positions are' ' keep fixed in cartesian coordinates, their value in direct' ' coords change with the lattice. The default is to keep the' ' positions unaltered in direct coords.') p_lattice.add_argument('--sc', action='store_true', help='set it to write the output' ' file in cartesian coords. Te default is in direct coords.') p_lattice.add_argument('-v', '--verbose', action='store_true') p_lattice.add_argument('--xyz', action='store_true', help='also a XYZ file is writtem') # p.lattice.add_argument('-r', '--rotate') ########## p_scell.add_argument('input', type=str, help='input file') p_scell.add_argument('output', type=str, help='output file') p_scell.add_argument("--b1", nargs=3, type=int, default=[1,0,0], help="first supercell vector, eg '1 2 -1'" ) p_scell.add_argument("--b2", nargs=3, type=int, default=[0,1,0], help="first supercell vector, eg '1 1 1'" ) p_scell.add_argument("--b3", nargs=3, type=int, default=[0,0,1], help="first supercell vector, eg '0 0 10'" ) p_scell.add_argument("--xyz", action="store_true") p_scell.add_argument('--sc', action='store_true', help='set it to write the output' ' file in cartesian coords. The default is in direct coords.') p_scell.add_argument('-v', '--verbose', action='store_true') ######################################### # there is a python 3 bug. If no argument provided an exception is raised #try: args = parser.parse_args() args.func(args) #except AttributeError: # parser.error("too few arguments")