Example #1
0
def get_optimised_species(calc, method, direction, atoms):
    """Get the species that is optimised from an initial set of atoms"""

    species = Molecule(name=f'{calc.name}_{direction}',
                       atoms=atoms,
                       charge=calc.molecule.charge,
                       mult=calc.molecule.mult)

    # Note that for the surface to be the same the keywords.opt and keywords.hess need to match in the level of theory
    calc = Calculation(name=f'{calc.name}_{direction}',
                       molecule=species,
                       method=method,
                       keywords=method.keywords.opt,
                       n_cores=Config.n_cores)
    calc.run()

    try:
        species.set_atoms(atoms=calc.get_final_atoms())
        species.energy = calc.get_energy()
        make_graph(species)

    except AtomsNotFound:
        logger.error(f'{direction} displacement calculation failed')

    return species
Example #2
0
def get_truncated_ts(reaction, bond_rearr):
    """Get the TS of a truncated reactant and product complex"""

    # Truncate the reactant and product complex to the core atoms so the full
    # TS can be template-d
    f_reactant = reaction.reactant.copy()
    f_product = reaction.product.copy()

    # Set the truncated reactant and product for this reaction
    reaction.reactant = get_truncated_complex(f_reactant, bond_rearr)
    reaction.product = get_truncated_complex(f_product, bond_rearr)

    # Re-find the bond rearrangements, which should exist
    reaction.name += '_truncated'
    bond_rearrangs = get_bond_rearrangs(reaction.reactant,
                                        reaction.product,
                                        name=reaction.name)

    if bond_rearrangs is None:
        logger.error('Truncation generated a complex with 0 rearrangements')
        return None

    # Find all the possible TSs
    for bond_rearr in bond_rearrangs:
        get_ts(reaction, reaction.reactant, bond_rearr, is_truncated=True)

    # Reset the reactant, product and name of the full reaction
    reaction.reactant = f_reactant
    reaction.product = f_product
    reaction.name = reaction.name.rstrip('_truncated')

    logger.info('Done with truncation')
    return None
Example #3
0
    def get_normal_mode_displacements(self, calc, mode_number):

        # mode numbers start at 1, not 6
        mode_number -= 5
        normal_mode_section, displacements = False, []

        for j, line in enumerate(calc.output.file_lines):
            if 'Projected Frequencies' in line:
                normal_mode_section = True
                displacements = []

            if '------------------------------' in line:
                normal_mode_section = False

            if normal_mode_section:
                if len(line.split()) == 6:
                    mode_numbers = [int(val) for val in line.split()]
                    if mode_number in mode_numbers:
                        col = [i for i in range(
                            len(mode_numbers)) if mode_number == mode_numbers[i]][0] + 1
                        displacements = [float(disp_line.split()[
                            col]) for disp_line in calc.output.file_lines[j + 4:j + 3 * calc.molecule.n_atoms + 4]]

        displacements_xyz = [displacements[i:i + 3]
                             for i in range(0, len(displacements), 3)]
        if len(displacements_xyz) != calc.molecule.n_atoms:
            logger.error(
                'Something went wrong getting the displacements n != n_atoms')
            return None

        return np.array(displacements_xyz)
Example #4
0
    def _get_energy(self, e=False, h=False, g=False, force=False):
        """
        Get the energy from a completed calculation

        Keyword Arguments:
            e (bool): Return the potential energy (E)
            h (bool): Return the enthalpy (H) at 298 K
            g (bool): Return the Gibbs free energy (G) at 298 K
            force (bool): Return the energy even if the calculation errored

        Returns:
            (float): Energy in Hartrees, or None
        """
        logger.info(f'Getting energy from {self.output.filename}')

        if self.terminated_normally() or force:

            if h:
                return self.method.get_enthalpy(self)

            if g:
                return self.method.get_free_energy(self)

            if e:
                return self.method.get_energy(self)

        logger.error('Calculation did not terminate normally. Energy = None')
        return None
