Source code for schrodinger.application.matsci.zmutils

"""
Contains classes for working with Z-Matrices

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

# Contributors: Dave Giesen

from copy import deepcopy

import numpy

from schrodinger import structure
from schrodinger.application.jaguar import input as jaguar_input
from schrodinger.application.matsci import pbcutils
from schrodinger.infra import mm
from schrodinger.structutils.transform import get_normalized_vector


[docs]def rotation_matrix(axis, angle): """ Get rotation matrix based on the Euler-Rodrigues formula. :param list axis: Axis, defined by 3 floats, will be normalized :param float angle: Angle (deg) to rotate around the axis :rtype: numpy.array :return: Rotation matrix (3 x 3) """ # See: https://en.wikipedia.org/wiki/Euler%E2%80%93Rodrigues_formula # This matrix is different compared to one computed with # structutils.transform.get_rotation_matrix # It seems that the difference comes from the left/right handed rotation # Normalize the axis axis = get_normalized_vector(numpy.array(axis)) angle = numpy.deg2rad(angle) a = numpy.cos(angle / 2) b, c, d = -axis * numpy.sin(angle / 2) a2, b2, c2, d2 = numpy.power([a, b, c, d], 2) return numpy.array( [[a2 + b2 - c2 - d2, 2 * (b * c - a * d), 2 * (b * d + a * c)], [2 * (b * c + a * d), a2 + c2 - b2 - d2, 2 * (c * d - a * b)], [2 * (b * d - a * c), 2 * (c * d + a * b), a2 + d2 - b2 - c2]] ) # yapf: disable
[docs]class ZAtom(object): """ A single atom in a Z-matrix """ COORD_LETTERS = ['r', 'a', 'd']
[docs] def __init__(self, index, element, values, constraints, neighbor_indexes, neighbor_elements): """ Initialize ZAtom object :param int index: Atom index with the structure :param str element: Atom element :param list values: Atom values (bond distance, angle, torsion) :param set constraints: Atom constraints :param list neighbor_indexes: Atom neighboring indices :param list neighbor_elements: Atom neighboring elements """ self.index = index self.element = element self.values = list(values) self.constraints = set(constraints) self.neighbor_indexes = list(neighbor_indexes) self.neighbor_elements = list(neighbor_elements)
[docs] @classmethod def fromJagin(cls, jagin, index): """ Create a ZAtom object from jaguar input. :type jagin: `schrodinger.application.jaguar.input.JaguarInput` :param jagin: The JaguarInput object the Z-matrix is for :type index: int :param index: The atom index (1-based) that this ZAtom is for :rtype: ZAtom :return: New ZAtom object """ struct = jagin.getStructure() atom = struct.atom[index] ZMAT_NUM = 0 (ctype, ind1, val1, ind2, val2, ind3, val3) = mm.mmjag_zmat_atom_get(jagin.handle, ZMAT_NUM, atom.index) neighbor_indexes = [x for x in [ind1, ind2, ind3] if x] neighbor_elements = [struct.atom[x].element for x in neighbor_indexes] # The value for an unused coordinate is 0, but 0 is also a possible # value for real angles and dihedrals. Zipping the values and indexes # together truncates the value list at the number of real coordinates. valdexes = list(zip([val1, val2, val3], neighbor_indexes)) values = [x for x, y in valdexes] index = atom.index element = atom.element constraints = set() return cls(index, element, values, constraints, neighbor_indexes, neighbor_elements)
def __deepcopy__(self, memo): """ Deep copy a ZAtom. :param memo: Memo to be passed to the deepcopy calls (if any) :type memo: Dict or None :rtype: ZAtom :return: Newly copied ZAtom """ cls = type(self) newobj = cls(self.index, self.element, self.values, self.constraints, self.neighbor_indexes, self.neighbor_elements) return newobj
[docs] def constrainCoord(self, coord): """ Constrain the given coord so it can't be optimized :type coord: int :param coord: 0 (bond), 1 (angle) or 2 (dihedral) """ self.constraints.add(coord)
[docs] def freeCoord(self, coord): """ Allow the given coord to be optimized :type coord: int :param coord: 0 (bond), 1 (angle) or 2 (dihedral) """ self.constraints.remove(coord)
[docs] def constrainAll(self): """ Constrain all the coords so they can't be optimized """ self.constraints.update(range(len(self.COORD_LETTERS)))
[docs] def freeAll(self): """ Allow all the coords to be optimized """ self.constraints.clear()
[docs] def bumpIndexes(self, bump): """ Modify all the indexes (both for self and any referenced atoms) by a constant number. Used when this z-matrix is being added to another, and now atom 1 is actually atom 20... :type bump: int :param bump: The amount to modify each index """ self.index = self.index + bump self.neighbor_indexes = [x + bump for x in self.neighbor_indexes]
[docs] def getSymbolicLine(self): """ Get the line that defines the atoms for the bond, angle and dihedral and also the coordinate names. i.e. C11 H1 r11 C2 a11 C7 d11 :rtype: str :return: The string defining the z-matrix coordinates for this atom. Includes the trailing return character """ tokens = [self.getAtomName(self.element, self.index)] for coord, (element, index) in enumerate( zip(self.neighbor_elements, self.neighbor_indexes)): neighbor_name = self.getAtomName(element, index) coord_name = self.getCoordName(coord) tokens.extend([neighbor_name, coord_name]) return ' '.join(tokens) + '\n'
[docs] def getVariableLines(self): """ Get a series of lines that defines each coordinate, i.e.:: r11 = 1.452 a11 = 102.4 d11 = 180 :rtype: str :return: The string defining the z-matrix coordinate values for this atom. Includes the trailing return character at the end of the block. """ lines = "" for index, value in enumerate(self.values): cname = self.getCoordName(index) if index in self.constraints: hashtag = '#' else: hashtag = "" lines += cname + ' = ' + "%.12f" % value + hashtag + '\n' return lines
[docs] def getAtomName(self, element, index): """ Get the name of an atom with the given element and index :type element: str :param element: The atomic symbol of the atom :type index: int :param index: The index of the atom :rtype: str :return: The name of the atom, such as "C12" """ return element + str(index)
[docs] def getCoordName(self, coord): """ Get the name of a coordinate for this atom, such as r11 or d7 :type coord: int :param coord: 0 (bond), 1 (angle) or 2 (dihedral) :rtype: str :return: The name of the coordinate, such as "r12" """ return self.COORD_LETTERS[coord] + str(self.index)
[docs] def addCoord(self, index, element, value): """ Add an internal coordinate for this atom referenced to a target atom. Note that coordinates must be added in the order bond, angle, dihedral. :type index: int :param index: The atom index of the target atom (such as the atom the bond extends to) :type element: str :param element: The element of the target atom :type value: float :param value: The value of this coordinate """ self.neighbor_indexes.append(index) self.neighbor_elements.append(element) self.values.append(value)
[docs] def changeCoord(self, coord, value, index=None, element=None): """ Change the definition of an existing internal coordinate. If index and element are passed in the target atom for the coordinate will be changed. :type coord: int :param coord: 0 (bond), 1 (angle) or 2 (dihedral) :type value: float :param value: The value of this coordinate :type index: int :param index: The atom index of the target atom (such as the atom the bond extends to), 1-based. Both index and element must be supplied for them to be used. :type element: str :param element: The element of the target atom. Both index and element must be supplied for them to be used. """ if index and element: self.neighbor_indexes[coord] = index self.neighbor_elements[coord] = element self.values[coord] = value
[docs]class ZMatrix(object): """ Contains the z-matrix for a structure """
[docs] def __init__(self, struct=None): """ Create a ZMatrix instance :type struct: `schrodinger.structure.Structure` or None :param struct: The structure this zmatrix is for """ if struct: jagin = jaguar_input.JaguarInput(structure=struct, name='temp') jagin.makeInternalCoords() self.atoms = [ ZAtom.fromJagin(jagin, x) for x in range(1, jagin.getAtomCount() + 1) ] self.setBasisVectors(struct) else: self.atoms = []
[docs] def setBasisVectors(self, struct): """ Set origin and basis vectors. These can be used to recover original Cartesian coordinates from Z-matrix. :param structure.Structure: Input structure """ # See getStructure function on how these are used if struct.atom_total >= 3: self.origin = numpy.array(struct.atom[1].xyz) self.bond1_vec = get_normalized_vector( numpy.asarray(struct.atom[2].xyz) - self.origin) self.bond2_vec = get_normalized_vector( numpy.asarray(struct.atom[3].xyz) - numpy.asarray(struct.atom[2].xyz)) else: self.origin = numpy.zeros(3) # Origin at zero self.bond1_vec = numpy.array([1, 0, 0]) # First vector along X self.bond2_vec = numpy.array([0, 1, 0]) # Second vector along Y
[docs] def getStructure(self): """ Create and return structure based on the zmatrix. :rtype: `schrodinger.structure.Structure` :return: Newly created structure """ struct = structure.create_new_structure() for idx, zatom in enumerate(self.atoms): # Note that indices can appear not sorted ([1, 0] vs [0, 1]) indices = numpy.array(zatom.neighbor_indexes) - 1 if idx == 0: # First atom, place at the origin struct.addAtom(zatom.element, *self.origin) continue elif idx == 1: # Second atom, place along the bond1_vec vector dist, = zatom.values vec = struct.getXYZ()[0] + dist * self.bond1_vec struct.addAtom(zatom.element, *vec) continue elif idx == 2: # Third atom, place along the bond vector and angle dist, angle = zatom.values a1_vec, a2_vec = struct.getXYZ()[indices] # Vector has origin at neighbor atom and direction along the # bond2_vec vec = a1_vec + dist * self.bond2_vec atom = struct.addAtom(zatom.element, *vec) # Adjust angle, atom indices that define it are reversed atom_indices = list(reversed(indices + 1)) struct.adjust(angle, atom_indices[0], atom_indices[1], atom.index) continue # Forth atom and beyond dist, angle, dihedral = zatom.values vec1, vec2, vec3 = struct.getXYZ()[indices] vec_a, vec_b = vec2 - vec1, vec2 - vec3 dist *= get_normalized_vector(vec_a) normal = numpy.cross(vec_a, vec_b) tmatrix1 = rotation_matrix(normal, angle) dist = numpy.dot(tmatrix1, dist) tmatrix2 = rotation_matrix(vec_a, dihedral) dist = numpy.dot(tmatrix2, dist) vec = vec1 + dist struct.addAtom(zatom.element, *vec) return struct
[docs] def fromStructure(self, struct): """ Given the input structure, generate a ZMatrix with the same atom neighbors lists as self. :param structure.Structure struct: The structure this zmatrix is for :rtype: ZMatrix :return: Newly created ZMatrix """ # Note that sometimes for two structures with the same list of atoms # but different coordinates, different zmatrices are generated by Jaguar # hence this function assert ([a.element for a in self.atoms ] == [a.element for a in struct.atom]) # Z-Matrix built using makeInternalCoords() in the constructor is not # PBC aware. To match, remove PBC properties here tmp_st = struct.copy() pbcutils.remove_pbc_props(tmp_st) newobj = type(self)() for atom in self.atoms: new_atom = deepcopy(atom) for idx, nidx in enumerate(atom.neighbor_indexes): new_atom.values[idx] = tmp_st.measure( atom.index, *atom.neighbor_indexes[:idx + 1]) newobj.atoms.append(new_atom) newobj.setBasisVectors(struct) return newobj
[docs] def copy(self): """ Copy Z-matrix. :rtype: ZMAtrix :return: Copied Z-matrix """ newobj = type(self)() newobj.atoms = [deepcopy(atom) for atom in self.atoms] newobj.origin = numpy.array(self.origin) newobj.bond1_vec = numpy.array(self.bond1_vec) newobj.bond2_vec = numpy.array(self.bond2_vec) return newobj
[docs] def difference(self, other_zmat): """ Compute difference Z-matrix between other zmatrix and self zmatrix (in this order). :param ZMatrix other_zmat: Other Z-Matrix :rtype: ZMatrix :return: Difference Z-matrix """ def diff_angles(angles1, angles2): """ Get the smallest angle difference between -180 and 180. Pairs of angles are defined as angle2 - angle1. """ # Example. For y, x = 359, 2 # result is -3 # See also https://stackoverflow.com/a/7869457 return [(y - x + 180) % 360 - 180 for x, y in zip(angles1, angles2)] assert len(self.atoms) == len(other_zmat.atoms) ret = ZMatrix() for atom1, atom2 in zip(self.atoms, other_zmat.atoms): new_atom = deepcopy(atom1) if len(atom1.values): # at least bond is present new_atom.values[0] = atom2.values[0] - atom1.values[0] if len(new_atom.values) >= 2: new_atom.values[1:] = diff_angles(atom1.values[1:], atom2.values[1:]) ret.atoms.append(new_atom) ret.origin = other_zmat.origin - self.origin ret.bond1_vec = other_zmat.bond1_vec - other_zmat.bond1_vec ret.bond2_vec = other_zmat.bond2_vec - other_zmat.bond2_vec return ret
[docs] def interpolate(self, other_zmat, disps, indices=None): """ Interpolate between two Z-matrices. Initial Z-matrix is self, other zmatrix is final point. :param ZMatrix other_zmat: Final point of zmatrix :param list disps: List of displacement coefficients :type indices: list[int] or None :param indices: List of atom indices to move, if None, will move all atoms :rtype: list[ZMatrix] :return: List of interpolated Z-matrices """ assert len(self.atoms) == len(other_zmat.atoms) indices = set( range(1, len(self.atoms) + 1) if indices is None else indices) indices = sorted(indices) zmat_diff = self.difference(other_zmat) zmats = [] for disp in disps: zmat = self.copy() for atom, atom_diff in zip(zmat.atoms, zmat_diff.atoms): if atom.index in indices: atom.values = (numpy.asarray(atom.values) + numpy.asarray(atom_diff.values) * disp) zmat.origin += zmat_diff.origin * disp zmat.bond1_vec = get_normalized_vector(zmat.bond1_vec + zmat_diff.bond1_vec * disp) zmat.bond2_vec = get_normalized_vector(zmat.bond2_vec + zmat_diff.bond2_vec * disp) zmats.append(zmat) return zmats
[docs] def bumpIndexes(self, bump): """ Bump all atom indexes for this z-matrix :type bump: int :param bump: The amount to modify each index """ for atom in self.atoms: atom.bumpIndexes(bump)
[docs] def constrainAll(self): """ Constrain all internal coordinates """ for atom in self.atoms: atom.constrainAll()
[docs] def freeAtom(self, index): """ Free all the internal coordinates for a single atom :type index: int :param index: The atom index (1-based) of the atom to free """ self.atoms[index - 1].freeAll()
[docs] def freeAtomBond(self, index): """ Free all the bond internal coordinate for a single atom :type index: int :param index: The atom index (1-based) of the atom to free """ self.atoms[index - 1].freeCoord(0)
[docs] def freeAtomAngle(self, index): """ Free all the angle internal coordinate for a single atom :type index: int :param index: The atom index (1-based) of the atom to free """ self.atoms[index - 1].freeCoord(1)
[docs] def freeAtomDihedral(self, index): """ Free all the angle internal coordinate for a single atom :type index: int :param index: The atom index (1-based) of the atom to free """ self.atoms[index - 1].freeCoord(2)
[docs] def findNonlinearAngle(self, struct, atom_a, atom_b, do_not_use, index_less_than, linear): """ Find an angle between atom_a, atom_b and some other atom that is smaller than linear :type struct: `schrodinger.structure.Structure` :param struct: The struct to use :type atom_a: int :param atom_a: The 1-indexed index of atom A for the A-B-C angle :type atom_b: int :param atom_b: The 1-indexed index of atom B for the A-B-C angle :type do_not_use: set :param do_not_use: An set of atom indexes that should not be used for the C atom for the A-B-C angle :type index_less_than: int :param index_less_than: Only look for indexes less than this number :type linear: float :param linear: The threshold where any angle greater than this is considered linear :rtype: int, float or None, None :return: The index of an atom that forms an angle with atom_a and atom_b that is less than linear degrees, and the angle it forms. None, None is returned if none of the indexes in choose_from gave a non-linear angle. """ for index in range(1, index_less_than): if index not in do_not_use: angle = struct.measure(atom_a, atom_b, index) if abs(angle) < linear: return index, angle return None, None
[docs] def merge(self, child_zmat, struct, glue_index): """ Combine the atom lists from this (parent) z-matrix and another (child) z-matrix. This involves not just combining the lists, but also defining the following new internal coordinates: child_atom_1: bond, angle, dihedral child_atom_2: angle, dihedral child_atom_3: dihedral All new internal coordinates will reference an atom from the parent structure. These six new internal coordinates fully specify the parent-child orientation. :type child_zmat: `ZMatrix` :param child_zmat: The child z-matrix to merge with this one :type struct: `schrodinger.structure.Structure` :param struct: The structure that results from merging these two z-matrices. This is needed to compute the new bonds, angles and dihedrals. :type glue_index: int :param glue_index: The index of the atom in the parent structure that should be bonded to the first atom of the child structure. This index should be 1-based (i.e. structure.atom indexed, not list indexed) """ parent_atom_total = len(self.atoms) child_zmat.bumpIndexes(parent_atom_total) # Compile a list of parent atoms to use to define the new child-parent # orientation coordinates parent_coord_indexes = [glue_index] neighbors = self.atoms[glue_index - 1].neighbor_indexes[:] # By preference use the atoms that the glue atom uses for coordinates parent_coord_indexes.extend(neighbors) if len(parent_coord_indexes) < 3: # glue_index atom must be one of the first three, so it doesn't have # three neighbors defined in the z-matrix - add the remaining atoms # from the first three parent atoms (if they exist) maxatoms = min(3, parent_atom_total) for index in range(1, maxatoms + 1): if index not in parent_coord_indexes: parent_coord_indexes.append(index) # Now add z-matrix coordinates to fill out the first 3 child atoms. Each # new z-matrix coordinate will be referenced to an atom in the parent # structure. # LINEAR = Jaguar cutoff for rejecting linear angle coordinates LINEAR = 165. atoms_to_fix = min(3, len(child_zmat.atoms)) for child_index in range(atoms_to_fix): # Note - child index is 0-indexed child_atom = child_zmat.atoms[child_index] # Note - child_atom.index is 1-indexed coord_indexes = parent_coord_indexes[:] if child_index < 1: # Bond only for the first child atom parent_index = coord_indexes.pop(0) bond = struct.measure(child_atom.index, parent_index) element = struct.atom[parent_index].element child_atom.addCoord(parent_index, element, bond) if child_index < 2 and coord_indexes: # Angle only for the first and second child atoms parent_index = coord_indexes.pop(0) bond_index = child_atom.neighbor_indexes[0] angle = struct.measure(child_atom.index, bond_index, parent_index) if abs(angle) > LINEAR: # Angle A-B-C is too close to linear. We will definitely # use A-B (they are bonded), so find a new C so that A-B-C # doesn't give 180 do_not_use = set(parent_coord_indexes) index, new_angle = self.findNonlinearAngle( struct, child_atom.index, bond_index, do_not_use, parent_atom_total + 1, LINEAR) if index is not None: c_index = index abc_angle = new_angle else: c_index = parent_index abc_angle = angle a_index = child_atom.index b_index = bond_index print('Warning: Probe-Target orientation involves an ' 'angle near 180, formed by %d, %d, %d atoms. ' 'Geometry may be unstable.' % (a_index, b_index, c_index)) else: c_index = parent_index abc_angle = angle element = struct.atom[c_index].element child_atom.addCoord(c_index, element, abc_angle) if coord_indexes: # Dihedral for all 3 first child atoms # There are two angles involved in an A-B-C-D dihedral: A-B-C # and B-C-D. We need to ensure that neither of those angles are # greater than the Jaguar LINEAR cutoff. In the process of # defining the angle, we already ensured that A-B-C was OK, so # now we need to concern ourselves with B-C-D. We can ensure # that B-C-D will be OK by using the atoms that B defines d_index = coord_indexes.pop(0) bcd_angle = struct.measure(child_atom.neighbor_indexes[0], child_atom.neighbor_indexes[1], d_index) if abs(bcd_angle) > LINEAR: do_not_use = set(parent_coord_indexes + child_atom.neighbor_indexes) index, angle = self.findNonlinearAngle( struct, child_atom.neighbor_indexes[0], child_atom.neighbor_indexes[1], do_not_use, parent_atom_total + 1, LINEAR) if index is None: a_index = child_atom.neighbor_indexes[0] b_index = child_atom.neighbor_indexes[1] print('Warning: Probe-Target orientation involves a ' 'torsion with one interior angle near 180, ' 'formed by %d, %d, %d atoms. Geometry may be ' 'unstable.' % (a_index, b_index, d_index)) else: d_index = index torsion = struct.measure(child_atom.index, child_atom.neighbor_indexes[0], child_atom.neighbor_indexes[1], d_index) element = struct.atom[d_index].element child_atom.addCoord(d_index, element, torsion) self.atoms = self.atoms + child_zmat.atoms
[docs]def add_zmatrix_lines(lines, zmat): """ Add the lines defining a z-matrix to the character string using the format required by Jaguar input files. :type lines: str :param lines: The string to add the z-matrix to :type zmat: `schrodinger.application.matsci.zmutils.ZMatrix` :param zmat: The ZMatrix that defines the lines to be added :rtype: str :return: The original lines with the z-matrix lines appended. The initial '&zmat' and the closing '&' are appended by this function """ lines += '&zmat\n' for atom in zmat.atoms: lines += atom.getSymbolicLine() lines += '&\n&zvar\n' for atom in zmat.atoms: lines += atom.getVariableLines() lines += '&' return lines
[docs]def replace_coords_with_zmat(path, zmat): """ Replace the coordinate section in a jaguar input file with the z-matrix. Replaces the current: &zmat ... & section with &zmat ... & &zvar ... & :type path: str :param path: The path to the input file @zmat: ZMatrix :param: The ZMatrix object to use to replace the coordinates """ # Now read in the input file, replace the coordinate section with our # z-matrix, and write it back out. input_file = open(path, 'r') all_lines = "" skipping = False for line in input_file: if not line.startswith('&zmat'): if not skipping: # If we're currently outside the &zmat section add this line all_lines += line else: if line.startswith('&'): # We're inside the zmat section and just found the end of it skipping = False else: # We just found the beginning of the zmat section skipping = True all_lines = add_zmatrix_lines(all_lines, zmat) input_file.close() with open(path, 'w') as input_file: input_file.write(all_lines)