def _minimise(self, method, n_cores, etol, max_n=30): """Minimise th energy of every image in the NEB""" logger.info(f'Minimising to ∆E < {etol:.4f} Ha on all NEB coordinates') result = minimize(total_energy, x0=self.images.coords(), method='L-BFGS-B', jac=derivative, args=(self.images, method, n_cores), tol=etol, options={'maxfun': max_n}) logger.info(f'NEB path energy = {result.fun:.5f} Ha, {result.message}') return result
def _check_solvent(self): """Check that all the solvents are the same for reactants and products """ molecules = self.reacs + self.prods if self.solvent is None: if all([mol.solvent is None for mol in molecules]): logger.info('Reaction is in the gas phase') return elif all([mol.solvent is not None for mol in molecules]): if not all([mol.solvent == self.reacs[0].solvent for mol in molecules]): logger.critical('Solvents in reactants and products ' 'don\'t match') raise SolventsDontMatch else: logger.info(f'Setting the reaction solvent to ' f'{self.reacs[0].solvent}') self.solvent = self.reacs[0].solvent else: logger.critical('Some species solvated and some not!') raise SolventsDontMatch if self.solvent is not None: logger.info(f'Setting solvent to {self.solvent.name} for all ' f'molecules in the reaction') for mol in self.reacs + self.prods: mol.solvent = self.solvent logger.info(f'Set the solvent of all species in the reaction to ' f'{self.solvent.name}') return None
def get_atoms_rotated_stereocentres(species, atoms, rand): """If two stereocentres are bonded, rotate them randomly with respect to each other Arguments: species (autode.species.Species): atoms (list(autode.atoms.Atom)): rand (np.RandomState): random state Returns: (list(autode.atoms.Atom)): Atoms """ stereocentres = [ node for node in species.graph.nodes if species.graph.nodes[node]['stereo'] is True ] # Check on every pair of stereocenters for (i, j) in combinations(stereocentres, 2): if (i, j) not in species.graph.edges: continue # Don't rotate if the bond connecting the centers is a π-bond if species.graph.edges[i, j]['pi'] is True: logger.info('Stereocenters were π bonded – not rotating') continue try: left_idxs, right_idxs = split_mol_across_bond(species.graph, bond=(i, j)) except ex.CannotSplitAcrossBond: logger.warning('Splitting across this bond does not give two ' 'components - could have a ring') return atoms # Rotate the left hand side randomly rot_axis = atoms[i].coord - atoms[j].coord theta = 2 * np.pi * rand.rand() idxs_to_rotate = left_idxs if i in left_idxs else right_idxs # Rotate all the atoms to the left of this bond, missing out i as that # is the origin for rotation and thus won't move for n in idxs_to_rotate: if n == i: continue atoms[n].rotate(axis=rot_axis, theta=theta, origin=atoms[i].coord) return atoms
def run(self): """Run the calculation using the EST method """ logger.info(f'Running calculation {self.name}') # Set an input filename and generate the input self.generate_input() # Set the output filename, run the calculation and clean up the files self.output.filename = self.method.get_output_filename(self) self.execute_calculation() self.clean_up() self._add_to_comp_methods() return None
def calculation_terminated_normally(self, calc): for n_line, line in enumerate(reversed(calc.output.file_lines)): if any(substring in line for substring in['CITATION', 'Failed to converge in maximum number of steps or available time']): logger.info('nwchem terminated normally') return True if 'MPI_ABORT' in line: return False if n_line > 500: return False return False
def get_fbonds_bbonds_1b(reac, prod, possible_brs, all_possible_bbonds, all_possible_fbonds, possible_bbond_and_fbonds, bbond_atom_type_fbonds, fbond_atom_type_bbonds): logger.info('Getting possible 1 breaking bond rearrangements') for bbond in all_possible_bbonds[0]: # Break one bond possible_brs = add_bond_rearrangment(possible_brs, reac, prod, fbonds=[], bbonds=[bbond]) return possible_brs
def get_imaginary_freqs(self, calc): imag_freqs = [] for i, line in enumerate(calc.output.file_lines): if 'VIBRATIONAL FREQUENCIES' in line: last_line = i + 3 * calc.molecule.n_atoms + 5 # Reset every time freqs are found, so the final is returned freq_lines = calc.output.file_lines[i + 5:last_line] freqs = [float(l.split()[1]) for l in freq_lines] imag_freqs = [freq for freq in freqs if freq < 0] logger.info(f'Found imaginary freqs {imag_freqs}') return imag_freqs
def get_truncated_active_mol_graph(graph, active_bonds=None): """ Generate a truncated graph of a graph that only contains the active bond atoms and their nearest neighbours Arguments: graph (nx.Graph): active_bonds (list(tuple(int)): """ if active_bonds is None: # Molecular graph may already define the active edges active_bonds = [ pair for pair in graph.edges if graph.edges[pair]['active'] ] if len(active_bonds) == 0: raise ValueError('Could not generate truncated active molecular ' 'graph with no active bonds') t_graph = nx.Graph() # Add all nodes that connect active bonds for bond in active_bonds: for idx in bond: if idx not in t_graph.nodes: label = graph.nodes[idx]['atom_label'] t_graph.add_node(idx, atom_label=label) t_graph.add_edge(*bond, active=True, pi=False) # For every active atom add the nearest neighbours for idx in deepcopy(t_graph.nodes): neighbours = graph.neighbors(idx) # Add nodes and edges for all atoms and bonds to the neighbours that # don't already exist in the graph for n_atom_index in neighbours: if n_atom_index not in t_graph.nodes: label = graph.nodes[idx]['atom_label'] t_graph.add_node(n_atom_index, atom_label=label) if (idx, n_atom_index) not in t_graph.edges: t_graph.add_edge(idx, n_atom_index, pi=False, active=False) logger.info(f'Truncated graph generated. {t_graph.number_of_nodes()} ' f'nodes and {t_graph.number_of_edges()} edges') return t_graph
def single_point(self, method): """Calculate the single point energy of the species with a autode.wrappers.base.ElectronicStructureMethod""" logger.info(f'Running single point energy evaluation of {self.name}') sp = Calculation(name=f'{self.name}_sp', molecule=self, method=method, keywords=method.keywords.sp, n_cores=Config.n_cores) sp.run() self.energy = sp.get_energy() return None
def _init_smiles(self, smiles): """Initialise a molecule from a SMILES string using RDKit if it's purely organic""" if any(metal in smiles for metal in metals): init_smiles(self, smiles) else: init_organic_smiles(self, smiles) logger.info(f'Initialisation with SMILES successful. ' f'Charge={self.charge}, Multiplicity={self.mult}, ' f'Num. Atoms={self.n_atoms}') return None
def get_template_ts_guess(reactant, product, bond_rearrangement, name, method, dist_thresh=4.0): """Get a transition state guess object by searching though the stored TS templates Arguments: reactant (autode.complex.ReactantComplex): bond_rearrangement (autode.bond_rearrangement.BondRearrangement): product (autode.complex.ProductComplex): method (autode.wrappers.base.ElectronicStructureMethod): name (str): keywords (list(str)): Keywords to use for the ElectronicStructureMethod Keyword Arguments: dist_thresh (float): distance above which a constrained optimisation probably won't work due to the initial geometry being too far away from the ideal (default: {4.0}) Returns: TSGuess object: ts guess object """ logger.info('Getting TS guess from stored TS template') active_bonds_and_dists_ts = {} # This will add edges so don't modify in place mol_graph = get_truncated_active_mol_graph(graph=reactant.graph, active_bonds=bond_rearrangement.all) ts_guess_templates = get_ts_templates() for ts_template in ts_guess_templates: if not template_matches(reactant=reactant, ts_template=ts_template, truncated_graph=mol_graph): continue # Get the mapping from the matching template mapping = get_mapping_ts_template(larger_graph=mol_graph, smaller_graph=ts_template.graph) for active_bond in bond_rearrangement.all: i, j = active_bond try: active_bonds_and_dists_ts[active_bond] = ts_template.graph.edges[mapping[i], mapping[j]]['distance'] except KeyError: logger.warning(f'Couldn\'t find a mapping for bond {i}-{j}') if len(active_bonds_and_dists_ts) == len(bond_rearrangement.all): logger.info('Found a TS guess from a template') if any([reactant.get_distance(*bond) > dist_thresh for bond in bond_rearrangement.all]): logger.info(f'TS template has => 1 active bond distance larger than {dist_thresh}. Passing') pass else: return get_ts_guess_constrained_opt(reactant, method=method, keywords=method.keywords.opt, name=name, distance_consts=active_bonds_and_dists_ts, product=product) logger.info('Could not find a TS guess from a template') return None
def is_true_ts(self): """Is this TS a 'true' TS i.e. has at least on imaginary mode in the hessian and is the correct mode""" if self.energy is None: logger.warning('Cannot be true TS with no energy') return False if len(self.imaginary_frequencies) > 0: if self.has_correct_imag_mode(calc=self.optts_calc): logger.info('Found a transition state with the correct ' 'imaginary mode & links reactants and products') return True return False
def get_imaginary_freqs(self, calc): """frequencies() needs to be used in the psi4 input file.""" imaginary_frequencies = [] for line in calc.output.file_lines: if 'post-proj' in line and 'i' in line and '[cm^-1]' in line: imaginary_frequencies.append(line.split()[3]) if len(imaginary_frequencies) > 0: logger.info(f'Found imaginary frequencies {imaginary_frequencies}') return imaginary_frequencies else: logger.info('Could not find any imaginary frequencies.') return None
def increment(self): """Increment the counter, and switch on a climbing image""" super().increment() if self[0].iteration < self.wait_iteration: # No need to do anything else return if self.peak_idx is None: logger.error('Lost NEB peak - cannot switch on CI') return logger.info(f'Setting image {self.peak_idx} as the CI') self[self.peak_idx] = CImage(image=self[self.peak_idx]) return None
def products_made(self): """Check that somewhere on the surface the molecular graph is isomorphic to the product""" logger.info('Checking product(s) are made somewhere on the surface') for i in range(self.n_points_r1): for j in range(self.n_points_r2): make_graph(self.species[i, j]) if is_isomorphic(graph1=self.species[i, j].graph, graph2=self.product_graph): logger.info(f'Products made at ({i}, {j})') return True return False
def get_mapping(graph, other_graph): """Return a sorted mapping""" logger.info('Running isomorphism') gm = isomorphism.GraphMatcher( graph, other_graph, node_match=isomorphism.categorical_node_match('atom_label', 'C')) try: mapping = next(gm.match()) except StopIteration: raise ex.NoMapping return {i: mapping[i] for i in sorted(mapping)}
def _update_graph(self): """Update the molecular graph to include all the bonds that are being made/broken""" if self.bond_rearrangement is None: logger.warning('Bond rearrangement not set - molecular graph ' 'updating with no active bonds') active_bonds = [] else: active_bonds = self.bond_rearrangement.all set_active_mol_graph(species=self, active_bonds=active_bonds) logger.info(f'Molecular graph updated with active bonds') return None
def get_unique_confs(conformers, energy_threshold_kj=1): """ For a list of conformers return those that are unique based on an energy threshold in kJ mol^-1 Arguments: conformers (list(autode.conformer.Conformer)): energy_threshold_kj (float): Energy threshold in kJ mol-1 Returns: (list(autode.conformers.conformers.Conformer)): List of conformers """ logger.info(f'Stripping conformers with energy ∆E < {energy_threshold_kj} ' f'kJ mol-1 to others') n_conformers = len(conformers) # Conformer.energy is in Hartrees threshold = energy_threshold_kj / Constants.ha2kJmol # The first conformer must be unique, if it has an energy unique_conformers = [] for conformer in conformers: if conformer.energy is None: logger.error('Conformer had no energy. Excluding') continue # Iterate through all the unique conformers already found and check # that the energy is not similar unique = True for other_conformer in unique_conformers: if np.abs(conformer.energy - other_conformer.energy) < threshold: unique = False break if unique: unique_conformers.append(conformer) n_unique_conformers = len(unique_conformers) logger.info(f'Stripped {n_conformers - n_unique_conformers} conformer(s) ' f'from a total of {n_conformers}') if n_unique_conformers == 0: logger.error('Have no conformers!') return unique_conformers
def get_fbonds_bbonds_2b1f(reac, prod, possible_brs, all_possible_bbonds, all_possible_fbonds, possible_bbond_and_fbonds, bbond_atom_type_fbonds, fbond_atom_type_bbonds): logger.info('Getting possible 2 breaking and 1 forming bond rearrangements') if len(all_possible_bbonds) == 2 and len(all_possible_fbonds) == 1: # Make a bond and break two bonds, all of different types possibles = itertools.product(all_possible_fbonds[0], all_possible_bbonds[0], all_possible_bbonds[1]) for fbond, bbond1, bbond2 in possibles: possible_brs = add_bond_rearrangment(possible_brs, reac, prod, fbonds=[fbond], bbonds=[bbond1, bbond2]) elif len(all_possible_bbonds) == 1 and len(all_possible_fbonds) == 1: # Make a bond of one type, break two bonds of another type two_same_possibles = itertools.combinations(all_possible_bbonds[0], 2) possibles = itertools.product(all_possible_fbonds[0], two_same_possibles) for fbond, (bbond1, bbond2) in possibles: possible_brs = add_bond_rearrangment(possible_brs, reac, prod, fbonds=[fbond], bbonds=[bbond1, bbond2]) elif len(all_possible_bbonds) == 1 and len(all_possible_fbonds) == 0: for bbonds, fbonds in possible_bbond_and_fbonds: # Make and break a bond of one type, break a bond of a different # type possibles = itertools.product(fbonds, all_possible_bbonds[0], bbonds) for fbond, bbond1, bbond2 in possibles: possible_brs = add_bond_rearrangment(possible_brs, reac, prod, fbonds=[fbond], bbonds=[bbond1, bbond2]) # Make and break two bonds, all of the same type two_same_possibles = itertools.combinations(all_possible_bbonds[0], 2) possibles = itertools.product(bbond_atom_type_fbonds, two_same_possibles) for fbond, (bbond1, bbond2) in possibles: possible_brs = add_bond_rearrangment(possible_brs, reac, prod, fbonds=[fbond], bbonds=[bbond1, bbond2]) return possible_brs
def calculate_reaction_profile(self, units=KcalMol): """Calculate a multistep reaction profile using the products of step 1 as the reactants of step 2 etc.""" logger.info('Calculating reaction profile') h_method = get_hmethod() @work_in(self.name) def calculate(reaction, prev_products): for mol in reaction.reacs + reaction.prods: # If this molecule is one of the previous products don't # regenerate conformers skip_conformers = False for prod in prev_products: # Has the same atoms & charge etc. as in the unique string if str(mol) == str(prod): mol = prod skip_conformers = True if not skip_conformers: mol.find_lowest_energy_conformer( hmethod=h_method if Config.hmethod_conformers else None ) reaction.optimise_reacs_prods() reaction.find_complexes() reaction.locate_transition_state() reaction.find_lowest_energy_ts_conformer() reaction.calculate_single_points() return None # For all the reactions calculate the reactants, products and TS for i, r in enumerate(self.reactions): r.name = f'{self.name}_step{i}' if i == 0: # First reaction has no previous products calculate(reaction=r, prev_products=[]) else: calculate(reaction=r, prev_products=self.reactions[i - 1].prods) plot_reaction_profile(self.reactions, units=units, name=self.name) return None
def plot_reaction_profile(reactions, units, name, free_energy=False, enthalpy=False): """For a set of reactions plot the reaction profile using matplotlib Arguments: reactions (list((autode.reaction.Reaction)): units (autode.units.Units): name (str): Keyword Arguments: free_energy (bool): Plot the free energy profile (G) enthalpy (bool): Plot the enthalpic profile (H) """ logger.info('Plotting reaction profile') if free_energy and enthalpy: raise AssertionError('Cannot plot a profile in both G and H') fig, ax = plt.subplots() # Get the energies for the reaction profile (y values) plotted against the # reaction coordinate (zi_s) energies = calculate_reaction_profile_energies(reactions, units=units, free_energy=free_energy, enthalpy=enthalpy) zi_s = np.array(range(len(energies))) try: plot_smooth_profile(zi_s, energies, ax=ax) except CouldNotPlotSmoothProfile: plot_points(zi_s, energies, ax=ax) ec = 'E' if free_energy: ec = 'G' elif enthalpy: ec = 'H' plt.ylabel(f'∆${ec}$ / {units.name}', fontsize=12) plt.ylim(min(energies)-3, max(energies)+3) plt.xticks([]) plt.subplots_adjust(top=0.95, right=0.95) fig.text(.1, .05, get_reaction_profile_warnings(reactions), ha='left', fontsize=6, wrap=True) return save_plot(plt, filename=f'{name}_reaction_profile.png')
def add_remaining_atoms(truncated_graph, full_graph, s_molecule): """Truncation can lead to a split across a C-C bond in a ring where one of the carbons is no longer has 4 nearest neighbours""" for i in deepcopy(truncated_graph.nodes): # No modification needed if the valency of this atom is retained n_truncated_neighbours = len(list(truncated_graph.neighbors(i))) n_full_neighbours = len(list(full_graph.neighbors(i))) if n_truncated_neighbours == n_full_neighbours: continue # Only consider non-swapped atoms e.g. not where C -> H if truncated_graph.nodes[i]['atom_label'] != full_graph.nodes[i][ 'atom_label']: continue logger.warning(f'Atom {i} changed valency in truncation') for n in nx.neighbors(full_graph, i): if (i, n) in truncated_graph.edges: continue # Missing atom n from the truncated graph - probably truncated # X -> H but was also bonded to another atom also in the truncated # graph. x, y, z = s_molecule.atoms[n].coord s_molecule.atoms.append(Atom(atomic_symbol='Og', x=x, y=y, z=z)) # Add the capping H atom in place of the X atom just added # will be the last atom index, if it's just been added add_capping_atom(atom_index=i, n_atom_index=len(s_molecule.atoms) - 1, graph=truncated_graph, s_molecule=s_molecule) # Also add the edge between the added atom and the one that changed # valency truncated_graph.add_edge(i, len(s_molecule.atoms) - 1, pi=False, active=False) logger.info( f'New valency is {len(list(truncated_graph.neighbors(i)))}') return None
def _generate_conformers(self): """ Generate rigid body conformers of a complex by (1) Fixing the first m olecule, (2) initialising the second molecule's COM evenly on the points of a sphere around the first with a random rotation and (3) iterating until all molecules in the complex have been added """ if len(self.molecules) < 2: # Single (or zero) molecule complex only has a single *rigid body* # conformer self.conformers = [get_conformer(name=self.name, species=self)] return None n_molecules = len(self.molecules) # Number of molecules in the complex self.conformers = [] n = 0 # Current conformer number points_on_sphere = get_points_on_sphere( n_points=Config.num_complex_sphere_points) for _ in iterprod(range(Config.num_complex_random_rotations), repeat=n_molecules - 1): # Generate the rotation thetas and axes rotations = [ np.random.uniform(-np.pi, np.pi, size=4) for _ in range(n_molecules - 1) ] for points in iterprod(points_on_sphere, repeat=n_molecules - 1): conformer = get_conformer(species=self, name=f'{self.name}_conf{n}') atoms = get_complex_conformer_atoms(self.molecules, rotations, points) conformer.set_atoms(atoms) self.conformers.append(conformer) n += 1 if n == Config.max_num_complex_conformers: logger.warning( f'Generated the maximum number of complex conformers ({n})' ) return None logger.info(f'Generated {n} conformers') return None
def set_lines(self): """ Set the output files lines. This may be slow for large files but should not become a bottleneck when running standard DFT/WF calculations Returns: (None) """ logger.info('Setting output file lines') if not os.path.exists(self.filename): raise ex.NoCalculationOutput self.file_lines = open(self.filename, 'r', encoding="utf-8").readlines() return None
def get_gradients(self): """ Get the gradient (dE/dr) with respect to atomic displacement from a calculation Returns: (np.ndarray): Gradient vectors for each atom (Ha Å^-1) gradients.shape = (n_atoms, 3) """ logger.info(f'Getting gradients from {self.output.filename}') gradients = self.method.get_gradients(self) if len(gradients) != self.molecule.n_atoms: raise ex.CouldNotGetProperty(name='gradients') return gradients
def _init_tensors(self, reactant, r1s, r2s): """Initialise the matrices of Species and distances""" logger.info(f'Initialising the {len(r1s)}x{len(r2s)} PES matrices') assert self.rs.shape == self.species.shape for i in range(len(self.rs)): for j in range(len(self.rs[i])): # Tuple of distances self.rs[i, j] = (r1s[i], r2s[j]) # Copy of the reactant complex, whose atoms/energy will be set in the # scan self.species[0, 0] = deepcopy(reactant) return None
def _minimise(self, method, n_cores, etol, max_n=30): """Minimise th energy of every image in the NEB""" logger.info(f'Minimising to ∆E < {etol:.4f} Ha on all NEB coordinates') result = super()._minimise(method, n_cores, etol, max_n) if any(im.iteration > self.images.wait_iteration for im in self.images): return result logger.info('Converged before CI was turned on. Reducing the wait and ' 'minimising again') self.images.wait_iteration = max(im.iteration for im in self.images) result = super()._minimise(method, n_cores, etol, max_n) return result
def calc_delta_e(self): """Calculate the ∆Er of a reaction defined as ∆E = E(products) - E(reactants) Returns: (float): Energy difference in Hartrees """ logger.info('Calculating ∆Er') if any(mol.energy is None for mol in self.reacs + self.prods): logger.error('Cannot calculate ∆Er. At least one required energy ' 'was None') return None return (sum([p.energy for p in self.prods]) - sum([r.energy for r in self.reacs]))
def get_atomic_charges(self): """ Get the partial atomic charges from a calculation. The method used to calculate them depends on the QM method and are implemented in their respective wrappers Returns: (list(float)): Atomic charges in units of e """ logger.info(f'Getting atomic charges from {self.output.filename}') charges = self.method.get_atomic_charges(self) if len(charges) != self.molecule.n_atoms: raise ex.CouldNotGetProperty(name='atomic charges') return charges
def _generate_conformers(self, n_confs=None): """ Use a simulated annealing approach to generate conformers for this molecule. Keyword Arguments: n_confs (int): Number of conformers requested if None default to autode.Config.num_conformers """ n_confs = n_confs if n_confs is not None else Config.num_conformers self.conformers = [] if self.smiles is not None and self.rdkit_conf_gen_is_fine: logger.info(f'Using RDKit to gen conformers. {n_confs} requested') method = AllChem.ETKDGv2() method.pruneRmsThresh = Config.rmsd_threshold method.numThreads = Config.n_cores logger.info('Running conformation generation with RDKit... running') conf_ids = list(AllChem.EmbedMultipleConfs(self.rdkit_mol_obj, numConfs=n_confs, params=method)) logger.info(' ... done') conf_atoms_list = [get_atoms_from_rdkit_mol_object(self.rdkit_mol_obj, conf_id) for conf_id in conf_ids] else: logger.info('Using simulated annealing to generate conformers') with Pool(processes=Config.n_cores) as pool: results = [pool.apply_async(get_simanl_atoms, (self, None, i)) for i in range(n_confs)] conf_atoms_list = [res.get(timeout=None) for res in results] for i, atoms in enumerate(conf_atoms_list): conf = Conformer(name=f'{self.name}_conf{i}', charge=self.charge, mult=self.mult, atoms=atoms) # If the conformer is unique on an RMSD threshold if conf_is_unique_rmsd(conf, self.conformers): conf.solvent = self.solvent self.conformers.append(conf) logger.info(f'Generated {len(self.conformers)} unique conformer(s)') return None