Example #5
0
    def optimise(self, method, reset_graph=False, calc=None):
        """
        Optimise the geometry of this conformer

        Arguments:
            method (autode.wrappers.base.ElectronicStructureMethod):

        Keyword Arguments:
            reset_graph (bool):
            calc (autode.calculation.Calculation):
        """
        logger.info(f'Running optimisation of {self.name}')

        if calc is not None or reset_graph:
            raise NotImplementedError

        opt = Calculation(name=f'{self.name}_opt',
                          molecule=self,
                          method=method,
                          keywords=method.keywords.low_opt,
                          n_cores=Config.n_cores,
                          distance_constraints=self.dist_consts)
        opt.run()
        self.energy = opt.get_energy()

        try:
            self.set_atoms(atoms=opt.get_final_atoms())

        except AtomsNotFound:
            logger.error(f'Atoms not found for {self.name} but not critical')
            self.set_atoms(atoms=None)

        return None
Example #6
0
def get_ts_templates(folder_path=Config.ts_template_folder_path):
    """Get all the transition state templates from a folder, or the default if
    folder path is None

    Keyword Arguments:
        folder_path (str): /path/to/the/ts/template/library

    Returns:
        (list(autode.transition_states.templates.TStemplate))

    """

    if folder_path is None:
        logger.info(
            'Folder path is not set – getting TS templates from the default path'
        )
        folder_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                   'lib')

    logger.info(f'Getting TS templates from {folder_path}')

    if not os.path.exists(folder_path):
        logger.error('Folder does not exist')
        return []

    obj_filenames = [
        fn for fn in os.listdir(folder_path) if fn.endswith('.obj')
    ]
    objects = [
        pickle.load(open(os.path.join(folder_path, filename), 'rb'))
        for filename in obj_filenames
    ]

    logger.info(f'Have {len(objects)} TS templates')
    return objects
Example #7
0
    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
Example #8
0
    def populate_conformers(self):
        """
        Generate and optimise with a low level method a set of conformers, the
        number of which is
        Config.num_complex_sphere_points ×  Config.num_complex_random_rotations
         ^ (n molecules in complex - 1)
        """
        n_confs = (Config.num_complex_sphere_points *
                   Config.num_complex_random_rotations *
                   (len(self.molecules) - 1))
        logger.info(f'Generating and optimising {n_confs} conformers of '
                    f'{self.name} with a low-level method')

        self._generate_conformers()

        try:
            lmethod = get_lmethod()
            for conformer in self.conformers:
                conformer.optimise(method=lmethod)
                conformer.print_xyz_file()

        except MethodUnavailable:
            logger.error('Could not optimise complex conformers')

        return None
Example #9
0
def get_bond_rearrangs_from_file(filename='bond_rearrangs.txt'):
    logger.info('Getting bond rearrangements from file')

    if not os.path.exists(filename):
        logger.error('No bond rearrangments file')
        return None

    bond_rearrangs = []

    with open(filename, 'r') as br_file:
        fbonds_block = False
        bbonds_block = True
        fbonds = []
        bbonds = []
        for line in br_file:
            if 'fbonds' in line:
                fbonds_block = True
                bbonds_block = False
            if 'bbonds' in line:
                fbonds_block = False
                bbonds_block = True
            if fbonds_block and len(line.split()) == 2:
                atom_id_string = line.split()
                fbonds.append((int(atom_id_string[0]), int(atom_id_string[1])))
            if bbonds_block and len(line.split()) == 2:
                atom_id_string = line.split()
                bbonds.append((int(atom_id_string[0]), int(atom_id_string[1])))
            if 'end' in line:
                bond_rearrangs.append(
                    BondRearrangement(forming_bonds=fbonds,
                                      breaking_bonds=bbonds))
                fbonds = []
                bbonds = []

    return bond_rearrangs
Example #10
0
    def find_lowest_energy_ts_conformer(self):
        """Find the lowest energy conformer of the transition state"""
        if self.ts is None:
            logger.error('No transition state to evaluate the conformer of')
            return None

        else:
            return self.ts.find_lowest_energy_ts_conformer()
