Source code for schrodinger.application.matsci.fragments

"""
Classes and functions to help with workflows that fragment multiple reactions
such as the bond dissociation and adsorption energy workflows.

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

from collections import defaultdict

import inflect
import numpy

from schrodinger.application.jaguar import input as jagin
from schrodinger.application.jaguar import results as jagresults
from schrodinger.application.matsci import buildcomplex
from schrodinger.application.matsci import jaguarworkflows
from schrodinger.application.matsci import jobutils
from schrodinger.application.matsci import msutils
from schrodinger.application.matsci import msprops
from schrodinger.application.matsci import textlogger
from schrodinger.job import jobcontrol
from schrodinger.structutils import analyze
from schrodinger.structutils import rmsd
from schrodinger.test import ioredirect
from schrodinger.utils import units

TARGET_PROP = 'b_matsci_target'
H_TO_KCAL = units.KCAL_PER_MOL_PER_HARTREE
KCAL_TO_H = 1.0 / H_TO_KCAL
GROUND = 's0'
TRIPLET = 't1'
ANION = 'anion'
CATION = 'cation'
S1_STATE = 's1'
EXCITED = 'excited'
OXIDIZED = 'oxidized'
REDUCED = 'reduced'
STATES = [GROUND, TRIPLET, EXCITED, ANION, CATION, S1_STATE]
CHARGED = {ANION, CATION}
REACTANT_CHARGE = {ANION: -1, CATION: 1, GROUND: 0, TRIPLET: 0, EXCITED: 0}
REACTANT_MULTIPLICITY = {ANION: 2, CATION: 2, GROUND: 1, TRIPLET: 3, EXCITED: 1}
UHF = 'iuhf'
REACTANT = 'reactant'
REACTANTS = REACTANT.title() + 's'

TYPE_FRAGMENT = 'fragment'
TYPE_GAS = 'gas'
TYPE_SUBSTRATE = 'substrate'

FLAG_CLOSED_SHELL_CHARGE = '-cs_charge'
FLAG_EXCITED = '-excited'

PROPERTY_FILE_EXTENSION = '-properties.csv'


[docs]def split_spin_state(spinstate): """ Split a spin state such as 'S1' into ('S', 1) :param str spinstate: The string to split - should be in the form of a single first letter followed by an integer :rtype: (str, int) :return: The leading string character and the following int """ spin = spinstate[0] state = int(spinstate[1:]) return (spin, state)
[docs]def spin_multiplicity(struct, charge=0): """ Return the multiplicity of the given structure with the given charge. Structures will either be singlets (even number of electrons) or doublets (odd number). No attempt is made to determine multiplicity beyond that. :param `structure.Structure` struct: The structure to check :param int charge: The charge on the structure :rtype: int :return: 1 if there is an even number of electrons, 2 if it is odd """ nelec = sum(x.atomic_number for x in struct.atom) - charge return 1 + abs(nelec % 2)
[docs]def label_bond(bond, value): """ Add a label to a bond that will show in the workspace :param `structure._StructureBond` bond: The bond to label :param float value: The value to label the bond with """ bond.property[msprops.SHOW_BOND_LABEL_PROP] = msprops.BDE_BOND_LABEL # Note - 1 decimal point due to MATSCI-10025 bond.property[msprops.BDE_BOND_LABEL] = f'{value:.1f}'
[docs]def set_pt_subgroup_info(struct, name, collapsed=True): """ Set the properties on the structure to have it incorporate in the proper subgroup :type struct: `schrodinger.structure.Structure` :param struct: The structure to set the properties one :type name: str :param name: The name of the subgroup :type collapsed: bool :param collapsed: Whether the subgroup should be collapsed """ jobname = jobcontrol.get_jobname(filename='bde') msutils.set_project_group_hierarchy(struct, [jobname, name], collapsed=collapsed)
[docs]class LoggerUser(object): """ Mixin for classes that need to use a logger """
[docs] def __init__(self, *args, logger=None, **kwargs): """ Create the LoggerUser :param `log.logger` logger: The logger to use """ self.logger = logger super().__init__(*args, **kwargs)
[docs] def log(self, msg, **kwargs): """ Log a message :param str msg: The message to log Additional keyword arguments are passed to the textlogger.log_msg function """ textlogger.log_msg(msg, logger=self.logger, **kwargs)
[docs]class BondEnergies: """ Keep track of a set of bond energies """
[docs] def __init__(self): """ Create a BondEnergies instance """ # Keys are energy property names, values are the energy for that # property self.energies = defaultdict(lambda: numpy.inf)
[docs] def storeIfLowestEnergy(self, etype, energy): """ Check an energy to see if it is the lowest energy of this type, and store it if it is :param str etype: The type of energy (an energy property name) :param float energy: The energy to store if it is the lowest of this type """ self.energies[etype] = min(self.energies[etype], energy)
[docs] def getEnergy(self, etype): """ Get the lowest energy associated with the given type :param str etype: The type of energy (an energy property name) :rtype: float or numpy.inf :return: The lowest energy found for the given type. numpy.inf is returned if no energy of that type is found """ return self.energies[etype]
[docs] def getEnergies(self): """ Get an iterator of all energy type/energy combinations :rtype: iterator :return: Each item is (energy type, energy) """ return self.energies.items()
[docs] def hasFreeEnergy(self): """ Check if free energies exist :rtype: bool :return: Whether free energy has been recorded """ return msprops.BDE_FREE_ENERGY in self.energies
[docs]class BreakingBond(object): """ Defines and tracks the information for a bond that breaks """
[docs] def __init__(self, struct, bond, preserve_order=False): """ Create a BreakingBond object :type struct: `schrodinger.structure.Structure` :param struct: The structure containing the breaking bond :type bond: `schrodinger.structure._StructureBond` :param bond: The bond that will be broken :param bool preserve_order: Preserve the found order of fragments. If False, fragments will be sorted based on SMILES string. """ self.preserve_order = preserve_order self.fragments = self.getFragments(struct, bond) self.is_valid = all([ (x is not None and x.is_valid) for x in self.fragments ])
[docs] def getFragments(self, struct, bond): """ Define the two fragments that will be created when the bond breaks :type struct: `schrodinger.structure.Structure` :param struct: The structure containing the breaking bond :type bond: `schrodinger.structure._StructureBond` :param bond: The bond that will be broken :rtype: list :return: Each item of the list is a `Fragment` object defining one of the fragments that is created by breaking the bond - or each item is None if the bond is actually part of a macrocycle. """ ind1 = bond.atom1.index ind2 = bond.atom2.index # Find which structure atoms belong to which fragment frag1_indexes = buildcomplex.find_atoms_to_remove(struct, ind2, ind1) if len(frag1_indexes) == 1: # Check to see if a macrocycle was found, in which case this is not # actually a valid bond if len(list(struct.atom[ind1].bonded_atoms)) > 1: return [None, None] frag1_set = set(frag1_indexes) frag2_indexes = [ x for x in range(1, struct.atom_total + 1) if x not in frag1_set ] # Create the fragments frags = [] for target, indexes in zip([ind1, ind2], [frag1_indexes, frag2_indexes]): frag = Fragment(struct, indexes, [target]) frags.append(frag) # Order the fragments in a meaningless but consistent way if not self.preserve_order and frags[0].smiles > frags[1].smiles: frags.reverse() return frags
[docs]class Fragment(object): """ A fragment of a structure that will be created after dissociation """
[docs] def __init__(self, struct, indexes, targets): """ Create a Fragment object :type struct: `schrodinger.structure.Structure` :param struct: The structure containing the fragment :type indexes: list :param indexes: The list of atom indexes in struct to include in the fragment :type targets: list :param targets: The atom index(es) in struct that are part of this fragment and that are directly involved in the dissociation """ for target in targets: struct.atom[target].property[TARGET_PROP] = True self.struct = struct.extract(indexes) for target in targets: struct.atom[target].property[TARGET_PROP] = False # self.parent_targets = target indexes in the original numbering scheme self.parent_targets = targets # self.parent_indexes = indexes in the parent structure that make up # this fragment self.parent_indexes = indexes # self.targets = targets in the Fragment numbering scheme self.smiles, self.targets = self.getSMILESForFrag() self.is_valid = self.smiles is not None self.closed_shell = spin_multiplicity(self.struct) == 1 self.fragment_type = TYPE_FRAGMENT self.is_proton = (self.struct.atom_total == 1 and self.struct.atom[1].element == 'H')
[docs] def getSMILESForFrag(self): """ Get the smiles string and list of targets for this fragment. Note that this SMILES string will have an addition At atom at the point of dissociation. This is done to increase the robustness of the unique SMILES method. Without it, I have found that a benzene ring radical can have different unique SMILES strings depending on what atom the SMILES is on. Since we never generate a structure from these SMILES strings, the extra atom isn't an issue. :rtype: (str, list) or (None, []) :return: (SMILES, list_of_targets). The SMILES string is the SMILES string for this fragment with an At atom at the dissociation point(s). Each item in list_of_targets is the atom index of a target atom (atom at the point of dissociation) using the atom index numbering in frag. (None, []) is returned if the SMILES string fails to generate. """ fcopy = self.struct.copy() my_targets = [] for atom in self.struct.atom: if not atom.property.get(TARGET_PROP): continue # We add a halogen at the attachment point because it makes the # process of forming unique SMILES strings more robust versus # using a radical. target = atom.index fatom = fcopy.addAtom('At', 0.0, 0.0, 0.0) fcopy.addBond(target, fatom.index, 1) my_targets.append(atom.index) try: with ioredirect.IOSilence(): smiles = analyze.generate_smiles(fcopy) except RuntimeError: # A structure we can't obtain the SMILES string for return None, [] return smiles, my_targets
[docs]class UniqueTracker(LoggerUser): """ Tracks the information for, and creates the Jaguar job for, a unique fragment. Since the same fragment may be generated by multiple dissociations, we use this class to track each unique fragment. """
[docs] def __init__(self, struct, keydict, key, options, basename='fragment', targets=None, parent_indexes=None, charge=None, mult=None, tddft=False, vertical=False, write_input_ok=True, **kwargs): """ Create a UniqueTracker object :type struct: `schrodinger.structure.Structure` :param struct: The structure containing the leaving ligand :type keydict: dict :param keydict: The dictionary containing the base set of keywords :type key: str :param key: a unique identifier for this unique fragment :param `argparse.Namespace` options: The command line options :type basename: str :param basename: The base name to use for files for this fragment. Will be used as the fragment_type and combined with key to form the file base names. :type targets: list :param targets: The atom indexes in struct that are at the dissociation point :type parent_indexes: list :param parent_indexes: The atom indexes with the parent atom numbering for the atoms in struct. Note this will only be valid for one specific reaction, so must be overwritten in any NonUniqueTracker objects that track this instance. :type charge: int :param charge: The charge of the fragment - will be used with the molchg keyword :type mult: int :param mult: The multiplicity of the fragment - will be used with the multip keyword :param str tddft: The state to compute via TD-DFT. Should be a spin-state string like S1, T2, etc. Only singlet (S) and triplet (T) states are supported. :type vertical: bool :param vertical: True if this is tracking an object that should not have its geometry optimized. If vertical is True, writing the Jaguar input file is delayed and must be triggered manually later by a call to setVerticalStructure. :param bool write_input_ok: If True, write the input files for this fragment. If False, do not. """ super().__init__(**kwargs) if targets is None: targets = [] self.targets = targets self.tddft = tddft self.title = struct.title self.fragment_type = basename self.basename = '%s_%s' % (basename, key) self.job = None self.parent_indexes = parent_indexes self.options = options self.unique = True # Define the keywords based on the settings keywords = keydict.copy() if charge is not None: keywords['molchg'] = charge if mult is not None: keywords['multip'] = mult if keywords.get('multip', 0) < 2: keywords.pop(UHF, None) if self.tddft: spin, state = split_spin_state(self.tddft) if spin == 'S': rkey = 'rsinglet' else: rkey = 'rtriplet' nroot = state + 4 tddftkeys = { 'itddft': 1, 'itda': 1, 'nroot': nroot, rkey: 1, 'target': state } for jkey, value in tddftkeys.items(): keywords.setdefault(jkey, value) if vertical: # Override any geometry optimization keyword if this is a vertical # dissociation keywords['igeopt'] = 0 self.keywords = keywords if write_input_ok and not vertical: self.writeInput(struct) # Keys are tuples of bonded atom indexes, values are BondEnergies self.bond_energies = defaultdict(BondEnergies)
[docs] def addBondEnergy(self, index1, index2, etype, energy): """ Add a computed bond dissociation energy for the given energy type. The energy will not be added if a lower energy has already been found for this bond and energy type. :type index1: int or `schrodigner.structure._StructureAtom` :param index1: The index of the first atom in the bond (or the atom object) :type index2: int or `schrodigner.structure._StructureAtom` :param index2: The index of the second atom in the bond (or the atom object) :type etype: str :param etype: The type of energy to add - typically an energy property name :type energy: float :param energy: The bond dissociation energy for that bond """ key = (int(index1), int(index2)) self.bond_energies[key].storeIfLowestEnergy(etype, energy)
[docs] def getWeakestBonds(self, etype=msprops.BDE_ENERGY): """ Get the bond with the lowest energy for the given energy type :param str etype: The energy type to use. Should be consistent with the etype parameter passed in to addBondEnergy :rtype: list, float :return: Each item of the list is a (int, int) tuple that gives the atom indexes involved the bond with the lowest energy. More than one item in the list indicates that more than one bond shares that energy. The energy is the energy of those bonds. The list will be empty and the energy will be None if there are no BDE's defined for this Reactant. """ energy = numpy.inf indexes = [] for (ind1, ind2), ben in self.bond_energies.items(): ene = ben.getEnergy(etype) if ene < energy: # A new low energy is found indexes = [(ind1, ind2)] energy = ene elif ene != numpy.inf and abs(ene - energy) < 0.001: # Another bond with the same energy (probably either undetected # symmetry or a bidentate ligand) indexes.append((ind1, ind2)) if energy == numpy.inf: energy = None return indexes, energy
[docs] def updateVerticalStructure(self, parent_structure): """ Grab the structure for these atoms from the parent structure and write out the Jaguar input file :type parent_structure: `schrodinger.structure.Structure` :param parent_structure: The parent structure to extract the fragment structure from """ struct = parent_structure.extract(self.parent_indexes) struct.title = self.title self.writeInput(struct)
[docs] def writeInput(self, struct): """ Write an input file using the given structure :type struct: `schrodinger.structure.Structure` :param struct: The structure to write to the input file """ jinput = jagin.JaguarInput(structure=struct, genkeys=self.keywords) jinput.saveAs(self.basename) self.filename = jinput.name + '.in'
[docs] def addToQueue(self, jobq, backend): """ Check if output file exists and if not create a job for this fragment and add it to the queue and add its output files to the backend :type jobq: `schrodinger.job.queue.JobDJ` :param jobq: The queue to add the job to :type backend: `schrodinger.job.jobcontrol.Backend` :param backend: The job control backend, if any """ # First check if there is an existing output file for this tracker - # this might have been created in a previous aborted run. try: out_file = self.basename + '.out' jaguarworkflows.get_jaguar_output(out_file) self.log('Will use already completed output file: %s' % out_file) return except jaguarworkflows.JaguarFailedException: # Continue submitting the jobs pass self.job = jaguarworkflows.create_job(self.options, self.filename) jobq.addJob(self.job) jaguarworkflows.add_jaguar_files_to_jc_backend(self.basename, backend=backend)
[docs] def getStructureWithProps(self): """ Get the structure resulting from the Jaguar run including any existing properties :rtype: `schrodinger.structure.Structure` or None :return: The output structure, or None if an error occurs """ try: struct = jaguarworkflows.get_jaguar_out_mae(self.basename) return struct except (jaguarworkflows.JaguarFailedException, IOError, jagresults.IncompleteOutput) as msg: self.log(msg) return None
[docs] def getStructureWithoutProps(self): """ Get the structure resulting from the Jaguar run but remove any existing properties :rtype: `schrodinger.structure.Structure` or None :return: The output structure, or None if an error occurs """ struct = self.getStructureWithProps() if not struct: return struct for prop in list(struct.property): try: del struct.property[prop] except (AttributeError, ValueError, KeyError): # All these are errors raised by del that we don't care about pass return struct
[docs] def superimposeOnParent(self, parent, struct): """ Superimpose this fragment on its parent structure to get the orientation the same. Modifies struct directly :type parent: `schrodinger.structure.Structure` :param parent: The parent structure to superimpose on - must have the same atom ordering as was used for the parent_indexes argument in the class constructor. :type struct: `schrodinger.structure.Structure` :param struct: The structure for this fragment """ if len(self.parent_indexes) > 2: rmsd.superimpose(parent, self.parent_indexes, struct, list(range(1, struct.atom_total + 1)))
[docs] def getEnergies(self): """ Get the various energies from the Jaguar job. Energies will always include the SCF energy (with solvent effect if included), plus possibly the free energy, enthalpy and internal energy if frequencies were computed. :rtype: dict :return: Keys of the dict are a BDE energy property, values are the energy corresponding to that energy property. """ energies = {} try: output = jaguarworkflows.get_jaguar_output(self.basename) except (jaguarworkflows.JaguarFailedException, jagresults.IncompleteOutput): return energies results = output.last_results if isinstance(results.thermo, list) and not self.tddft: energies[msprops.BDE_INTERNAL_ENERGY] = results.thermo[0].UTotal energies[msprops.BDE_ENTHALPY] = results.thermo[0].HTotal energies[msprops.BDE_FREE_ENERGY] = results.thermo[0].GTotal if results.solution_phase_energy: energies[msprops.BDE_ENERGY] = results.solution_phase_energy else: energies[msprops.BDE_ENERGY] = results.energy if self.tddft: spin, state = split_spin_state(self.tddft) if spin == 'S': excitations = results.excitation_energies else: excitations = results.triplet_excitation_energies try: delta = excitations[state - 1] except IndexError: self.log( 'No excited state energies were found in %s - skipping' % self.basename) return None delta_hartree = delta / units.EV_PER_HARTREE energies[msprops.BDE_ENERGY] += delta_hartree return energies
[docs] def write(self, writer): """ Write the structure with properties to the output file :type writer: L{schrodinger.structure.StructureWriter :param writer: The writer to use to write the output file """ struct = self.getStructureWithProps() if struct: # Add a visible BDE label to each bond for each energy type etypes = set() for (index1, index2), ben in self.bond_energies.items(): bond = struct.getBond(index1, index2) for etype, energy in ben.getEnergies(): bond.property[etype] = energy evtype = msprops.kcal_prop_to_ev_prop(etype) bond.property[evtype] = energy / units.KCAL_PER_MOL_PER_EV # Show free energy instead of SCF energy if available if etype == msprops.BDE_FREE_ENERGY or ( etype == msprops.BDE_ENERGY and not ben.hasFreeEnergy()): label_bond(bond, energy) etypes.add(etype) # Add a property giving the weakest energy of any bond. for etype in etypes: indexes, energy = self.getWeakestBonds(etype=etype) if energy is not None: weak_prop = msprops.BDE_ENERGY_TO_WEAKEST_PROP[etype] struct.property[weak_prop] = energy # Set the PT group to be reactants or fragments group = self.basename.split('_')[0].title() + 's' set_pt_subgroup_info(struct, group) writer.append(struct) else: self.log('Skipping %s, which appears to have failed' % self.basename)
[docs]class NonUniqueTracker(UniqueTracker): """ This is a placeholder for a second (or third, etc.) time a fragment is used in the reaction. Mimics the UniqueTracker job without creating any new Jaguar jobs or writing to the output file. """
[docs] def __init__(self, unique_master, parent_indexes): """ Create a NonUniqueTracker object :type unique_master: `UniqueTracker` :param unique_master: The UniqueTracker object this NonUniqueTracker should mimic :type parent_indexes: list :param parent_indexes: The atom indexes with the parent atom numbering for the atoms in this fragment. Note this is only be valid for the specific reaction this tracker is for, so must it overwrites the parent_indexes of the UniqueTracker this object is mimicking """ self.targets = unique_master.targets self.basename = unique_master.basename self.parent_indexes = parent_indexes # Fragments never use tddft self.tddft = None self.unique_master = unique_master self.unique = False self.fragment_type = unique_master.fragment_type
[docs] def updateVerticalStructure(self, *args): """ Overwrite the parent method because this class doesn't deal with structures but we want to be able to call this method safely. """
[docs] def addToQueue(self, *args, **kwargs): """ The whole point of this class is that it doesn't run a Jaguar job but takes the results from a different job """
[docs] def write(self, writer): """ Don't write anything out - we're vaporware of the best kind """
[docs]class Reaction(LoggerUser): """ Holds the information for a single dissociation reaction """
[docs] def __init__(self, reactant_index, reactant_targets, product_trackers, **kwargs): """ Create a Reaction object :type reactant_index: str :param reactant_index: the key into the reactants dictionary for the reactant in this reaction :type reactant_targets: list :param reactant_targets: The atom indexes in the reactant structure that dissociate in this reaction :type product_trackers: list :param product_trackers: Each item of this list is a UniqueTracker or NonUniqueTracker object for one of the product fragments :param `logging.logger` logger: The logger to use """ super().__init__(**kwargs) self.reactant_index = reactant_index self.product_trackers = product_trackers self.reactant_targets = reactant_targets
[docs] def updateProductGeometries(self, reactants): """ Update the geometry of all product fragments to have the geometry of that fragment in the reactant :type reactants: dict :param reactants: keys are reactant_index strings, values are UniqueTracker objects for that reactant """ reactant = reactants[self.reactant_index] rstruct = reactant.getStructureWithProps() if rstruct: for prod in self.product_trackers: prod.updateVerticalStructure(rstruct) else: self.log('No structure found for %s, skipping' % self.reactant_index)
[docs]class FragmentReactor(LoggerUser): """ Create reactions, track all reactant and fragment objects, and run jobs on all reactants and products Reactants are turned into products by the createProductsGroup method, which must be implemented in subclasses """ ALL = 'all' REACTANTS = 'reactants' PRODUCTS = 'products'
[docs] def __init__(self, structs, states, options, keywords=None, vertical=False, logger=None, tracker_class=UniqueTracker, reactant_title_base=REACTANT): """ Create a fragment reactor instance :param list structs: Each item is a reactant `Structure` object :param list states: Each item is a module constant from the STATES list :param `argparse.Namespace` options: The command line options :param dict keywords: A dictionary of Jaguar keyword/value pairs :param bool vertical: True if the products will use the reactant geometry for those atoms, False if products will be optimized :param `logging.logger` logger: The logger to use :param tracker_class: The class to use for tracking unique fragments :param reactant_title_base: The base title for reactant structures """ super().__init__(logger=logger) self.structs = structs self.tracker_class = tracker_class self.reactant_title_base = reactant_title_base self.vertical = vertical self.keywords = keywords self.options = options self.states = states # All fragments, keys are SMILES, values are index number self.unique_fragments = {} # Keys are SMILES with possibly a state modifier, values are # tracker_class objects. There may be more than one unique tracker # for each unique fragment because each state requires its own tracker self.unique_trackers = {} # Keys are strings formed from the index number of reactant plus # possibly a state modifier, values are tracker_class objects self.reactants = {} # List of Reaction objects self.reactions = []
[docs] def getAllUniqueTrackers(self): """ Get all unique reactant and product trackers :rtype: list :return: The list has one item per unique reactant or product, the type of object is given by self.tracker_class """ return self.getReactantTrackers() + self.getUniqueProductTrackers()
[docs] def getReactantTrackers(self): """ Get all reactant trackers :rtype: list :return: The list has one item per reactant, the type of object is given by self.tracker_class """ return list(self.reactants.values())
[docs] def getUniqueProductTrackers(self): """ Get all unique product trackers :rtype: list :return: The list has one item per unique product, the type of object is given by self.tracker_class """ return list(self.unique_trackers.values())
[docs] def preprocessInputStruct(self, struct): """ Do any initial processing of an input structure before creating reactions for it. Base class implementation does nothing :param `structure.Structure` struct: The input structure """ pass
[docs] def createAllReactions(self): """ Create all reactions for all structures and states""" for rind, struct in enumerate(self.structs, 1): self.preprocessInputStruct(struct) product_groups = self.createProductGroups(struct) for state in self.states: if state == EXCITED: # tddft should be something like S1, T3, etc tddft = jobutils.get_option(self.options, FLAG_EXCITED) if not tddft: raise RuntimeError('No excited state specified in ' f'options for state={EXCITED}') else: tddft = "" self.reactions.extend( self.createStateReactions(struct, product_groups, state, rind, tddft))
[docs] def createStateReactions(self, struct, product_groups, state, rind, tddft): """ Create the reactions for this structure and state :param structure.Structure struct: The reactant structure :param list product_groups: Each item is a group of products :param str state: The current state, should be one of the STATES items :param int rind: The index for this reactant :param str tddft: The target excited state :rtype: list :return: A list of Reaction objects created """ rtracker, unique_key, charge, mult = self.createReactantTracker( struct, state, rind, tddft) self.reactants[unique_key] = rtracker product_index = 1 skipped_protons = 0 all_rxns = [] for products in product_groups: reactant_targets = [] # For neutral, closed shell reactants, we need to create one # reaction. # Rxn1: Reactant -> P1(.) + P2(.) # For charged or open shell reactants, we need to create # two reactions, each reaction has the charge # or unpaired electron on a different fragment. ex. for a # radical cation: # Rxn1: Reactant -> P1(.) + P2(+) # Rxn2: Reactant -> P1(+) + P2(.) potential_reactions = {0: [], 1: []} skipped_protons += self.preventUnwantedReactions( potential_reactions, products, charge) # Iterate over both product fragments for findex, fragment in enumerate(products.fragments): # The order we create product trackers is: # fragment 1, reaction 1 (uncharged) # fragment 1, reaction 2 (charged) # fragment 2, reaction 1 (charged) # fragment 2, reaction 2 (uncharged) reactant_targets.extend(fragment.parent_targets) # Iterate over both reactions for radical in [0, 1]: if radical not in potential_reactions: # This is an unwanted reaction continue if findex == radical: this_charge = 0 else: this_charge = charge ptracker = self.getFragmentTracker(fragment, state, product_index, charge=this_charge) product_index += 1 potential_reactions[radical].append(ptracker) rxns = [ Reaction(unique_key, reactant_targets, x, logger=self.logger) for x in potential_reactions.values() ] all_rxns.extend(rxns) if skipped_protons and self.logger: rword = inflect.engine().plural('reaction', skipped_protons) self.log(f'Skipped {skipped_protons} {rword} involving H+') return all_rxns
[docs] def getStateModifier(self, state, tddft): """ Get a tag modifier based on the current state :param str state: The current state, should be one of the STATES items :param str tddft: The target excited state :rtype: str :return: A tag modifier based on the current state """ if state == GROUND: state_modifier = "" elif state == EXCITED: state_modifier = f' {tddft}' elif state == CATION: state_modifier = f' {OXIDIZED.title()}' elif state == ANION: state_modifier = f' {REDUCED.title()}' else: state_modifier = ' %s' % state.capitalize() return state_modifier
[docs] def getStateChargeAndMultiplicity(self, state): """ Get the charge and multiplicity for the current state :param str state: The current state, should be one of the STATES items :rtype: (int, int) :return: The charge and multiplicity for this state """ state_charge = REACTANT_CHARGE[state] cs_charge = jobutils.get_option(self.options, FLAG_CLOSED_SHELL_CHARGE) if cs_charge is None: cs_charge = 0 charge = state_charge + cs_charge if abs(charge) > 1: raise RuntimeError('System charge must be -1, 0 or 1') mult = REACTANT_MULTIPLICITY[state] return charge, mult
[docs] def createReactantTracker(self, struct, state, rind, tddft): """ Create the reactant tracker for this structure and state :param structure.Structure struct: The reactant structure :param str state: The current state, should be one of the STATES items :param int rind: The index for this reactant :param str tddft: The target excited state :rtype: (object, str, int, int) :return: The object is the tracker for the reactant. Object type is given by self.tracker_class. The string is the unique key for this reactant. The two ints are charge and multiplicity """ reactant_struct = struct.copy() # Create a unique identifier for this reactant and state state_modifier = self.getStateModifier(state, tddft) unique_key = str(rind) + state_modifier # Give the reactant a descriptive title title = reactant_struct.title if title: title = title + state_modifier else: title = f'Reactant {unique_key}' reactant_struct.title = title charge, mult = self.getStateChargeAndMultiplicity(state) reactant_tracker = self.tracker_class(reactant_struct, self.keywords, unique_key.replace(' ', '_').lower(), self.options, basename=self.reactant_title_base, charge=charge, mult=mult, tddft=tddft, logger=self.logger) return reactant_tracker, unique_key, charge, mult
[docs] def createProductGroups(self, struct): """ Form all the products that can be created from struct Base class implementation does nothing :param `structure.Structure` struct: The input structure :rtype: list :return: Each item should be a BreakingBond or LeavingLigand object, one for each possible fragmentation """ raise NotImplementedError return []
[docs] def preventUnwantedReactions(self, potential_reactions, products, charge): """ Modify the potential_reactions dictionary to prevent creating reactions that we would not want :param dict potential_reactions: keys are reaction index. This dictionary may be modified by this function to remove the key for an unwanted reaction :type products: `BreakingBond` or `LeavingLigand` :param products: The products that will be formed :param int charge: The overall charge of this reaction :rtype: int :return: The number of reactions skipped due to avoiding H+ """ skipped_protons = 0 # Do not create any reaction that would involve an H+ if charge == 1: for frag, is_cation_site in zip(products.fragments, (1, 0)): if frag.is_proton: del potential_reactions[is_cation_site] skipped_protons += 1 elif charge == 0: # What we have is a (possibly radical) neutral. There's only one # possible reaction that doesn't involve adding charges to # both products. We'll just create one reaction with both # fragments neutral. If the reactant is a radical, which product # gets the radical will work out naturally based on which fragment # has an odd number of protons del potential_reactions[1] # Don't create two reactions if they would be identical if len(potential_reactions) == 2 and not self.vertical: if products.fragments[0].smiles == products.fragments[1].smiles: del potential_reactions[1] return skipped_protons
[docs] def getExistingFragmentTracker(self, fragment, tagged_smiles): """ Return any tracker for this fragment with the given state and charge :type fragment: `BreakingBond` or `LeavingLigand` :param fragment: The fragment to generate tags for :param str tagged_smiles: The smiles string for this fragment that has been modified by adding the fragment tag :rtype: object or None, int :return: A tracker for this fragment with the given tagged_smiles if one exists or None if no such tracker exists, and the fragment index for this fragment. Note that fragment index is always valid even if there is no tracker, and the type of tracker object returned is given by self.tracker_class. """ try: # Determine the fragment index for this smiles string findex = self.unique_fragments[fragment.smiles] except KeyError: # A new smiles string, create a unique index for it findex = len([ 1 for x in self.unique_trackers.values() if x.fragment_type == fragment.fragment_type ]) + 1 self.unique_fragments[fragment.smiles] = findex existing_tracker = None else: # Check for an existing tracker for this state of this smiles string existing_tracker = self.unique_trackers.get(tagged_smiles) return existing_tracker, findex
[docs] def getFragmentTags(self, fragment, state, index, charge): """ Get tags for this fragment based on the state and charge :type fragment: `BreakingBond` or `LeavingLigand` :param fragment: The fragment to generate tags for :param str state: The current state, should be one of the STATES items :param int index: The fragment index for this fragment :param int charge: The charge of this fragment :rtype: str, str :return: A tag for this fragment and the SMILES string for this fragment with the tag added """ if charge > 0: tag = ' Cation' elif charge < 0: tag = ' Anion' else: tag = "" if self.vertical and fragment.struct.atom_total > 1: # Make sure every fragment has a unique tag to ensure that they are # all considered unique because even fragments with identical SMILES # may have different geometries if we are not optimizing # geometries. This is not needed for single-atom fragments because # they have no "geometry". tag += ' %s %d' % (state, index) smiles = fragment.smiles tagged_smiles = smiles + tag return tag, tagged_smiles
[docs] def getFragmentTracker(self, fragment, state, index, charge=0): """ Return a tracker for this fragment with the given state and charge :type fragment: `BreakingBond` or `LeavingLigand` :param fragment: The fragment to generate tags for :param str state: The current state, should be one of the STATES items :param int index: The product index for the fragment :param int charge: The charge of this fragment :return: A tracker for this fragment with the given charge state. The type of object returns is given by self.tracker_class """ tag, tagged_smiles = self.getFragmentTags(fragment, state, index, charge) existing_tracker, findex = self.getExistingFragmentTracker( fragment, tagged_smiles) if existing_tracker: # This fragment already exists and is tracked by another # reaction. Just return a tracker that mimics the real one. return NonUniqueTracker(existing_tracker, fragment.parent_indexes) # No tracker exists for this fragment/state/charge, make one id_tag = '%d%s' % (findex, tag) # Create a descriptive structure title key = id_tag.replace(' ', '_').lower() title = f'{fragment.fragment_type} {id_tag}' backend = jobcontrol.get_backend() if backend: job = backend.getJob() title = '%s %s' % (title, job.Name) fragment.struct.title = title # The below statement is True if only ONE of the two things is True if fragment.closed_shell ^ charge: mult = 1 else: mult = 2 tracker = self.tracker_class(fragment.struct, self.keywords, key, self.options, basename=fragment.fragment_type, targets=fragment.targets, parent_indexes=fragment.parent_indexes, charge=charge, mult=mult, vertical=self.vertical, logger=self.logger) self.unique_trackers[tagged_smiles] = tracker return tracker
[docs] def runJobs(self, logfn, what=ALL): """ Create all jaguar jobs and run them :param callable logfn: A logging function - should take a string to log and an optional timestamp argument :param str what: A class constant (ALL, REACTANTS, PRODUCTS) that says which jobs to run :rtype: bool or None :return: None if no jobs were run, True if at least one job did not fail, otherwise False """ jobq = jobutils.create_queue(self.options, host=jobutils.AUTOHOST) backend = jobcontrol.get_backend() trackers = [] if what in (self.ALL, self.REACTANTS): trackers.extend(self.getReactantTrackers()) if what in (self.ALL, self.PRODUCTS): trackers.extend(self.getUniqueProductTrackers()) for tracker in trackers: tracker.addToQueue(jobq, backend) logfn("") try: textlogger.run_jobs_with_update_logging(jobq, logfn) except RuntimeError as msg: return None if jobq.total_added == 0 or len(jobq._failed) < jobq.total_added: return True else: return False
[docs] def writeSingleStructures(self, writer): """ Write all single structures to the output file :type writer: `schrodinger.structure.StructureWriter` :param writer: The structure writer to use to write structures with """ for tracker in self.getAllUniqueTrackers(): tracker.write(writer)