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

"""
Classes and functions to create nanoparticles.

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

# Contributor: Thomas F. Hughes

import argparse
import itertools
import math
import warnings
from collections import OrderedDict
from past.utils import old_div

import numpy
from scipy import constants as scipy_constants

from schrodinger.application.matsci import shapes
from schrodinger.application.matsci import textlogger
from schrodinger.application.matsci.nano import constants
from schrodinger.application.matsci.nano import plane
from schrodinger.application.matsci.nano import xtal
from schrodinger.structutils import build
from schrodinger.structutils import color
from schrodinger.structutils import transform

_version = '$Revision 0.0 $'

DEFAULT_SHAPE = shapes.Cube.NAME

ALL_SHAPES = list(shapes.SHAPES_NAMES_TO_OBJECTS_DICT)

OUTFILE_SUFFIX = '-out'

FRAGMENT_NAME = constants.Constants.FRAGMENT_NAME
TERM_FRAG = constants.Constants.TERMFRAG
TERM_FRAGS = constants.Constants.TERMFRAGS
TERM_DICT = constants.Constants.TERMDICT
TERM_DIRECTION = 'forward'

TERM_BOND_LENGTH = 1.50

NANOPARTICLE_NATOMS_KEY = 'i_matsci_Nanoparticle_Natoms'
NANOPARTICLE_VOLUME_KEY = 'r_matsci_Volume/Ang.^3'
NANOPARTICLE_SURFACE_AREA_KEY = 'r_matsci_Surface_Area/Ang.^2'
NANOPARTICLE_CIRCUMRADIUS_KEY = 'r_matsci_Circumradius/Ang.'
NANOPARTICLE_DENSITY_KEY = 'r_matsci_Density/g/cm^3'
NANOPARTICLE_TEMPLATE = 'b_matsci_Nanoparticle_Template'

ORIGINAL_ATOM_KEY = 'b_matsci_np_gui_original_atom'

NUM_DECIMAL = 4

CM_TO_ANGSTROM = float(100000000)

TOKEN_SEPARATOR = ','
KEY_VALUE_SEPARATOR = '='

AXIS_KEY = 'axis'
VECTOR_KEY = 'vector'
BASIS_KEY = 'basis'
HKL_VALUE = 'hkl'
ABC_VALUE = 'abc'
XYZ_VALUE = 'xyz'
ALIGNMENT_KEYS = [AXIS_KEY, VECTOR_KEY, BASIS_KEY]
BASIS_VALUES = [HKL_VALUE, ABC_VALUE, XYZ_VALUE]
PRIMARY_ALIGNMENT_DEFAULT = OrderedDict([(AXIS_KEY, '1'), (VECTOR_KEY, '1.0 0.0 0.0'), \
    (BASIS_KEY, HKL_VALUE)])
SECONDARY_ALIGNMENT_DEFAULT = OrderedDict([(AXIS_KEY, '1'), (VECTOR_KEY, '0.0 1.0 0.0'), \
    (BASIS_KEY, ABC_VALUE)])
dict_to_str = lambda x: [
    TOKEN_SEPARATOR.join(
        [KEY_VALUE_SEPARATOR.join(map(str, pair)) for pair in x.items()])
]
PRIMARY_ALIGNMENT_DEFAULT_LIST = dict_to_str(PRIMARY_ALIGNMENT_DEFAULT)
SECONDARY_ALIGNMENT_DEFAULT_LIST = dict_to_str(SECONDARY_ALIGNMENT_DEFAULT)

INCLUDE_AXES_DEFAULT = False

VERTICES_DEFAULT = []

ALLOW_FRAGMENTS = False


[docs]def get_lattice_properties(): """ Get the properties which are required for defining a crystal structure :rtype: list of str :return: Each item in the list is a structure property that is required to be present if a crystal structure is defined """ aclass = xtal.Crystal return [ aclass.A_KEY, aclass.B_KEY, aclass.C_KEY, aclass.ALPHA_KEY, aclass.BETA_KEY, aclass.GAMMA_KEY ]
[docs]def check_if_lattice_properties_exist(struct, keys=None): """ Check if the structure has all the required crystal lattice properties :type struct: `schrodinger.structure.Structure` :param struct: The structure to check :type keys: list :param keys: The list of crystal properties to check. If not given, the default set of crystal properties from get_lattice_properties will be checked :rtype: bool :return: If all the requested properties are present on the structure """ if not keys: keys = get_lattice_properties() return set(keys) <= set(struct.property)
[docs]def format_alignment_ids(num_unique): """ Return a formatted collection of shapes and alignment axis IDs. :type num_unique: str :param num_unique: the shape attr to format :rtype: str :return: the formatted string """ values = [] for name in ALL_SHAPES: shape_obj = shapes.get_shape_object_by_name(name) if shape_obj.TYPE != shapes.BASIC: values.append(name + ': ' + str(getattr(shape_obj, num_unique))) return ', '.join(values)
[docs]class ParserWrapper(object): """ Manages the argparse module to parse user command line arguments. """
[docs] def __init__(self, scriptname, description): """ Create a ParserWrapper instance and process it. :type scriptname: str :param scriptname: name of this script :type description: str :param description: description of this script """ name = '$SCHRODINGER/run ' + scriptname self.parserobj = argparse.ArgumentParser( prog=name, description=description, add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter)
[docs] def loadIt(self): """ Load ParserWrapper with options. """ shape_help = """Specify the shape of the nanoparticle.""" self.parserobj.add_argument('-shape', type=str, choices=ALL_SHAPES, default=DEFAULT_SHAPE, help=shape_help) origin_help = """When cutting the specified shape of nanoparticle from the crystalline solid it is necessary to specify the placement of the shape in the solid. This origin specifies the location of the shape's centroid. See the options -primary_alignment and -secondary_alignment which are also useful for orienting the shape. Note that if the shape is being aligned along a crystal plane (see -primary_alignment) the specified origin will be updated by translating it along the plane normal.""" self.parserobj.add_argument('-origin', type=float, nargs=3, default=shapes.ORIGIN, metavar='float', help=origin_help) params_help = """Specify the set of parameters that define the geometry of the shape. Units of Angstrom and degree are used. Here is a collection of shapes, their default parameters, and parameter descriptions: %s.""" defaults = [] for name in ALL_SHAPES: values = [] shape_obj = shapes.get_shape_object_by_name(name) for value, info in zip(shape_obj.DEFAULT, shape_obj.DESCRIPTION): values.append(str(value) + ' (' + info + ')') defaults.append(name + ': ' + ' '.join(values)) defaults = ', '.join(defaults) self.parserobj.add_argument('-params', type=float, nargs='*', metavar='float', help=params_help % defaults) vertices_help = """Overwrite the vertices of the polyhedron that has been specified via the -origin, -params, -primary_alignment, and -secondary_alignment options to this script. This offers the user the most flexible level of control with regard to orienting the polyhedron relative to the supercell. The polyhedron returned from this script when run without this command can be imported into Maestro and oriented relative to the supercell. The Cartesian coordinates of the vertices can then be fed in here using the form: x_1 y_1 z_1 x_2 ... x_N y_N z_N and will be parsed three numbers at a time. When using this option it is important that none of the internal degrees of freedom that define the polyhedron be altered. Avoid using this option with output from a run that uses the -include_axes option.""" self.parserobj.add_argument('-vertices', type=float, nargs='*', default=VERTICES_DEFAULT, help=vertices_help) primary_alignment_help = """Specify the primary alignment using the following sub-options, %s. Sub-options should be separated from their values using an '%s', while individual sub-option pairs should be separated from each other using a '%s'. The %s sub-option takes an integer axis ID that specifies which of the available symmetry unique primary axes to use in the alignment. Primary axes are shape face normals and are sorted by decreasing face area. Here is a collection of shapes and the number of available primary axes: %s. The %s sub-option takes three whitespace separated floating point numbers defining a vector in a basis given by the %s sub-option. Available bases include %s, which is reciprocal lattice space or the normal to the crystal plane given by Miller indices h, k, and l, %s, which is direct lattice space, and %s, which is Cartesian space. With this option the shape will be rotated so that the normal of the face specified via the primary axis ID lies parallel to the specified vector. If a crystal plane has been specified then the face will be translated so that it is coplanar with the crystal plane. As a consequence of this the origin, specified with the origin option, will be updated. By default the face of largest area, i.e. %s, will be aligned along the %s crystal plane. Together with the -secondary alignment options the rigid body rotations of the shape are fixed.""" values = format_alignment_ids('NUM_UNIQUE_FACES') self.parserobj.add_argument( '-primary_alignment', type=str, nargs='+', default=PRIMARY_ALIGNMENT_DEFAULT_LIST, help=primary_alignment_help % (', '.join(ALIGNMENT_KEYS), KEY_VALUE_SEPARATOR, TOKEN_SEPARATOR, AXIS_KEY, values, VECTOR_KEY, BASIS_KEY, HKL_VALUE, ABC_VALUE, XYZ_VALUE, PRIMARY_ALIGNMENT_DEFAULT[AXIS_KEY], PRIMARY_ALIGNMENT_DEFAULT[VECTOR_KEY])) secondary_alignment_help = """Specify the secondary alignment using the following sub-options, %s. Sub-options should be separated from their values using an '%s', while individual sub-option pairs should be separated from each other using a '%s'. The %s sub-option takes an integer axis ID that specifies which of the available symmetry unique secondary axes to use in the alignment. Secondary axes are shape face edges of the primary face, specified with -primary_alignment, and are sorted by decreasing edge length. Here is a collection of shapes and the number of available secondary axes: %s. The %s sub-option takes three whitespace separated floating point numbers defining a vector in a basis given by the %s sub-option. Available bases include %s, which is reciprocal lattice space or the normal to the crystal plane given by Miller indices h, k, and l, %s, which is direct lattice space, and %s, which is Cartesian space. With this option the shape will be rotated, following the primary alignment, so that the edge, specified via the secondary axis ID, of the primary face lies parallel to the specified vector. If a crystal plane has been specified then the edge will lie perpendicular to that plane. For convenience if the vectors specified in the primary and secondary alignment are non-orthogonal then rather than using this vector directly we will use it's component that is orthogonal to the vector used in the primary alignment. By default the longest edge, i.e. %s, of the face with the largest area will be aligned along the %s direct lattice vector. Together with the -primary alignment options the rigid body rotations of the shape are fixed.""" values = format_alignment_ids('NUM_UNIQUE_EDGES') self.parserobj.add_argument( '-secondary_alignment', type=str, nargs='+', default=SECONDARY_ALIGNMENT_DEFAULT_LIST, help=secondary_alignment_help % (', '.join(ALIGNMENT_KEYS), KEY_VALUE_SEPARATOR, TOKEN_SEPARATOR, AXIS_KEY, values, VECTOR_KEY, BASIS_KEY, HKL_VALUE, ABC_VALUE, XYZ_VALUE, SECONDARY_ALIGNMENT_DEFAULT[AXIS_KEY], SECONDARY_ALIGNMENT_DEFAULT[VECTOR_KEY])) include_axes_help = """Specifies that unit vectors defining the primary and seconary alignment axes be added to the returned template structure of the shape.""" self.parserobj.add_argument('-include_axes', action='store_true', help=include_axes_help) term_frag_help = """Choose a fragment from the following list to terminate the nanoparticle: %s. See the -allow_fragments option for additional information.""" self.parserobj.add_argument('-term_frag', type=str, choices=TERM_FRAGS, metavar=FRAGMENT_NAME, default=TERM_FRAG, help=term_frag_help % ', '.join(TERM_FRAGS)) term_bond_length_help = """Specify the bond length (in Angstrom) to use when terminating the nanoparticle with fragments. This is the length of the bond connecting the nanoparticle and fragment.""" self.parserobj.add_argument('-term_bond_length', type=float, default=TERM_BOND_LENGTH, help=term_bond_length_help) allow_fragments_help = """By default if the nanoparticle is being cut from a molecular solid then molecules that lie across the shape boundary are included in the nanoparticle. In this case the -term_frag option is ignored and no termination is performed. Use this option to instead fragment the molecule at the boundary of the shape. In this case the -term_frag option is honored. Note that because sometimes non-molecular solids may be incorrectly classified as molecular solids, this option may sometimes be necessary to force the cutting of nanoparticles from non-molecular solids.""" self.parserobj.add_argument('-allow_fragments', action='store_true', help=allow_fragments_help) input_file_help = """Specify a Maestro file containing a crystalline supercell from which to cut the specified shape of nanoparticle.""" self.parserobj.add_argument('-input_file', type=str, required=True, help=input_file_help) self.parserobj.add_argument('-verbose', action='store_true', help='Turn on verbose printing.') self.parserobj.add_argument('-help', '-h', action='help', default=argparse.SUPPRESS, help='Show this help message and exit.') self.parserobj.add_argument( '-version', '-v', action='version', default=argparse.SUPPRESS, version=_version, help="""Show the script's version number and exit.""")
[docs] def parseArgs(self, args): """ Parse the command line arguments. :type args: tuple :param args: command line arguments """ self.options = self.parserobj.parse_args(args)
[docs]class CheckInput(object): """ Manage checking user input. """
[docs] def checkShapeParams(self, shape, params, logger=None): """ Check the specified shape and shape parameters. :type shape: str :param shape: the shape of nanoparticle :type params: list :param params: contains floats specifying the shapes geometry :type logger: logging.Logger :param logger: output logger """ if shape not in ALL_SHAPES: UNSUPPORTED_SHAPE = ( 'The shape that you have requested ' 'is not supported. Please provide one of the following ' 'shapes: %s.') if logger: logger.error(UNSUPPORTED_SHAPE % ', '.join(ALL_SHAPES)) raise ValueError(UNSUPPORTED_SHAPE % ', '.join(ALL_SHAPES)) shape_obj = shapes.get_shape_object_by_name(shape) nparams = len(shape_obj.DEFAULT) if len(params) != nparams: INCORRECT_NUM_PARAMS = ( 'You have specified an incorrect ' 'number of parameters to define the specified shape. The ' 'shape %s requires %s parameters, i.e. %s.') if logger: logger.error(INCORRECT_NUM_PARAMS % (shape, nparams, \ shape_obj.DESCRIPTION)) raise ValueError(INCORRECT_NUM_PARAMS % (shape, nparams, \ shape_obj.DESCRIPTION))
[docs] def checkAlignment(self, shape, primary_alignment, secondary_alignment, logger=None): """ Check the specified alignment. :type shape: str :param shape: the shape of nanoparticle :type primary_alignment: dict :param primary_alignment: dictionary defining the primary alignment :type secondary_alignment: dict :param secondary_alignment: dictionary defining the secondary alignment :type logger: logging.Logger :param logger: output logger :rtype: dict, dict :return: the final primary and secondary alignment dictionaries """ def cast_alignment(alignment): for key, value in alignment.items(): if key == AXIS_KEY: alignment[key] = int(value) elif key == VECTOR_KEY: alignment[key] = numpy.array(list(map(float, value.split()))) return alignment # flatten the dicts and do some of the validation try: build_kwargs(dict_to_str(primary_alignment), PRIMARY_ALIGNMENT_DEFAULT) build_kwargs(dict_to_str(secondary_alignment), SECONDARY_ALIGNMENT_DEFAULT) except ValueError as msg: if logger: logger.error(msg) raise msg shape_obj = shapes.get_shape_object_by_name(shape) data_msg = ('Primary and secondary alignment axis IDs must be ' 'integers no larger than the maxima defined for the given ' 'shape. For a %s those maxima are %s and %s respectively. ' 'Alignment vectors must be non-zero floating point triples.') % \ (shape, shape_obj.NUM_UNIQUE_FACES, shape_obj.NUM_UNIQUE_EDGES) # do casting try: primary_alignment = cast_alignment(primary_alignment.copy()) secondary_alignment = cast_alignment(secondary_alignment.copy()) except ValueError: if logger: logger.error(data_msg) raise ValueError(data_msg) # check data if primary_alignment[VECTOR_KEY].size != 3 or secondary_alignment[ VECTOR_KEY].size != 3: if logger: logger.error(data_msg) raise ValueError(data_msg) if transform.get_vector_magnitude(primary_alignment[VECTOR_KEY]) == 0.0 or \ transform.get_vector_magnitude(secondary_alignment[VECTOR_KEY]) == 0.0: if logger: logger.error(data_msg) raise ValueError(data_msg) if shape_obj.TYPE != shapes.BASIC: if primary_alignment[AXIS_KEY] not in list(range(1, shape_obj.NUM_UNIQUE_FACES + 1)) or \ secondary_alignment[AXIS_KEY] not in list(range(1, shape_obj.NUM_UNIQUE_EDGES + 1)): if logger: logger.error(data_msg) raise ValueError(data_msg) # see MATSCI-2451 - sphere is symmetric SPHERE_MSG = ('With sphereical symmetry there are no faces ' 'nor edges and so all possible primary and secondary ' 'alignment axes are equivalent. As a result the choice ' 'is basis independent and so your input is taken in the ' '%s basis.') if shape == shapes.Sphere.NAME: if primary_alignment[BASIS_KEY] != XYZ_VALUE or \ secondary_alignment[BASIS_KEY] != XYZ_VALUE: primary_alignment[BASIS_KEY] = XYZ_VALUE secondary_alignment[BASIS_KEY] = XYZ_VALUE if logger: logger.warning(SPHERE_MSG % XYZ_VALUE) return primary_alignment, secondary_alignment
[docs]def group_subtuples_by_key_and_value(atuple): """ Take a tuple of two-element subtuples and group them by both key and value and collect into subsubtuples. For example, take ((1, 2), (1, 3), (4, 5), (6, 7), (8, 7)) and return (((1), (2, 3)), ((4), (5)), ((6, 8), (7))). :type atuple: tuple :param atuple: contains two-element tuples :rtype: tuple :return: contains two-element tuples each of which contains a keys tuple and a values tuple """ atuple_grouped = () # sort by value atuple_sorted = tuple(sorted(atuple, key=lambda x: x[1])) # handle by value next_atuple = [] for key, group in itertools.groupby(atuple_sorted, lambda x: x[1]): things = sorted([thing[0] for thing in group]) if len(things) > 1: atuple_grouped += ((tuple(things), (key,)),) else: next_atuple.append((things[0], key)) # sort by key next_atuple_sorted = tuple(sorted(next_atuple, key=lambda x: x[0])) # handle by key for key, group in itertools.groupby(next_atuple_sorted, lambda x: x[0]): things = sorted([thing[1] for thing in group]) atuple_grouped += (((key,), tuple(things)),) return atuple_grouped
[docs]class Nanoparticle(object): """ Manage the building of a nanoparticle. """ MSGWIDTH = 100
[docs] def __init__(self, supercell, shape=DEFAULT_SHAPE, origin=None, params=None, overwrite_vertices=VERTICES_DEFAULT, primary_alignment_dict=PRIMARY_ALIGNMENT_DEFAULT, secondary_alignment_dict=SECONDARY_ALIGNMENT_DEFAULT, include_axes=INCLUDE_AXES_DEFAULT, term_frag=TERM_FRAG, term_bond_length=TERM_BOND_LENGTH, allow_fragments=ALLOW_FRAGMENTS, logger=None, no_term_faces=None): """ Create an instance. :type supercell: `schrodinger.structure.Structure` :param supercell: the crystalline supercell from which to cut the nanaparticle :type shape: str :param shape: the shape of the nanoparticle :type origin: list :param origin: list of three floats specifying the origin of the shape in the crystal :type params: list :param params: list of parameters defining the specified shape :type overwrite_vertices: list :param overwrite_vertices: vertices to overwrite those of the created shape, just of list of floats :type primary_alignment_dict: dict :param primary_alignment_dict: a dictionary defining the primary alignment :type secondary_alignment_dict: dict :param secondary_alignment_dict: a dictionary defining the secondary alignment :type include_axes: bool :param include_axes: include unit vectors for the primary and secondary alignment axes in the template :type term_frag: str :param term_frag: the fragment by which to terminate the nanoparticle :type term_bond_length: float :param term_bond_length: the bond length of the bond connecting the nanoparticle with the fragment :type allow_fragments: bool :param allow_fragments: specify if molecular fragments are allowed at the boundary of the nanoparticle :type logger: logging.Logger :param logger: output logger :type no_term_faces: list :param no_term_faces: by default when terminating polyhedral nanoparticles all faces are considered, this option specifies that the given integer faces not be terminated (integers start at zero) """ if origin is None: origin = shapes.ORIGIN if not params: params = shapes.get_shape_object_by_name(shape).DEFAULT self.supercell = supercell self.shape = shape self.origin = numpy.array(origin) self.params = params self.overwrite_vertices = overwrite_vertices self.primary_alignment_dict = primary_alignment_dict self.secondary_alignment_dict = secondary_alignment_dict self.include_axes = include_axes self.term_frag = term_frag self.term_bond_length = term_bond_length self.allow_fragments = allow_fragments self.logger = logger # the face indicies must correspond with those given in # shapes.<ShapeObject>.INDICES if no_term_faces is None: self.no_term_faces = [] else: self.no_term_faces = no_term_faces self.is_infinite = None self.molecule_completion = None self.lattice_parameters = None self.plane = None self.shape_object = None self.template = None self.inside_indices = None self.boundary_indices = None self.outside_indices = None self.no_term_indices = set() self.nanoparticle = self.supercell.copy() self.polyhedron = None self.nanoparticle_natoms = None self.nanoparticle_volume = None self.nanoparticle_surface_area = None self.nanoparticle_circumradius = None self.nanoparticle_density = None self.embedded = None
[docs] def checkInput(self): """ Check user input. """ CheckInput().checkShapeParams(self.shape, self.params, self.logger) self.primary_alignment_dict, self.secondary_alignment_dict = \ CheckInput().checkAlignment(self.shape, self.primary_alignment_dict, self.secondary_alignment_dict, self.logger)
[docs] def setMoleculeCompletion(self): """ Set whether or not molecules should be completed at the nanoparticle boundary. """ self.molecule_completion = not self.allow_fragments if self.molecule_completion: try: self.is_infinite = xtal.is_infinite(self.nanoparticle) except ValueError: self.is_infinite = False self.molecule_completion = not self.is_infinite if not self.nanoparticle.bond: self.molecule_completion = True if self.molecule_completion: self.term_frag = TERM_FRAGS[0]
[docs] def isInside(self, aatom): """ Return whether or not the given atom is inside the shape. If the given atom has not yet been categorized then categorize it and append the result to the collections. :type aatom: `schrodinger.structure._StructureAtom` :param aatom: the atom in question :rtype: bool :return: True if the atom is inside the shape, False otherwise """ if aatom.index in self.inside_indices: inside = True elif aatom.index in self.outside_indices: inside = False else: inside = self.shape_object.pointInside(numpy.array(aatom.xyz)) if inside: self.inside_indices.add(aatom.index) else: self.outside_indices.add(aatom.index) # if we will be performing a termination then store # this outside atom's index unless the # atom-to-polyhedron-center line segment intersects # a plane of a polyhedron face (not the face itself # but rather the plane that the face resides in) that # is supposed to be terminated if self.polyhedron and self.term_frag != TERM_FRAGS[0] and \ self.no_term_faces: terminatable = False for aface in self.shape_object.faces: if aface.index - 1 not in self.no_term_faces and \ aface.plane_intersected: terminatable = True break if not terminatable: self.no_term_indices.add(aatom.index) return inside
[docs] def checkIntersectingBond(self, atom1, atom2): """ Check if the bond given by the specified atoms can be intersected by the shape of nanoparticle. We will only be either keeping a single atom from this bond (no termination) or using it to terminate with a fragment that is expected to connect via a single bond. :type atom1: int :param atom1: the first atom index of the bond :type atom2: int :param atom2: the second atom index of the bond """ CAN_NOT_INTERSECT_MSG = ( 'The nanoparticle specifications ' 'that you have provided result in a shape that is ' 'intersecting bonds in the crystalline supercell ' 'that ought not to be intersected, i.e. those ' 'of bond order greater than one. The nanoparticle ' 'should be cut from the supercell at single bonds.') if self.nanoparticle.getBond(atom1, atom2).order > 1: if self.logger: self.logger.error(CAN_NOT_INTERSECT_MSG) raise ValueError(CAN_NOT_INTERSECT_MSG)
[docs] def partitionAtoms(self): """ Return three non-overlapping lists of atom indices, (1) for atoms inside the shape, (2) for atoms belonging to bonds which intersect the shape boundary (a (inside, outside) tuple of atom indices for the intersecting bonds), and (3) for atoms that lie outside of shape. """ self.inside_indices, self.boundary_indices, self.outside_indices = \ set(), set(), set() # handle two cases of nanoparticles (1) cut from a lattice with # infinite bonding, i.e. atomic solid, and (2) cut from a # lattice with finite bonding, i.e. molecular or VDW solid, for # (1) termination is relevant and is honored and so boundary # information is necessary and molecules not completed, for # (2) termination is not relevant and is not honored and so # boundary information is not necessary and molecules are # completed if self.molecule_completion: nanoparticle_indices = set( range(1, self.nanoparticle.atom_total + 1)) while nanoparticle_indices: aatom = self.nanoparticle.atom[nanoparticle_indices.pop()] if not self.isInside(aatom): continue # make all atoms inside but label those that are outside # so that the slab builder will treat them as if they # are terminating atoms for mol_atom in aatom.getMolecule().atom: nanoparticle_indices.discard(mol_atom.index) if not self.isInside(mol_atom): mol_atom.property[ORIGINAL_ATOM_KEY] = False self.outside_indices.remove(mol_atom.index) self.inside_indices.add(mol_atom.index) else: for target_atom in self.nanoparticle.atom: target_inside = self.isInside(target_atom) boundary_indices = set() for bonded_atom in target_atom.bonded_atoms: bonded_inside = self.isInside(bonded_atom) if bonded_inside != target_inside: if self.term_frag != TERM_FRAGS[0]: self.checkIntersectingBond(target_atom.index, bonded_atom.index) if bonded_inside: boundary_index = (bonded_atom.index, target_atom.index) else: boundary_index = (target_atom.index, bonded_atom.index) boundary_indices.add(boundary_index) # update collections, if the target atom is inside and bonded exclusively # to outside atoms then make the target atom an outside atom (this is # a rare case but can happen for certain crystals when an inside atom # is close to a polyhedron vertex) if target_inside and target_atom.bond_total == len( boundary_indices): self.inside_indices.remove(target_atom.index) self.outside_indices.add(target_atom.index) self.boundary_indices.difference_update(boundary_indices) else: self.boundary_indices.update(boundary_indices) # make unique lists flattened = set() for abond in self.boundary_indices: flattened.update(set(abond)) self.inside_indices = sorted(self.inside_indices.difference(flattened)) self.outside_indices = sorted( self.outside_indices.difference(flattened)) self.boundary_indices = list(self.boundary_indices)
[docs] def removeAndRenumber(self, to_remove=None, renumber_map=None): """ Remove the specified atoms from the nanoparticle and renumber all collections of indices or just renumber the collections using the specified renumber map. :type to_remove: list or none :param to_remove: indices of atoms to remove :type renumber_map: dict or none :param renumber_map: a renumber map to use in renumbering collections """ def process_list(alist): return [renumber_map[idx] for idx in alist if \ renumber_map[idx] is not None] # just return if there is nothing to do if not to_remove and not renumber_map: return if to_remove: renumber_map = self.nanoparticle.deleteAtoms(to_remove, renumber_map=True) boundary_indices_renumbered = [] for atuple in self.boundary_indices: atuple_renumbered = tuple((renumber_map[x] for x in atuple)) if None not in atuple_renumbered: boundary_indices_renumbered.append(atuple_renumbered) self.boundary_indices = boundary_indices_renumbered self.inside_indices = process_list(self.inside_indices) self.outside_indices = process_list(self.outside_indices)
[docs] def cutAwayExcess(self): """ Remove the unneeded atoms from the crystalline supercell. """ # determine the atoms to remove, if there are boundary indices then # handle splitting them up for two cases (1) no termination and (2) # a specific type of termination in which certain atoms should not # be terminated, if there are not boundary indices then just proceed to_remove = list(self.outside_indices) if self.boundary_indices: if self.term_frag == TERM_FRAGS[0]: keep, remove = list(zip(*(self.boundary_indices))) self.inside_indices += list(set(keep)) to_remove += list(set(remove)) elif self.no_term_indices: tmp_boundary_indices = list(self.boundary_indices) self.boundary_indices = [] for atuple in tmp_boundary_indices: inside, outside = atuple if outside in self.no_term_indices: if inside not in self.inside_indices: self.inside_indices.append(inside) if outside not in to_remove: to_remove.append(outside) else: self.boundary_indices.append(atuple) to_remove.sort() # remove atoms and renumber collections self.removeAndRenumber(to_remove=to_remove)
[docs] def handleOneToMany(self, inside_index, outside_indices): """ Handle the one to many situation, i.e. a single atom inside the region of interest bonded to multiple atoms outside of the region of interest. :type inside_index: int :param inside_index: the index of the inside atom :type outside_indices: list :param outside_indices: the indices of the outside atoms """ # remove bonding amongst outside atoms to_remove = set() for outside_index in outside_indices: for outside_atom in self.nanoparticle.atom[ outside_index].bonded_atoms: if outside_atom.index != inside_index: to_remove.add( tuple(sorted([outside_index, outside_atom.index]))) for atuple in to_remove: self.nanoparticle.deleteBond(*atuple) # make outside atoms hydrogen and set bond lengths for index in outside_indices: self.nanoparticle.atom[index].element = 'H' self.nanoparticle.adjust(self.term_bond_length, inside_index, index) self.nanoparticle.atom[index].property[ORIGINAL_ATOM_KEY] = False
[docs] def handleManyToOne(self, inside_indices, outside_index): """ Handle the many to one situation, i.e. multiple atoms inside the region of interest are bonded to a single atom that is outside of the region of interest. :type inside_indices: list :param inside_indices: the indices of the inside atoms :type outside_index: int :param outside_index: the index of the outside atom :rtype: list :return: contains (inside, outside) tuples of newly created bonds """ outside_vec = numpy.array(self.nanoparticle.atom[outside_index].xyz) # terminate each inside index by bonding to a newly added hydrogen # along the pre-existing bond vectors, clashes will be resolved with # subsequent optimization of the structure with a real physical model new_bonds = [] for inside_index in inside_indices: inside_vec = numpy.array(self.nanoparticle.atom[inside_index].xyz) bond_unit_vec = transform.get_normalized_vector(outside_vec - inside_vec) x_term, y_term, z_term = inside_vec + self.term_bond_length * bond_unit_vec h_term = self.nanoparticle.addAtom('H', x_term, y_term, z_term) self.nanoparticle.deleteBond(inside_index, outside_index) self.nanoparticle.addBond(inside_index, h_term.index, 1) new_bonds.append((inside_index, h_term.index)) return new_bonds
[docs] def configureBonds(self): """ Configure the nanoparticle bonds that intersect the shape. """ # update data structure to ease collecting of intersecting bonds # that share an inside or outside atom boundary_indices_grouped = \ group_subtuples_by_key_and_value(tuple(self.boundary_indices)) # handle three situations for nanoparticles: (1) one-to-many, a single # atom in the region of interest is bonded to multiple atoms outside # the region of interest, here the outside atoms themselves are used # to terminate, (2) many-to-one, multiple atoms in the region of # interest are bonded to a single atom outside the region of # interest, here the outside atom can't be used and so we create # several new terminating atoms, (3) one-to-one, simple case to_remove = [] for atuple in boundary_indices_grouped: inside, outside = atuple if len(inside) > 1: new_bonds = self.handleManyToOne(list(inside), outside[0]) obsolete_bonds = set(zip(inside, len(inside) * outside)) old_bonds = list( set(self.boundary_indices).difference(obsolete_bonds)) self.boundary_indices = old_bonds + new_bonds to_remove.append(outside[0]) else: self.handleOneToMany(inside[0], list(outside)) # remove atoms and renumber collections self.removeAndRenumber(to_remove=to_remove)
[docs] def doTermination(self): """ Terminate the nanoparticle. """ def get_bonded_indices(aatom): return [bonded_atom.index for bonded_atom in \ self.nanoparticle.atom[aatom].bonded_atoms] frag_group, frag_name = TERM_DICT[self.term_frag] term_bonds = list(self.boundary_indices) while term_bonds: # get from and to indices from_index, to_index = term_bonds.pop() # get the other bonds for the from atom (this is so that we can later # figure out the atom index of the fragment atom that has replaced # the terminating hydrogen) bonded_atoms = get_bonded_indices(from_index) bonded_atoms.remove(to_index) # attach the fragment renumber_map = build.attach_fragment(self.nanoparticle, from_index, to_index, frag_group, frag_name, TERM_DIRECTION) # determine the atom number of the fragment atom that has replaced # the terminating hydrogen bonded_atoms = set( [renumber_map[bonded_atom] for bonded_atom in bonded_atoms]) updated_to_index = \ set(get_bonded_indices(renumber_map[from_index])).difference(bonded_atoms).pop() # adjust the bond length of the new nanoparticle-fragment bond self.nanoparticle.adjust(self.term_bond_length, renumber_map[from_index], \ updated_to_index) # update term_bonds term_bonds = [ (renumber_map[x[0]], renumber_map[x[1]]) for x in term_bonds ] self.removeAndRenumber(renumber_map=renumber_map)
[docs] def setNanoparticleProperties(self): """ Set nanoparticle properties. """ # set title self.nanoparticle.title = self.supercell.title + '-' + self.shape # set atom and bond visualization representation set_representation(self.nanoparticle) # set number of nanoparticle atoms if self.boundary_indices: inside_indices = list(set(list(zip(*(self.boundary_indices)))[0])) else: inside_indices = [] inside_indices = self.inside_indices + inside_indices self.nanoparticle_natoms = len(inside_indices) self.nanoparticle.property[ NANOPARTICLE_NATOMS_KEY] = self.nanoparticle_natoms # set nanoparticle volume self.nanoparticle_volume = self.shape_object.volume self.nanoparticle.property[ NANOPARTICLE_VOLUME_KEY] = self.nanoparticle_volume # set nanoparticle surface area self.nanoparticle_surface_area = self.shape_object.surface_area self.nanoparticle.property[NANOPARTICLE_SURFACE_AREA_KEY] = \ self.nanoparticle_surface_area # set nanoparticle circumradius if self.polyhedron and self.shape_object.TYPE != shapes.PRISM: self.nanoparticle_circumradius = self.shape_object.circumradius self.nanoparticle.property[NANOPARTICLE_CIRCUMRADIUS_KEY] = \ self.nanoparticle_circumradius # set nanoparticle density volume_cm3 = self.nanoparticle_volume * math.pow( old_div(1.0, CM_TO_ANGSTROM), 3) total_mass = 0.0 for index in inside_indices: total_mass += self.nanoparticle.atom[index].atomic_weight total_mass = old_div(total_mass, scipy_constants.N_A) self.nanoparticle_density = old_div(total_mass, volume_cm3) self.nanoparticle.property[NANOPARTICLE_DENSITY_KEY] = \ self.nanoparticle_density
[docs] def printParams(self): """ Log the parameters. """ HEADER = 'Nanoparticle Parameters' # shape parameters shape_name = textlogger.get_param_string('Shape name', self.shape, self.MSGWIDTH) shape_type = textlogger.get_param_string('Shape type', self.shape_object.TYPE, self.MSGWIDTH) num_unique_faces = textlogger.get_param_string( 'Num. primary axes', self.shape_object.NUM_UNIQUE_FACES, self.MSGWIDTH) num_unique_edges = textlogger.get_param_string( 'Num. secondary axes', self.shape_object.NUM_UNIQUE_EDGES, self.MSGWIDTH) shape_origin = textlogger.get_param_string('Shape origin', self.origin, self.MSGWIDTH) primary_alignment = textlogger.get_param_string( 'Primary alignment', dict_to_str(self.primary_alignment_dict), self.MSGWIDTH) secondary_alignment = textlogger.get_param_string( 'Secondary alignment', dict_to_str(self.secondary_alignment_dict), self.MSGWIDTH) overwrite_vertices = textlogger.get_param_string( 'Overwrite vertices given', bool(len(self.overwrite_vertices)), self.MSGWIDTH) shape_params = [ '%s (%s)' % (str(x[0]), x[1]) for x in zip(self.params, self.shape_object.DESCRIPTION) ] shape_params = ', '.join(shape_params) shape_params = textlogger.get_param_string( 'Shape parameters / Ang. & degree', shape_params, self.MSGWIDTH) shape_volume = '%.4f' % round(self.nanoparticle_volume, NUM_DECIMAL) shape_volume = textlogger.get_param_string('Shape volume / Ang.^3', shape_volume, self.MSGWIDTH) shape_surface_area = '%.4f' % round(self.nanoparticle_surface_area, NUM_DECIMAL) shape_surface_area = textlogger.get_param_string( 'Shape surface area / Ang.^2', shape_surface_area, self.MSGWIDTH) if self.polyhedron: shape_num_verts = textlogger.get_param_string( 'Shape number of vertices', len(self.shape_object.vertices), self.MSGWIDTH) shape_num_faces = textlogger.get_param_string( 'Shape number of faces', len(self.shape_object.faces), self.MSGWIDTH) if self.shape_object.TYPE != shapes.PRISM: shape_circumradius = '%.4f' % round( self.nanoparticle_circumradius, NUM_DECIMAL) shape_circumradius = textlogger.get_param_string( 'Shape circumradius / Ang.', shape_circumradius, self.MSGWIDTH) embedded = textlogger.get_param_string('Embedded in supercell', \ self.embedded, self.MSGWIDTH) # termination parameters term_frag = textlogger.get_param_string('Terminating fragment', self.term_frag, self.MSGWIDTH) term_bond_length = textlogger.get_param_string( 'Terminating bond length / Ang.', self.term_bond_length, self.MSGWIDTH) allow_fragments = textlogger.get_param_string('Allow fragments', self.allow_fragments, self.MSGWIDTH) # nanoparticle parameters nanoparticle_natoms = textlogger.get_param_string( 'Nanoparticle number of atoms', self.nanoparticle_natoms, self.MSGWIDTH) nanoparticle_density = '%.4f' % round(self.nanoparticle_density, NUM_DECIMAL) nanoparticle_density = textlogger.get_param_string( 'Nanoparticle density / g/cm^3', nanoparticle_density, self.MSGWIDTH) # lattice parameters if self.lattice_parameters: lattice_parameters = \ textlogger.get_param_string('Lattice (a,b,c,alpha,beta,gamma) / Ang. & degree', ', '.join([str(round(param, 3)) for param in self.lattice_parameters]), self.MSGWIDTH) self.logger.info(HEADER) self.logger.info('-' * len(HEADER)) self.logger.info(shape_name) self.logger.info(shape_type) self.logger.info(num_unique_faces) self.logger.info(num_unique_edges) self.logger.info(shape_origin) self.logger.info(primary_alignment) self.logger.info(secondary_alignment) self.logger.info(overwrite_vertices) self.logger.info(shape_params) self.logger.info(shape_volume) self.logger.info(shape_surface_area) if self.polyhedron: self.logger.info(shape_num_verts) self.logger.info(shape_num_faces) if self.shape_object.TYPE != shapes.PRISM: self.logger.info(shape_circumradius) self.logger.info(embedded) self.logger.info(term_frag) self.logger.info(term_bond_length) self.logger.info(allow_fragments) self.logger.info(nanoparticle_natoms) self.logger.info(nanoparticle_density) if self.lattice_parameters: self.logger.info(lattice_parameters) self.logger.info('')
[docs] def getAlignmentVector(self, alignment_dict): """ Return the alignment vector. :type alignment_dict: dict :param alignment_dict: defines the alignment :rtype: numpy.array :return: the vector on which to align """ NO_KEYS_MSG = ( 'You have specified an alignment using an %s or %s basis. ' 'Such alignments require the following structure properties to exist ' 'in your input Maestro file of the supercell, i.e. %s.') if alignment_dict[BASIS_KEY] != XYZ_VALUE: lattice_parameter_keys = get_lattice_properties() if not check_if_lattice_properties_exist( self.supercell, keys=lattice_parameter_keys): msg = NO_KEYS_MSG % (HKL_VALUE, ABC_VALUE, ', '.join(lattice_parameter_keys)) if self.logger: self.logger.error(msg) raise ValueError(msg) else: self.lattice_parameters = [ self.supercell.property[x] for x in lattice_parameter_keys ] a_vec, b_vec, c_vec = xtal.get_lattice_vectors( *(self.lattice_parameters)) if alignment_dict[BASIS_KEY] == ABC_VALUE: terms = list( zip(alignment_dict[VECTOR_KEY], [a_vec, b_vec, c_vec])) alignment_vector = sum( [coef * vector for coef, vector in terms]) elif alignment_dict[BASIS_KEY] == HKL_VALUE: h_index, k_index, l_index = alignment_dict[VECTOR_KEY] self.plane = plane.CrystalPlane(h_index, k_index, l_index, a_vec, b_vec, c_vec) alignment_vector = self.plane.normal_vec else: alignment_vector = alignment_dict[VECTOR_KEY] return alignment_vector
[docs] def translateToPlane(self): """ Translate the shape object so that the reference face is coplanar with the requested crystal plane. """ face_idx = self.primary_alignment_dict[AXIS_KEY] face = self.shape_object.reference_faces[face_idx - 1] unit_normal = transform.get_normalized_vector(self.plane.normal_vec) to_plane = self.plane.normal_vec - numpy.dot(face.center, unit_normal) * unit_normal self.shape_object.translateVertices(self.shape_object.vertices, to_plane)
[docs] def overwriteVertices(self): """ Overwrite the vertices of the shape object. """ BAD_VERTS_MSG = ( 'You must specify at least 4 overwrite vertices and each ' 'vertex must be defined using 3 floats.') if len(self.overwrite_vertices) < 12 or len( self.overwrite_vertices) % 3: if self.logger: self.logger.error(BAD_VERTS_MSG) raise ValueError(BAD_VERTS_MSG) vertices = [] for index, first_index in enumerate( list(range(0, len(self.overwrite_vertices), 3)), 1): coord = numpy.array( self.overwrite_vertices[first_index:first_index + 3]) vertices.append(shapes.Vertex(index, coord)) self.shape_object.updateShape(vertices)
[docs] def runIt(self, template_only=False): """ Create the nanoparticle. :type template_only: bool :param template_only: If True, the method will return immediately after the template is created and before the nanoparticle has been fully synthesized. Using this parameter means that the .template structure is valid but no .nanoparticle structure is created - the .nanoparticle property will be set to None. """ # check input self.checkInput() # get reference face and edge axis IDs ref_face_idx = self.primary_alignment_dict[AXIS_KEY] ref_edge_idx = self.secondary_alignment_dict[AXIS_KEY] # get along vectors ref_face_normal_along = self.getAlignmentVector( self.primary_alignment_dict) ref_edge_along = self.getAlignmentVector(self.secondary_alignment_dict) # get shape class shape_object = shapes.get_shape_object_by_name(self.shape) # set whether or not we have a polyhedral shape self.polyhedron = shape_object.TYPE in shapes.POLYHEDRON_TYPES # get shape object try: if self.polyhedron: self.shape_object = shape_object(*(self.params), center=self.origin, \ ref_face_idx=ref_face_idx, ref_face_normal_along=ref_face_normal_along, \ ref_edge_idx=ref_edge_idx, ref_edge_along=ref_edge_along) else: self.shape_object = shape_object(*(self.params), center=self.origin) except ValueError as msg: if self.logger: self.logger.error(msg) raise msg # if we have a polyhedron then do some things if self.polyhedron: # translate reference face to crystal plane along the normal and allow the # user specified origin to change in this case if self.primary_alignment_dict[BASIS_KEY] == HKL_VALUE: self.translateToPlane() self.origin = self.shape_object.center # overwrite vertices if self.overwrite_vertices: self.overwriteVertices() # set template if self.include_axes: self.shape_object.addAlignmentAxesToTemplate() self.template = self.shape_object.template.copy() # Mark the template if self.template: self.template.property[NANOPARTICLE_TEMPLATE] = True if template_only: # The nanoparticle isn't valid at this point, so blank it out self.nanoparticle = None return # determine how to handle molecules at the nanoparticle boundary self.setMoleculeCompletion() xtal.delete_all_pbc_bonds(self.nanoparticle) # the following issue pertains to convex polyhedron shapes only, # in shapes.Face.intersectSegmentAndPlane, from the following execution # path partitionAtoms -> isInside -> shapes.<Shape>.pointInside, a # divide-by-zero RuntimeWarning can be shown, we want to silent that # because it is being properly processed in that intersectSegmentAndPlane # method warnings.simplefilter('ignore', RuntimeWarning) # partition atoms self.partitionAtoms() # check if we are completely outside the region of interest if len(self.outside_indices) == self.nanoparticle.atom_total: NO_ATOMS_MSG = ( 'Given your provided crystalline supercell ' 'and shape parameters this nanoparticle contains zero ' 'atoms.') if self.logger: self.logger.error(NO_ATOMS_MSG) raise ValueError(NO_ATOMS_MSG) # if we have a polyhedron then check if it was embedded in the # supercell such that all faces, not planes containing faces, have been # intersected by at least one pointInside query, this information # tells us if part of the requested polyhedron lies outside the # region of interest, this is currently allowed as it allows # users to be more flexible with the way in which they use this # script, for example they can pit surfaces, etc. if self.polyhedron: self.embedded = \ self.shape_object.allFacesIntersected() if not self.embedded: if self.logger: NOT_EMBEDDED_MSG = ( 'The polyhedron that has been ' 'specified is not entirely embedded in the ' 'given crystalline supercell, meaning that ' 'there is at least one face hanging outside ' 'of the supercell boundaries. The returned ' 'nanoparticle may thus be incomplete, i.e. ' 'will not entirely fill the shape you requested.') self.logger.warning(NOT_EMBEDDED_MSG) # cut away unneeded atoms and if terminating then configure # the bonds and terminate the nanoparticle self.cutAwayExcess() if self.term_frag != TERM_FRAGS[0]: self.configureBonds() if self.term_frag != TERM_FRAG: self.doTermination() color.apply_color_scheme(self.nanoparticle, 'element') self.nanoparticle.retype() # set nanoparticle properties self.setNanoparticleProperties() # log nanoparticle properties if self.logger: self.printParams()
[docs]def build_kwargs(kwargs_list, defaults, acceptable_keys=None, token_separator=TOKEN_SEPARATOR, key_value_separator=KEY_VALUE_SEPARATOR, all_choices=None): """ Build the kwargs dictionary from an unformatted and unvalidated list of kwargs. :type kwargs_list: list :param kwargs_list: list of strings that contain kwargs in one form or another :type defaults: dict :param defaults: default kwargs to be updated :type acceptable_keys: list :param acceptable_keys: list of acceptable keys :type token_separator: str :param token_separator: the separator used for tokens :type key_value_separator: str :param key_value_separator: the separator used in key-value pairs :type all_choices: dict :param all_choices: if the values of certain keys are limited then collect those options here in lists for validation purposes :rtype: dict :return: dictionary of kwargs, formatted, validated, and contains unspecified defaults """ if acceptable_keys is None: acceptable_keys = ALIGNMENT_KEYS if all_choices is None: all_choices = {BASIS_KEY: BASIS_VALUES} general_msg = ('Alignment options must be specified using tokens of the form ' '<key>%s<value>, where key is a valid key from %s, and where multiple ' 'tokens are separated with a \'%s\'.') % \ (key_value_separator, acceptable_keys, token_separator) choices_msg = ('The %s sub-option must take on a value in %s.') pairs = [] for token in ' '.join(kwargs_list).split(token_separator): if token.count(key_value_separator): key, value = [ x.strip() for x in token.strip().split(key_value_separator, 1) ] if key not in acceptable_keys or value == '': raise ValueError(general_msg) else: choices = all_choices.get(key) if choices is None or value in choices: pairs.append((key, value)) else: raise ValueError(choices_msg % (key, choices)) else: raise ValueError(general_msg) kwargs = defaults.copy() kwargs.update(OrderedDict(pairs)) return kwargs
[docs]def set_representation(astructure): """ Set the representation of the given structure. :type astructure: `schrodinger.structure.Structure` :param astructure: the structure to set the representation for """ astructure.applyStyle()