Example #11
0
def get_point_species(point, species, distance_constraints, name, method,
                      keywords, n_cores, energy_threshold=1):
    """
    On a 2d PES calculate the energy and the structure using a constrained
    optimisation

    Arguments:
        point (tuple(int)): Index of this point e.g. (0, 0) for the first point
                            on a 2D surface

        species (autode.species.Species):

        distance_constraints (dict): Keyed with atom indexes and the constraint
                             value as the value

        name (str):

        method (autode.wrappers.base.ElectronicStructureMethod):

        keywords (autode.wrappers.keywords.Keywords):

        n_cores (int): Number of cores to used for this calculation

    Keyword Arguments:
        energy_threshold (float): Above this energy (Ha) the calculation
                                  will be disregarded

    Returns:
        (autode.species.Species): Species
    """
    logger.info(f'Calculating point {point} on PES surface')

    species.name = f'{name}_scan_{"-".join([str(p) for p in point])}'
    original_species = deepcopy(species)

    # Set up and run the calculation
    const_opt = Calculation(name=species.name, molecule=species, method=method,
                            n_cores=n_cores,
                            keywords=keywords,
                            distance_constraints=distance_constraints)
    try:
        species.optimise(method=method, calc=const_opt)

    except AtomsNotFound:
        logger.error(f'Optimisation failed for {point}')
        return original_species

    # If the energy difference is > 1 Hartree then likely something has gone
    # wrong with the EST method we need to be not on the first point to compute
    # an energy difference..
    if not all(p == 0 for p in point):
        if species.energy is None or np.abs(original_species.energy - species.energy) > energy_threshold:
            logger.error(f'PES point had a relative energy '
                         f'> {energy_threshold} Ha. Using the closest')
            return original_species

    return species
Example #12
0
    def get_free_energy(self, calc):
        """Get the Gibbs free energy (G) from an g09 calculation output"""

        for line in reversed(calc.output.file_lines):
            if 'Sum of electronic and thermal Free Energies' in line:
                return float(line.split()[-1])

        logger.error('Could not get the enthalpy from the calculation. '
                     'A frequency must be requested')
        return None
Example #13
0
def get_ts_guess_constrained_opt(reactant, method, keywords, name,
                                 distance_consts,
                                 product):
    """Get a TS guess from a constrained optimisation with the active atoms
    fixed at values defined in distance_consts

    Arguments:
        reactant (autode.complex.ReactantComplex):
        method (autode.wrappers.base.ElectronicStructureMethod):
        keywords (autode.wrappers.keywords.Keywords):
        name (str):
        distance_consts (dict): Distance constraints keyed with a tuple of atom
                                indexes and value of the distance
        product (autode.complex.ProductComplex):

    Returns:
       (autode.ts_guess.TSguess):
    """
    logger.info('Getting TS guess from constrained optimisation')

    mol_with_constraints = reactant.copy()

    # Run a low level constrained optimisation first to prevent the DFT being
    # problematic if there are >1 constraint
    l_method = get_lmethod()
    ll_const_opt = Calculation(name=f'{name}_constrained_opt_ll',
                               molecule=mol_with_constraints, method=l_method,
                               keywords=l_method.keywords.low_opt,
                               n_cores=Config.n_cores,
                               distance_constraints=distance_consts)

    # Try and set the atoms, but continue if they're not found as hopefully the
    # other method will be fine(?)
    try:
        mol_with_constraints.optimise(method=l_method, calc=ll_const_opt)

    except AtomsNotFound:
        logger.error('Failed to optimise with the low level method')

    hl_const_opt = Calculation(name=f'{name}_constrained_opt',
                               molecule=mol_with_constraints, method=method,
                               keywords=keywords, n_cores=Config.n_cores,
                               distance_constraints=distance_consts)

    # Form a transition state guess from the optimised atoms and set the
    # corresponding energy
    try:
        mol_with_constraints.optimise(method=method, calc=hl_const_opt)

    except AtomsNotFound:
        logger.error('Failed to optimise with the high level method')

    return get_ts_guess(species=mol_with_constraints, reactant=reactant,
                        product=product, name=f'ts_guess_{name}')
Example #14
0
    def _check_molecule(self):
        """Ensure the molecule has the required attributes"""
        assert hasattr(self.molecule, 'n_atoms')
        assert hasattr(self.molecule, 'atoms')
        assert hasattr(self.molecule, 'mult')
        assert hasattr(self.molecule, 'charge')
        assert hasattr(self.molecule, 'solvent')

        # The molecule must have > 0 atoms
        if self.molecule.atoms is None or self.molecule.n_atoms == 0:
            logger.error('Have no atoms. Can\'t form a calculation')
            raise ex.NoInputError
