def imag_mode_generates_other_bonds(ts, f_species, b_species, bond_rearrangement): """Determine if the forward or backwards displaced molecule break or make bonds that aren't in all the active bonds bond_rearrangement.all. Will be fairly conservative here""" for species in (ts, f_species, b_species): make_graph(species, rel_tolerance=0.3) for product in (f_species, b_species): new_bonds_in_product = set([ bond for bond in product.graph.edges if bond not in ts.graph.edges ]) # If there are new bonds in the forward displaced species that are not # part of the bond rearrangement if any(bond not in bond_rearrangement.all for bond in new_bonds_in_product): logger.warning(f'New bonds in product: {new_bonds_in_product}') logger.warning(f'Bond rearrangement: {bond_rearrangement.all}') return True logger.info('Imaginary mode does not generate any other unwanted bonds') return False
def calc_multiplicity(molecule, n_radical_electrons): """Calculate the spin multiplicity 2S + 1 where S is the number of unpaired electrons Arguments: molecule (autode.molecule.Molecule): n_radical_electrons (int): Returns: int: multiplicity of the molecule """ if molecule.mult == 1 and n_radical_electrons == 0: return 1 if molecule.mult == 1 and n_radical_electrons == 1: # Cannot have multiplicity = 1 and 1 radical electrons – override # default multiplicity return 2 if molecule.mult == 1 and n_radical_electrons > 1: logger.warning('Diradicals by default singlets. Set mol.mult if it\'s ' 'any different') return 1 return molecule.mult
def get_free_energy(self, calc): """Get the Gibbs free energy (G) from an ORCA calculation output""" if calc.molecule.n_atoms == 1: logger.warning('ORCA fails to calculate the entropy for a single ' 'atom, returning the correct G in 1 atm') h = self.get_enthalpy(calc) s = calc_atom_entropy(atom_label=calc.molecule.atoms[0].label, temp=calc.input.temp) # J K-1 mol-1 # Calculate H - TS, the latter term from Jmol-1 -> Ha return h - s * calc.input.temp for line in reversed(calc.output.file_lines): if ('Final Gibbs free energy' in line or 'Final Gibbs free enthalpy' in line): try: return float(line.split()[-2]) except ValueError: break logger.error('Could not get the free energy from the calculation. ' 'Was a frequency requested?') return None
def are_coords_reasonable(coords): """ Determine if a set of coords are reasonable. No distances can be < 0.7 Å and if there are more than 4 atoms ensure they do not all lie in the same plane. The latter possibility arises from RDKit's conformer generation algorithm breaking Arguments: coords (np.ndarray): Species coordinates as a n_atoms x 3 array Returns: bool: """ n_atoms = len(coords) # Generate a n_atoms x n_atoms matrix with ones on the diagonal dist_mat = distance_matrix(coords, coords) + np.identity(n_atoms) if np.min(dist_mat) < 0.7: logger.warning('There is a distance < 0.7 Å. Structure is *not* ' 'sensible') return False if n_atoms > 4: if all([coord[2] == 0.0 for coord in coords]): logger.warning('RDKit likely generated a wrong geometry. Structure' ' is *not* sensible') return False return True
def _set_lowest_energy_conformer(self): """Set the species energy and atoms as those of the lowest energy conformer""" lowest_energy = None for conformer in self.conformers: if conformer.energy is None: continue # Conformers don't have a molecular graph, so make it make_graph(conformer) if not is_isomorphic(conformer.graph, self.graph, ignore_active_bonds=True): logger.warning('Conformer had a different graph. Ignoring') continue # If the conformer retains the same connectivity, up the the active # atoms in the species graph if lowest_energy is None: lowest_energy = conformer.energy if conformer.energy <= lowest_energy: self.energy = conformer.energy self.set_atoms(atoms=conformer.atoms) lowest_energy = conformer.energy return None
def modify_keywords_for_point_charges(keywords): """For a list of Gaussian keywords modify to include z-matrix if not already included. Required if point charges are included in the calc""" logger.warning('Modifying keywords as point charges are present') keywords.append('Charge') for keyword in keywords: if 'opt' not in keyword.lower(): continue opt_options = [] if '=(' in keyword: # get the individual options unformated_options = keyword[5:-1].split(',') opt_options = [ option.lower().strip() for option in unformated_options ] elif '=' in keyword: opt_options = [keyword[4:]] if not any(option.lower() == 'z-matrix' for option in opt_options): opt_options.append('Z-Matrix') new_keyword = f'Opt=({", ".join(opt_options)})' keywords.remove(keyword) keywords.append(new_keyword) return None
def has_correct_imag_mode(self, calc=None, method=None): """Check that the imaginary mode is 'correct' set the calculation (hessian or optts)""" self.calc = calc if calc is not None else self.calc # By default the high level method is used to check imaginary modes if method is None: method = get_hmethod() # Run a fast check on whether it's likely the mode is correct if not self.could_have_correct_imag_mode(method=method): return False if imag_mode_has_correct_displacement(self.calc, self.bond_rearrangement): logger.info('Displacement of the active atoms in the imaginary ' 'mode bond forms and breaks the correct bonds') return True # Perform displacements over the imaginary mode to ensure the mode # connects reactants and products if imag_mode_links_reactant_products(self.calc, self.reactant, self.product, method=method): logger.info('Imaginary mode does link reactants and products') return True logger.warning('Species does *not* have the correct imaginary mode') return False
def contains_peak(species_list): """ Does this list of species contain a peak in the energy? Arguments: species_list (list(autode.species.Species): Returns: (bool): """ if any(species.energy is None for species in species_list): logger.warning('Cannot determine if path contains a peak, an E=None') return False for i, species in enumerate(species_list): # Cannot be a peak on the end points if i == 0 or i == len(species_list) - 1: continue # Points either side of this species must be lower in energy if all(species_list[k].energy < species.energy for k in (i - 1, i + 1)): return True return False
def get_keywords(calc_input, molecule): """Generate a keywords list and adding solvent""" new_keywords = [] scf_block = False for keyword in calc_input.keywords: if isinstance(keyword, kws.Functional): keyword = f'dft\n maxiter 100\n xc {keyword.nwchem}\nend' elif isinstance(keyword, kws.BasisSet): keyword = f'basis\n * library {keyword.nwchem}\nend' elif isinstance(keyword, kws.Keyword): keyword = keyword.nwchem if 'opt' in keyword.lower() and molecule.n_atoms == 1: logger.warning('Cannot do an optimisation for a single atom') # Replace any 'opt' containing word in this keyword with energy words = [] for word in keyword.split(): if 'opt' in word: words.append('energy') else: words.append(word) new_keywords.append(' '.join(words)) elif keyword.lower().startswith('dft'): lines = keyword.split('\n') lines.insert(1, f' mult {molecule.mult}') new_keyword = '\n'.join(lines) new_keywords.append(new_keyword) elif keyword.lower().startswith('scf'): if calc_input.solvent: logger.critical('nwchem only supports solvent for DFT calcs') raise UnsuppportedCalculationInput scf_block = True lines = keyword.split('\n') lines.insert(1, f' nopen {molecule.mult - 1}') new_keyword = '\n'.join(lines) new_keywords.append(new_keyword) elif (any(st in keyword.lower() for st in ['ccsd', 'mp2']) and not scf_block): if calc_input.solvent.keyword is not None: logger.critical('nwchem only supports solvent for DFT calcs') raise UnsuppportedCalculationInput new_keywords.append(f'scf\n nopen {molecule.mult - 1}\nend') new_keywords.append(keyword) else: new_keywords.append(keyword) return new_keywords
def get_species_saddle_point(self): """Find a TS guess species for this NEB: highest energy saddle point""" if self.images.peak_idx is None: logger.warning('Found no peaks in the NEB') return None return self.images[self.images.peak_idx].species
def run_external_monitored(params, output_filename, break_word='MPI_ABORT'): """ Run an external process monitoring the standard output and error for a word that will terminate the process Arguments: params (list(str)): output_filename (str): Keyword Arguments: break_word (str): String that if found will terminate the process """ def output_reader(process, out_file): for line in process.stdout: if break_word in line.decode('utf-8'): raise ChildProcessError print(line.decode('utf-8'), end='', file=out_file) return None with open(output_filename, 'w') as output_file: proc = Popen(params, stdout=PIPE, stderr=STDOUT) try: output_reader(proc, output_file) except ChildProcessError: logger.warning('External terminated') proc.terminate() return return None
def remove_bonds_invalid_valancies(species): """ Remove invalid valencies for atoms that exceed their maximum valencies e.g. H should have no more than 1 'bond' Arguments: species (autode.species.Species): """ for i in species.graph.nodes: max_valance = get_maximal_valance(atom_label=species.atoms[i].label) neighbours = list(species.graph.neighbors(i)) if len(neighbours) <= max_valance: # All is well continue logger.warning(f'Atom {i} exceeds its maximal valence removing edges') # Get the atom indexes sorted by the closest to atom i closest_atoms = sorted(neighbours, key=lambda j: species.get_distance(i, j)) # Delete all the bonds to atom(s) j that are above the maximal valance for j in closest_atoms[max_valance:]: species.graph.remove_edge(i, j) return None
def find_lowest_energy_ts_conformer(self): """Find the lowest energy transition state conformer by performing constrained optimisations""" atoms, energy = deepcopy(self.atoms), deepcopy(self.energy) calc = deepcopy(self.optts_calc) hmethod = get_hmethod() if Config.hmethod_conformers else None self.find_lowest_energy_conformer(hmethod=hmethod) if len(self.conformers) == 1: logger.warning('Only found a single conformer. ' 'Not rerunning TS optimisation') self.set_atoms(atoms=atoms) self.energy = energy self.optts_calc = calc return None self.optimise(name_ext='optts_conf') if self.is_true_ts() and self.energy < energy: logger.info('Conformer search successful') else: logger.warning(f'Transition state conformer search failed ' f'(∆E = {energy - self.energy:.4f} Ha). Reverting') self.set_atoms(atoms=atoms) self.energy = energy self.optts_calc = calc self.imaginary_frequencies = calc.get_imaginary_freqs() return None
def method_string(self): """Generate a string with refs (dois) for this method e.g. PBE0-D3BJ""" string = '' func = self.functional() if func is not None: string += f'{func.upper()}({func.doi_str()})' disp = self.dispersion() if disp is not None: string += f'-{disp.upper()}({disp.doi_str()})' wf = self.wf_method() if wf is not None: string += f'{str(wf)}({wf.doi_str()})' ri = self._get_keyword(keyword_type=RI) if ri is not None: string += f'({ri.upper()}, {ri.doi_str()})' if len(string) == 0: logger.warning('Unknown method') string = '???' return string
def get_distance_constraints(species): """Set all the distance constraints required in an optimisation as the active bonds Arguments: species (autode.species.Species): Returns: (dict): Keyed with atom indexes for the active atoms (tuple) and equal to the constrained value """ distance_constraints = {} if species.graph is None: logger.warning('Molecular graph was not set cannot find any distance ' 'constraints') return None # Add the active edges(/bonds) in the molecular graph to the dict, value # being the current distance for edge in species.graph.edges: if species.graph.edges[edge]['active']: distance_constraints[edge] = species.distance(*edge) return distance_constraints
def get_avg_bond_length(atom_i_label, atom_j_label): """Get the average bond length between two atoms with their labels (atomic symbols) e.g. (atom_i_label='C', atom_j_label ='H') -> 1.1 Å (atom_i_label='H', atom_j_label ='C') -> 1.1 Å ordering invariant. Keyword Arguments: atom_i_label (str): atom label e.g 'C' atom_j_label (str): atom label e.g 'C' Returns: (float): Average bond length of the bond (Å) """ key1, key2 = atom_i_label + atom_j_label, atom_j_label + atom_i_label if key1 in avg_bond_lengths.keys(): return avg_bond_lengths[key1] elif key2 in avg_bond_lengths.keys(): return avg_bond_lengths[key2] else: logger.warning(f'Couldn\'t find a default bond length for ' f'({atom_i_label},{atom_j_label}). Using 1.5 Å') return 1.5
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 could_have_correct_imag_mode(self, method=None, threshold=-45): """ Determine if a point on the PES could have the correct imaginary mode. This must have (0) An imaginary frequency (quoted as negative in most EST codes) (1) The most negative(/imaginary) is more negative that a threshold Keywords Arguments: method (autode.wrappers.base.ElectronicStructureMethod): threshold (float): Returns: (bool): """ self._check_reactants_products() # By default the high level method is used to check imaginary modes if method is None: method = get_hmethod() if self.calc is None: logger.info('Calculating the hessian..') self.calc = Calculation(name=self.name + '_hess', molecule=self, method=method, keywords=method.keywords.hess, n_cores=Config.n_cores) self.calc.run() imag_freqs = self.calc.get_imaginary_freqs() if len(imag_freqs) == 0: logger.warning('Hessian had no imaginary modes') return False if len(imag_freqs) > 1: logger.warning(f'Hessian had {len(imag_freqs)} imaginary modes') if imag_freqs[0] > threshold: logger.warning('Imaginary modes were too small to be significant') return False try: _ = self.calc.get_normal_mode_displacements(mode_number=6) except NoNormalModesFound: logger.warning('No normal modes could be found cannot determine if' 'this the correct imaginary mode is found') return None # Check very conservatively for the correct displacement if not imag_mode_has_correct_displacement(self.calc, self.bond_rearrangement, delta_threshold=0.05, req_all=False): logger.warning('Species does not have the correct imaginary mode') return False logger.info('Species could have the correct imaginary mode') return True
def terminated_normally(self): """Determine if the calculation terminated without error""" logger.info(f'Checking for {self.output.filename} normal termination') if not self.output.exists(): logger.warning('Calculation did not generate any output') return False return self.method.calculation_terminated_normally(self)
def get_version(self, calc): """Get the version of ORCA used to execute this calculation""" for line in calc.output.file_lines: if 'Program Version' in line and len(line.split()) >= 3: return line.split()[2] logger.warning('Could not find the ORCA version number') return '???'
def get_version(self, calc): """Get the version of Gaussian used in this calculation""" for line in calc.output.file_lines: if line.startswith('Gaussian ') and 'Revision' in line: return line.lstrip('Gaussian ') logger.warning('Could not find the Gaussian version number') return '???'
def get_version(self, calc): """Get the XTB version from the output file""" for line in calc.output.file_lines: if 'xtb version' in line and len(line.split()) >= 4: # e.g. * xtb version 6.2.3 (830e466) compiled by .... return line.split()[3] logger.warning('Could not find the XTB version in the output file') return '???'
def get_version(self, calc): """Get the NWChem version from the output file""" for line in calc.output.file_lines: if '(NWChem)' in line: # e.g. Northwest Computational Chemistry Package (NWChem) 6.6 return line.split()[-1] logger.warning('Could not find the NWChem version') return '???'
def save_plot(plot, filename): """Save a plot""" if os.path.exists(filename): logger.warning('Plot already exists. Overriding..') os.remove(filename) plot.savefig(filename, dpi=400 if Config.high_quality_plots else 100) plot.close() return None
def species_are_isomorphic(species1, species2): """ Check if two complexes are isomorphic in at least one of their conformers Arguments: species1 (autode.species.Species): species2 (autode.species.Species): Returns: (bool): """ logger.info(f'Checking if {species1.name} and {species2.name} are ' f'isomorphic') if species1.graph is None or species2.graph is None: raise ex.NoMolecularGraph if is_isomorphic(species1.graph, species2.graph): return True if species1.conformers is None and species2.conformers is None: logger.warning('Cannot check for isomorphic species conformers') return False # Conformers don't necessarily have molecular graphs, so make them all logger.disabled = True for species in (species1, species2): if species.conformers is None: continue for conformer in species.conformers: make_graph(conformer) logger.disabled = False # Check on all the pairwise combinations of species conformers looking for # an isomorphism def conformers_or_self(species): """If there are no conformers for this species return itself otherwise the list of conformers""" if species.conformers is None: return [species] return species.conformers # Check on all pairs of conformers between the two species for conformer1 in conformers_or_self(species1): for conformer2 in conformers_or_self(species2): if is_isomorphic(conformer1.graph, conformer2.graph): return True return False
def calc_delta_with_cont(left, right, cont): """Calculate a ∆H or ∆G by adding a contribution to ∆E""" de = calc_delta(attr='energy', left=left, right=right) d_cont = calc_delta(attr=cont, left=left, right=right) if de is None or d_cont is None: logger.warning('Could not calculate ∆ either the energy or thermal ' 'contribution was None') return None return de + d_cont
def find_lowest_energy_conformer(self, lmethod=None, hmethod=None): """ For a molecule object find the lowest conformer in energy and set the molecule.atoms and molecule.energy Arguments: lmethod (autode.wrappers.ElectronicStructureMethod): hmethod (autode.wrappers.ElectronicStructureMethod): """ logger.info('Finding lowest energy conformer') if self.n_atoms <= 2: logger.warning('Cannot have conformers of a species with 2 atoms ' 'or fewer') return None if lmethod is None: logger.info('Getting the default low level method') lmethod = get_lmethod() methods.add('Low energy conformers located with the') self._generate_conformers() # For all generated conformers optimise with the low level of theory method_string = f'and optimised using {lmethod.name}' if hmethod is not None: method_string += f' then with {hmethod.name}' methods.add(f'{method_string}.') for conformer in self.conformers: conformer.optimise(lmethod) # Strip conformers that are similar based on an energy criteria or # don't have an energy self.conformers = get_unique_confs(conformers=self.conformers) if hmethod is not None: # Re-evaluate the energy of all the conformers with the higher # level of theory for conformer in self.conformers: if Config.hmethod_sp_conformers: assert hmethod.keywords.low_sp is not None conformer.single_point(hmethod) else: # Otherwise run a full optimisation conformer.optimise(hmethod) self._set_lowest_energy_conformer() logger.info(f'Lowest energy conformer found. E = {self.energy}') return None
def rel_energies(self): """ "Relative energies in a particular unit Returns: (np.ndarray): """ if len(self) == 0: logger.warning('Cannot determine relative energies with no points') return np.array([]) return self.units.conversion * (self.energies - np.min(self.energies))
def _run_opt_ts_calc(self, method, name_ext): """Run an optts calculation and attempt to set the geometry, energy and normal modes""" if self.bond_rearrangement is None: logger.warning('Cannot add redundant internal coordinates for the ' 'active bonds with no bond rearrangement') bond_ids = None else: bond_ids = self.bond_rearrangement.all self.optts_calc = Calculation( name=f'{self.name}_{name_ext}', molecule=self, method=method, n_cores=Config.n_cores, keywords=method.keywords.opt_ts, bond_ids_to_add=bond_ids, other_input_block=method.keywords.optts_block) self.optts_calc.run() if not self.optts_calc.optimisation_converged(): if self.optts_calc.optimisation_nearly_converged(): logger.info('Optimisation nearly converged') self.calc = self.optts_calc if self.could_have_correct_imag_mode(): logger.info('Still have correct imaginary mode, trying ' 'more optimisation steps') self.atoms = self.optts_calc.get_final_atoms() self.optts_calc = Calculation( name=f'{self.name}_{name_ext}_reopt', molecule=self, method=method, n_cores=Config.n_cores, keywords=method.keywords.opt_ts, bond_ids_to_add=bond_ids, other_input_block=method.keywords.optts_block) self.optts_calc.run() else: logger.info('Lost imaginary mode') else: logger.info('Optimisation did not converge') try: self.imaginary_frequencies = self.optts_calc.get_imaginary_freqs() self.atoms = self.optts_calc.get_final_atoms() self.energy = self.optts_calc.get_energy() except (AtomsNotFound, NoNormalModesFound): logger.error('Transition state optimisation calculation failed') return
def init_organic_smiles(molecule, smiles): """ Initialise a molecule from a SMILES string, set the charge, multiplicity ( if it's not already specified) and the 3D geometry using RDKit Arguments: molecule (autode.molecule.Molecule): smiles (str): SMILES string """ try: molecule.rdkit_mol_obj = Chem.MolFromSmiles(smiles) if molecule.rdkit_mol_obj is None: logger.warning('RDKit failed to initialise a molecule') return init_smiles(molecule, smiles) molecule.rdkit_mol_obj = Chem.AddHs(molecule.rdkit_mol_obj) except RuntimeError: raise RDKitFailed molecule.charge = Chem.GetFormalCharge(molecule.rdkit_mol_obj) molecule.mult = calc_multiplicity(molecule, NumRadicalElectrons(molecule.rdkit_mol_obj)) bonds = [(bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()) for bond in molecule.rdkit_mol_obj.GetBonds()] # Generate a single 3D structure using RDKit's ETKDG conformer generation # algorithm method = AllChem.ETKDGv2() method.randomSeed = 0xf00d AllChem.EmbedMultipleConfs(molecule.rdkit_mol_obj, numConfs=1, params=method) molecule.atoms = atoms_from_rdkit_mol(molecule.rdkit_mol_obj, conf_id=0) make_graph(molecule, bond_list=bonds) # Revert back to RR if RDKit fails to return a sensible geometry if not are_coords_reasonable(coords=molecule.coordinates): molecule.rdkit_conf_gen_is_fine = False molecule.atoms = get_simanl_atoms(molecule, save_xyz=False) for atom, _ in Chem.FindMolChiralCenters(molecule.rdkit_mol_obj): molecule.graph.nodes[atom]['stereo'] = True for bond in molecule.rdkit_mol_obj.GetBonds(): if bond.GetBondType() != Chem.rdchem.BondType.SINGLE: molecule.graph.edges[bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()]['pi'] = True if bond.GetStereo() != Chem.rdchem.BondStereo.STEREONONE: molecule.graph.nodes[bond.GetBeginAtomIdx()]['stereo'] = True molecule.graph.nodes[bond.GetEndAtomIdx()]['stereo'] = True check_bonds(molecule, bonds=molecule.rdkit_mol_obj.GetBonds()) return None