Source code for schrodinger.application.matsci.nano.plane

"""
Classes and functions for crystal planes.

Copyright Schrodinger, LLC. All rights reserved."""

# Contributor: Thomas F. Hughes

from collections import OrderedDict
from functools import reduce
from math import gcd
from past.utils import old_div

import numpy
from scipy.spatial import ConvexHull

from schrodinger.application.matsci import shapes
from schrodinger.application.matsci.nano import xtal
from schrodinger.math import mathutils
from schrodinger.structutils import transform

_version = '$Revision 0.0 $'


[docs]def ext_gcd(a, b): """ Solve ax + by = gcd(a, b) using the Extended Euclidean Algorithm. Return (1) the greatest common divisor (gcd) of integers a and b and (2) the integer Bezout coefficients x and y. :type a: int :param a: the a coefficient :type b: int :param b: the b coefficient :rtype: int, int, int :return: gcd(a, b), Bezout coefficient x, and Bezout coefficient y """ iterate = lambda r_prev, q, r: (r_prev - q * r, r) r_prev, r = a, b x_prev, x = 1, 0 y_prev, y = 0, 1 while r != 0: q = old_div(r_prev, r) (r, r_prev) = iterate(r_prev, q, r) (x, x_prev) = iterate(x_prev, q, x) (y, y_prev) = iterate(y_prev, q, y) return r_prev, x_prev, y_prev
[docs]def reduce_hkl(hkl): """ Reduce hkl to the smallest set of indices param list hkl: Miller indices :retype: list, int :return: Reduced Miller indices, divisor """ divisor = abs(reduce(gcd, hkl)) hkl = [int(index / divisor) for index in hkl] return hkl, divisor
[docs]class CrystalPlane(object): """ Manage a crystal plane object. """ SQUARE = OrderedDict([(1, numpy.array([-1.0, 1.0, 0.0])), (2, numpy.array([-1.0, -1.0, 0.0])), (3, numpy.array([1.0, -1.0, 0.0])), (4, numpy.array([1.0, 1.0, 0.0]))]) DISTANCE_THRESH = -0.001 SAME_VECTOR_THRESH = 0.0001 SLAB_THRESHOLD = 0.0000001
[docs] def __init__(self, h_index, k_index, l_index, a_vec, b_vec, c_vec, origin=None, logger=None): """ Create an instance. :type h_index: int :param h_index: the h Miller index :type k_index: int :param k_index: the k Miller index :type l_index: int :param l_index: the l Miller index :type a_vec: numpy.array :param a_vec: the a lattice vector :type b_vec: numpy.array :param b_vec: the b lattice vector :type c_vec: numpy.array :param c_vec: the c lattice vector :type origin: numpy.array :param origin: the origin of the lattice vectors in Angstrom :type logger: logging.getLogger :param logger: output logger """ self.h_index = h_index self.k_index = k_index self.l_index = l_index self.a_vec = a_vec self.b_vec = b_vec self.c_vec = c_vec if origin is None: self.origin = numpy.array(xtal.ParserWrapper.ORIGIN) else: self.origin = origin self.logger = logger self.ra_vec = None self.rb_vec = None self.rc_vec = None self.inter_planar_separation = None self.normal_vec = None self.rotate_to_z = None self.inv_rotate_to_z = None self.checkMillerIndices() self.getReciprocals() self.getNormal() self.u_vec = None self.v_vec = None self.w_vec = None
[docs] def checkMillerIndices(self): """ Check the user provided Miller indices. """ bad_indices_msg = ( 'You have provided the Miller indices ' '(000). At least a single Miller index must be non-zero.') if self.h_index == self.k_index == self.l_index == 0: if self.logger: self.logger.error(bad_indices_msg) raise ValueError(bad_indices_msg)
[docs] def getReciprocals(self): """ Return the reciprocal lattice vectors. :rtype: three numpy.array :return: the three reciprocal lattice vectors. """ self.ra_vec, self.rb_vec, self.rc_vec = \ xtal.get_reciprocal_lattice_vectors(self.a_vec, self.b_vec, self.c_vec) return self.ra_vec, self.rb_vec, self.rc_vec
[docs] def getNormal(self): """ Return the normal vector. :rtype: numpy.array :return: the normal vector for this plane """ rnormal_vec = self.h_index * self.ra_vec + self.k_index * self.rb_vec + \ self.l_index * self.rc_vec self.inter_planar_separation = old_div( 1.0, transform.get_vector_magnitude(rnormal_vec)) self.normal_vec = \ self.inter_planar_separation * transform.get_normalized_vector(rnormal_vec) return self.normal_vec
[docs] def getLinDepPlaneVectors(self): """ Return three typically used plane vectors that are linearly dependent. :rtype: numpy.array, numpy.array, numpy.array :return: typically used plane vectors that are linearly dependent """ a_vec_prime = self.k_index * self.a_vec - self.h_index * self.b_vec b_vec_prime = self.l_index * self.a_vec - self.h_index * self.c_vec c_vec_prime = self.l_index * self.b_vec - self.k_index * self.c_vec return a_vec_prime, b_vec_prime, c_vec_prime
[docs] def transformVectors(self, a_vec, b_vec, c_vec): """ Transform the given vectors using the basis transform. :type a_vec: numpy.array :param a_vec: the first vector :type b_vec: numpy.array :param b_vec: the second vector :type c_vec: numpy.array :param c_vec: the third vector :rtype: numpy.array, numpy.array, numpy.array :return: the three transformed vectors """ matrix = numpy.array([a_vec, b_vec, c_vec]) matrixp = numpy.dot(matrix.T, self.basis) u_vec, v_vec, w_vec = matrixp.T return u_vec, v_vec, w_vec
[docs] def getSimpleSlabVectors(self): """ This sets the simple, i.e. two of the Miller indices are zero, transformation matrix into self.basis and sets the simple slab vectors. """ miller_indices = [self.h_index, self.k_index, self.l_index] num_zero = miller_indices.count(0) if num_zero != 2: raise ValueError('Number of null Miller indices is not two.') if self.h_index: self.basis = numpy.array([(0, 0, 1), (0, 1, 0), (-1, 0, 0)]) elif self.k_index: self.basis = numpy.array([(1, 0, 0), (0, 0, 1), (0, -1, 0)]) elif self.l_index: self.basis = numpy.array([(1, 0, 0), (0, 1, 0), (0, 0, 1)]) self.u_vec, self.v_vec, self.w_vec = \ self.transformVectors(self.a_vec, self.b_vec, self.c_vec)
[docs] def getSlabVectors(self): """ This sets the transformation matrix into self.basis. Basis vectors are chosen such that a and b axes are in the plane of the Miller plane, and c-axis is out of this plane (NOT necessarily normal to it). Also sets the slab vectors. """ # if this is a simple case, i.e. two of the Miller # indices are zero, then just handle it now try: self.getSimpleSlabVectors() return except ValueError: pass # the algorithm used here follows from that given in # https://wiki.fysik.dtu.dk/ase/_downloads/general_surface.pdf # to summarize, three plane vectors, a', b', and c', are # formed from linear combinations of the three lattice vectors, # a, b, and c, using the Miller indices: # a' = k*a - h*b # b' = l*a - h*c # c' = l*b - k*c # one of these vectors is picked to be one of the two plane # vectors while a linear combination of the other two vectors # is taken to form the last plane vector. this linear combination # is taken so as to both minimize the planar area spanned by the two # planar vectors and to make them as orthogonal as possible. these # are determined subject to their cross product having a length of one # inter-planar separation # get some typcially used linearly dependent vectors that # span the plane a_vec_prime, b_vec_prime, c_vec_prime = self.getLinDepPlaneVectors() # set the first plane vector with linear combinations of # a_vec_prime and b_vec_prime, the linear combination to # minimize the area requires solving pk + ql = 1 gcd_kl, p_coef, q_coef = ext_gcd(self.k_index, self.l_index) u_vec = p_coef * a_vec_prime + q_coef * b_vec_prime # if we can make the planar vectors more orthogonal then go ahead, # in the original document cited above they mention the need for # a threshold due to numerical precision however I have not yet # seen a case where this is necessary numerator = numpy.dot(u_vec, c_vec_prime) denominator = \ numpy.dot(self.l_index * a_vec_prime - self.k_index * b_vec_prime, \ c_vec_prime) if abs(denominator) > self.SLAB_THRESHOLD: coef = -1 * int(mathutils.roundup(numerator / denominator)) p_coef += coef * self.l_index q_coef -= coef * self.k_index # Set transformation matrix into self.basis vec1 = p_coef * numpy.array((self.k_index, -self.h_index, 0)) + \ q_coef * numpy.array((self.l_index, 0, -self.h_index)) vec2 = old_div(numpy.array((0, self.l_index, -self.k_index)), \ abs(xtal.gcd(self.l_index, self.k_index))) gcd_coef, a_coef, b_coef = ext_gcd( p_coef * self.k_index + q_coef * self.l_index, self.h_index) vec3 = (b_coef, a_coef * p_coef, a_coef * q_coef) self.basis = numpy.array([vec1, vec2, vec3]).T self.u_vec, self.v_vec, self.w_vec = \ self.transformVectors(self.a_vec, self.b_vec, self.c_vec)
[docs] def getSpanningVectors(self, st): """ Return the spanning vectors of this bounding box. :type st: schrodinger.structure.Structure :param st: the structure :rtype: list of numpy.array :return: contains vectors spanning the parallelepiped and its sides """ try: vecs = xtal.get_vectors_from_chorus(st) except ValueError: params = xtal.get_lattice_param_properties(st) vecs = xtal.get_lattice_vectors(*params) a_vec, b_vec, c_vec = vecs return [ a_vec, b_vec, c_vec, a_vec + b_vec, a_vec + c_vec, b_vec + c_vec, a_vec + b_vec + c_vec ]
[docs] def getBestSpanningVector(self, st): """ Return the spanning vector with the largest projection onto the plane normal vector. :type st: schrodinger.structure.Structure :param st: the structure :rtype: numpy.array :return: the best spanning vector """ spanning_vecs = self.getSpanningVectors(st) unit_normal_vec = transform.get_normalized_vector(self.normal_vec) pairs = [(x, abs(numpy.dot(x, unit_normal_vec))) for x in spanning_vecs] return max(pairs, key=lambda x: x[1])[0]
[docs] def getNumPlanes(self, st): """ Return the number of planes that will fit inside the bounding box. :type st: schrodinger.structure.Structure :param st: the structure :rtype: int :return: the number of planes that will fit inside the bounding box """ unit_normal_vec = transform.get_normalized_vector(self.normal_vec) spanning_vec = self.getBestSpanningVector(st) spanning_normal_vec = abs(numpy.dot(spanning_vec, unit_normal_vec)) * unit_normal_vec nplanes = int( round( old_div(transform.get_vector_magnitude(spanning_normal_vec), self.inter_planar_separation))) return nplanes
[docs] def getInterPlanarSeparation(self): """ Return the inter-planar separation in Angstrom. :rtype: float :return: the inter-planar separation in Angstrom """ return self.inter_planar_separation
[docs] def getRotationToZ(self): """ Return the rotation matrix needed to rotate this plane to the XY-plane as well as its inverse. :rtype: two numpy.array :return: the rotation matrix that rotates this plane to the XY-plane and its inverse. """ self.rotate_to_z = transform.get_alignment_matrix( self.normal_vec, numpy.array(transform.Z_AXIS)) self.inv_rotate_to_z = numpy.array(numpy.matrix(self.rotate_to_z).I) return self.rotate_to_z, self.inv_rotate_to_z
[docs] def getSquareVertices(self): """ Return the vertices of a square that lies in this plane. The square has and edge-length of 2 Angstrom. It is rotated from the XY-plane, centered on origin, into this plane. :rtype: list of numpy.array :return: the vertices of the squre that lies in this plane """ self.getRotationToZ() vertices = [] for vertex in self.SQUARE.values(): vertices.append( transform.transform_atom_coordinates(numpy.copy(vertex), self.inv_rotate_to_z)) return vertices
[docs] def getParallelepipedLineSegments(self, st): """ Return the line segments that make this bounding box. :type st: schrodinger.structure.Structure :param st: the structure :rtype: list of tuples of heads and tails of 12 line segments. :return: the line segments that make this bounding box """ a_vec, b_vec, c_vec, ab_vec, ac_vec, bc_vec, abc_vec = \ self.getSpanningVectors(st) # (start, end) line segment pairs # yapf: disable line_segments = [ (self.origin, self.origin + a_vec), (self.origin, self.origin + b_vec), (self.origin, self.origin + c_vec), (self.origin + a_vec, self.origin + ab_vec), (self.origin + a_vec, self.origin + ac_vec), (self.origin + b_vec, self.origin + ab_vec), (self.origin + b_vec, self.origin + bc_vec), (self.origin + ab_vec, self.origin + abc_vec), (self.origin + c_vec, self.origin + ac_vec), (self.origin + c_vec, self.origin + bc_vec), (self.origin + ac_vec, self.origin + abc_vec), (self.origin + bc_vec, self.origin + abc_vec) ] # yapf: enable return line_segments
[docs] def getPlaneBoxIntersections(self, st, vertices): """ Return the points where the plane containing the specified vertices intersects the parallelepiped. :type st: schrodinger.structure.Structure :param st: the structure :type vertices: list of numpy.array :param vertices: the vertices of the square that lies in this plane :rtype: list of numpy.array :return: the points of intersection """ num_unique = 1 aface = shapes.Face(1, list(self.SQUARE), vertices, num_unique) intersections = [] for line_segment in self.getParallelepipedLineSegments(st): start, end = line_segment intersection = aface.intersectSegmentAndPlane(start, end, \ distance_thresh=self.DISTANCE_THRESH) if intersection is not None: if not intersections: intersections.append(intersection) else: redundant = False for point in intersections: if transform.get_vector_magnitude(intersection - point) < \ self.SAME_VECTOR_THRESH: redundant = True break if not redundant: intersections.append(intersection) return intersections
[docs] def getOrderedIntersections(self, intersections): """ Return the provided list of planar points in counter-clockwise order. :type intersections: list of numpy.array :param intersections: some intersection points in a plane :rtype: list of numpy.array :return: those planar intersections in counter-clockwise order """ intersections += [old_div(sum(intersections), len(intersections))] intersections_xy = [] for intersection in intersections: intersection_xy = transform.transform_atom_coordinates( numpy.copy(intersection) - self.origin, self.rotate_to_z) z_coord = intersection_xy[2] intersections_xy.append(numpy.delete(intersection_xy, 2)) indices = ConvexHull(numpy.matrix(intersections_xy)).vertices intersections_xy_with_z = [ numpy.append(intersections_xy[index], z_coord) for index in indices ] intersections = [] for intersection in intersections_xy_with_z: point = transform.transform_atom_coordinates( numpy.copy(intersection), self.inv_rotate_to_z) + self.origin intersections.append(point) return intersections
[docs] def getVertices(self, normal_vec, idx, draw_location=0, thickness=1): """ Return a list of numpy.array containing vertices of a plane with the given index. :type normal_vec: numpy.array :param normal_vec: the normal vector :type idx: int :param idx: the plane index, the sign of the index controls whether behind or ahead of the normal vector origin :type draw_location: float :param draw_location: specifies the starting location at which planes will be drawn in terms of a fraction of the normal vector which has a length of one inter-planar spacing :type thickness: float :param thickness: specifies the thickness or distance between consecutive planes in terms of a fraction of the normal vector which has a length of one inter-planar spacing :rtype: list of numpy.array :return: the plane vertices """ # get square vertices of the hkl plane that passes through (0, 0, 0) square_vertices = self.getSquareVertices() # define a vector from (0, 0, 0) to the given draw_location shift_vec = self.origin + draw_location * normal_vec # define an offset vector offset_vec = idx * thickness * normal_vec return [v + shift_vec + offset_vec for v in square_vertices]
[docs] def getVerticesOfAllPlanes(self, st, draw_location=0, thickness=1, also_draw_planes_behind=True): """ Return a list of lists of points where the set of planes intersect the parallelepiped. :type st: schrodinger.structure.Structure :param st: the structure :type draw_location: float :param draw_location: specifies the starting location at which planes will be drawn in terms of a fraction of the normal vector which has a length of one inter-planar spacing :type thickness: float :param thickness: specifies the thickness or distance between consecutive planes in terms of a fraction of the normal vector which has a length of one inter-planar spacing :type also_draw_planes_behind: bool :param also_draw_planes_behind: whether to also draw planes behind the specified draw location, this is in addition to always drawing planes ahead of the draw location :rtype: list of list of numpy.array :return: where the planes intersect the parallelepiped """ # number of planes that can fit inside the largest bounding box nplanes = self.getNumPlanes(st) # get normal vector of proper sign spanning_vec = self.getBestSpanningVector(st) normal_vec = numpy.sign( numpy.dot(spanning_vec, transform.get_normalized_vector( self.normal_vec))) * self.normal_vec # if the zero plane (idx_plane == 0) doesn't intersect the parallelepiped # then create one outside the box at the parallelepiped origin at the start # of the normal vector, the size of the plane will be proportional to the # length of the normal vector if also_draw_planes_behind: idxs_plane = range(-nplanes, nplanes + 1) else: idxs_plane = range(nplanes + 1) vertices_of_all_planes = [] for idx_plane in idxs_plane: vertices = self.getVertices(normal_vec, idx_plane, draw_location=draw_location, thickness=thickness) intersections = self.getPlaneBoxIntersections(st, vertices) if len(intersections) >= 3: if len(intersections) >= 4: intersections = self.getOrderedIntersections(intersections) vertices_of_all_planes.append(intersections) elif not idx_plane: normal_len = transform.get_vector_magnitude(normal_vec) vertices = [ self.origin + normal_len * transform.get_normalized_vector(v - self.origin) for v in vertices ] vertices_of_all_planes.append(vertices) return vertices_of_all_planes