Example #15
0
    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
Example #16
0
def is_isomorphic(graph1, graph2, ignore_active_bonds=False, timeout=5):
    """Check whether two NX graphs are isomorphic. Contains a timeout because
    the gm.is_isomorphic() method occasionally gets stuck

    Arguments:
        graph1 (nx.Graph): graph 1
        graph2 (nx.Graph): graph 2

    Keyword Arguments:
        ignore_active_bonds (bool):
        timeout (float): Timeout in seconds

    Returns:
        (bool): if the graphs are isomorphic
    """

    if ignore_active_bonds:
        graph1, graph2 = get_graphs_ignoring_active_edges(graph1, graph2)

    if not isomorphism.faster_could_be_isomorphic(graph1, graph2):
        return False

    # Always match on atom types
    node_match = isomorphism.categorical_node_match('atom_label', 'C')

    if ignore_active_bonds:
        gm = isomorphism.GraphMatcher(graph1, graph2, node_match=node_match)

    else:
        # Also match on edges
        edge_match = isomorphism.categorical_edge_match('active', False)
        gm = isomorphism.GraphMatcher(graph1,
                                      graph2,
                                      node_match=node_match,
                                      edge_match=edge_match)

    # NX can hang here for not very large graphs, so kill after a timeout

    def handler(signum, frame):
        raise TimeoutError

    signal.signal(signal.SIGALRM, handler)
    signal.alarm(int(timeout))
    try:
        result = gm.is_isomorphic()
        # Cancel the timer
        signal.alarm(0)
        return result

    except TimeoutError:
        logger.error('NX graph matching hanging')
        return False
Example #17
0
    def is_saddle(self, idx):
        """Is an index a saddle point"""
        if idx == 0 or idx == len(self) -1:
            logger.warning('Cannot be saddle point, index was at the end')
            return False

        if any(self[i].energy is None for i in (idx-1, idx, idx+1)):
            logger.error(f'Could not determine if point {idx} was a saddle '
                         f'point, an energy close by was None')
            return False

        energy = self[idx].energy
        return self[idx-1].energy < energy and self[idx+1].energy < energy
Example #18
0
def calc_delta(attr, left, right):
    """Calculate the difference (∆) for a molecular attribute for some L → R"""
    if any(mol is None for mol in left + right):
        logger.error('Could not calculate ∆, a molecule was None')
        return None

    if any(getattr(mol, attr) is None for mol in left + right):
        logger.error('Cannot calculate ∆. At least one required attribute'
                     ' was None')
        return None

    return (sum([getattr(mol, attr)
                 for mol in right]) - sum([getattr(mol, attr)
                                           for mol in left]))
Example #19
0
    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
Example #20
0
    def get_free_energy(self, calc):
        """Get the Gibbs free energy (G) from an ORCA calculation output"""

        for line in reversed(calc.output.file_lines):
            if '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
Example #21
0
def get_vdw_radius(atom_label):
    """Get the van der waal's radius of an atom

    Arguments:
        atom_label (str): atom label e.g. C or Pd

    Returns:
        (float): van der waal's radius of the atom
    """
    if atom_label in vdw_radii.keys():
        return vdw_radii[atom_label]
    else:
        logger.error(f'Couldn\'t find the VdV radii for {atom_label}. '
                     f'Guessing at 2.3')
        return 2.3
Example #22
0
    def calc_h_cont(self, method=None, calc=None, temp=298.15):
        """Calculate the free energy contribution for a species"""
        assert self.energy is not None

        if calc is None:
            calc = self._run_hess_calculation(method=method, temp=temp)

        enthalpy = calc.get_enthalpy()

        if enthalpy is None:
            logger.error(f'Could not calculate H for {self.name}, not h_cont')
            return

        self.h_cont = enthalpy - self.energy
        return None
Example #23
0
    def calc_g_cont(self, method=None, calc=None, temp=298.15):
        """Calculate the free energy contribution for a species"""
        assert self.energy is not None

        if calc is None:
            calc = self._run_hess_calculation(method=method, temp=temp)

        free_energy = calc.get_free_energy()

        if free_energy is None:
            logger.error('Could not calculate g_cont, free energy not found')
            return

        self.g_cont = free_energy - self.energy
        return None
Example #24
0
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
Example #25
0
    def get_atomic_charges(self, calc):

        charges_section = False
        charges = []
        for line in reversed(calc.output.file_lines):
            if 'sum of mulliken charges' in line.lower():
                charges_section = True

            if len(charges) == calc.molecule.n_atoms:
                return list(reversed(charges))

            if charges_section and len(line.split()) == 3:
                charges.append(float(line.split()[2]))

        logger.error('Something went wrong finding the atomic charges')
        return None
Example #26
0
def check_bonds(molecule, bonds):
    """
    Ensure the SMILES string and the 3D structure have the same bonds,
    but don't override

    Arguments:
        molecule (autode.molecule.Molecule):
        bonds (list):
    """
    check_molecule = deepcopy(molecule)
    make_graph(check_molecule)

    if len(bonds) != check_molecule.graph.number_of_edges():
        logger.error('Bonds and graph do no match')

    return None
Example #27
0
    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]))
Example #28
0
    def calc_delta_e_ddagger(self):
        """Calculate the ∆E‡ of a reaction defined as
         ∆E = E(ts) - E(reactants)

        Returns:
            float: energy difference in Hartrees
        """
        logger.info('Calculating ∆E‡')
        if self.ts is None:
            logger.error('No TS, cannot calculate ∆E‡')
            return None

        if self.ts.energy is None or any(r.energy is None for r in self.reacs):
            logger.error('TS or reactants had no energy, cannot calculate ∆E‡')
            return None

        return self.ts.energy - sum([r.energy for r in self.reacs])
Example #29
0
    def optimise(self, name_ext='optts'):
        """Optimise this TS to a true TS """
        logger.info(f'Optimising {self.name} to a transition state')

        self._run_opt_ts_calc(method=get_hmethod(), name_ext=name_ext)

        # A transition state is a first order saddle point i.e. has a single
        # imaginary frequency
        if len(self.imaginary_frequencies) == 1:
            logger.info('Found a TS with a single imaginary frequency')
            return

        if len(self.imaginary_frequencies) == 0:
            logger.error('Transition state optimisation did not return any '
                         'imaginary frequencies')
            return

        if all([freq > -50 for freq in self.imaginary_frequencies[1:]]):
            logger.warning('Had small imaginary modes - not displacing along')
            return

        # There is more than one imaginary frequency. Will assume that the most
        # negative is the correct mode..
        for disp_magnitude in [1, -1]:
            logger.info('Displacing along second imaginary mode to try and '
                        'remove')
            dis_name_ext = name_ext + '_dis' if disp_magnitude == 1 else name_ext + '_dis2'
            atoms, energy, calc = deepcopy(self.atoms), deepcopy(
                self.energy), deepcopy(self.optts_calc)

            self.atoms = get_displaced_atoms_along_mode(
                self.optts_calc, mode_number=7, disp_magnitude=disp_magnitude)

            self._run_opt_ts_calc(method=get_hmethod(), name_ext=dis_name_ext)

            if len(self.imaginary_frequencies) == 1:
                logger.info('Displacement along second imaginary mode '
                            'successful. Now have 1 imaginary mode')
                break

            self.optts_calc = calc
            self.atoms = atoms
            self.energy = energy
            self.imaginary_frequencies = self.optts_calc.get_imaginary_freqs()

        return None
Example #30
0
    def find_lowest_energy_ts(self):
        """From all the transition state objects in Reaction.pes1d choose the
        lowest energy if there is more than one otherwise return the single
        transtion state or None if there no TS objects.
        """

        if self.tss is None:
            logger.error('Could not find a transition state')
            return None

        elif len(self.tss) > 1:
            logger.info('Found more than 1 TS. Choosing the lowest energy')
            min_ts_energy = min([ts.energy for ts in self.tss])
            return [ts for ts in self.tss if ts.energy == min_ts_energy][0]

        else:
            return self.tss[0]