Exemplo n.º 1
0
    def test_find_torsions(self):

        terminal_torsions, non_terminal_torsions = find_terminal_torsions(
            self.conformer)
        self.assertEqual(len(self.conformer.torsions),
                         len(terminal_torsions + non_terminal_torsions))
        self.assertEqual(len(terminal_torsions),
                         1)  # only one terminal methyl group
        self.assertEqual(len(non_terminal_torsions), 3)  # N-C, O-C, C-C
Exemplo n.º 2
0
def find_all_combos(conformer,
                    delta=float(30),
                    cistrans=True,
                    chiral_centers=True):
    """
    A function to find all possible conformer combinations for a given conformer
    """

    terminal_torsions, torsions = find_terminal_torsions(conformer)
    cistranss = conformer.cistrans
    chiral_centers = conformer.chiral_centers

    torsion_angles = np.arange(0, 360, delta)
    torsion_combos = list(
        itertools.combinations_with_replacement(torsion_angles, len(torsions)))
    if len(torsions) != 1:
        torsion_combos = list(
            set(torsion_combos + list(
                itertools.combinations_with_replacement(
                    torsion_angles[::-1], len(torsions)))))

    if cistrans:
        cistrans_options = ["E", "Z"]
        cistrans_combos = list(
            itertools.combinations_with_replacement(cistrans_options,
                                                    len(cistranss)))
        if len(cistranss) != 1:
            cistrans_combos = list(
                set(cistrans_combos + list(
                    itertools.combinations_with_replacement(
                        cistrans_options[::-1], len(cistranss)))))

    else:
        cistrans_combos = [()]

    if chiral_centers:
        chiral_options = ["R", "S"]
        chiral_combos = list(
            itertools.combinations_with_replacement(chiral_options,
                                                    len(chiral_centers)))
        if len(chiral_centers) != 1:
            chiral_combos = list(
                set(chiral_combos + list(
                    itertools.combinations_with_replacement(
                        chiral_options[::-1], len(chiral_centers)))))
    else:
        chiral_combos = [()]

    all_combos = list(
        itertools.product(torsion_combos, cistrans_combos, chiral_combos))
    return all_combos
Exemplo n.º 3
0
def find_all_combos(
        conformer,
        delta=float(120),
        cistrans=True,
        chiral_centers=True):
    """
    A function to find all possible conformer combinations for a given conformer

    Params:
    - conformer (`Conformer`) an AutoTST `Conformer` object of interest
    - delta (int or float): a number between 0 and 180 or how many conformers to generate per dihedral
    - cistrans (bool): indication of if one wants to consider cistrans bonds
    - chiral_centers (bool): indication of if one wants to consider chiral centers bonds

    Returns:
    - all_combos (list): a list corresponding to the number of unique conformers created.
    """

    conformer.get_geometries()

    _, torsions = find_terminal_torsions(conformer)
    cistranss = conformer.cistrans
    chiral_centers = conformer.chiral_centers

    torsion_angles = np.arange(0, 360, delta)
    torsion_combos = list(itertools.product(
        torsion_angles, repeat=len(torsions)))

    if cistrans:
        cistrans_options = ["E", "Z"]
        cistrans_combos = list(itertools.product(
            cistrans_options, repeat=len(cistranss)))

    else:
        cistrans_combos = [()]

    if chiral_centers:
        chiral_options = ["R", "S"]
        chiral_combos = list(itertools.product(
            chiral_options, repeat=len(chiral_centers)))

    else:
        chiral_combos = [()]

    all_combos = list(
        itertools.product(
            torsion_combos,
            cistrans_combos,
            chiral_combos))
    return all_combos
Exemplo n.º 4
0
def systematic_search(
        conformer,
        delta=float(60),
        cistrans=True,
        chiral_centers=True,
):
    """
    Perfoms a systematic conformer analysis of a `Conformer` or a `TS` object

    Variables:
    - conformer (`Conformer` or `TS`): a `Conformer` or `TS` object of interest
    - delta (int or float): a number between 0 and 180 or how many conformers to generate per dihedral
    - cistrans (bool): indication of if one wants to consider cistrans bonds
    - chiral_centers (bool): indication of if one wants to consider chiral centers bonds

    Returns:
    - confs (list): a list of unique `Conformer` objects within 1 kcal/mol of the lowest energy conformer determined
    """
    # Takes each of the molecule objects

    combos = find_all_combos(conformer,
                             delta=delta,
                             cistrans=cistrans,
                             chiral_centers=chiral_centers)

    if len(combos) == 0:
        logging.info(
            "This species has no torsions, cistrans bonds, or chiral centers")
        logging.info("Returning origional conformer")
        return [conformer]

    _, torsions = find_terminal_torsions(conformer)

    calc = conformer.ase_molecule.get_calculator()
    if isinstance(calc, FileIOCalculator):
        logging.info("The calculator generates input and output files.")

    results = []
    conformers = {}
    combinations = {}
    for index, combo in enumerate(combos):

        combinations[index] = combo

        torsions, cistrans, chiral_centers = combo

        for i, torsion in enumerate(torsions):

            tor = conformer.torsions[i]
            i, j, k, l = tor.atom_indices
            mask = tor.mask

            conformer.ase_molecule.set_dihedral(a1=i,
                                                a2=j,
                                                a3=k,
                                                a4=l,
                                                angle=torsion,
                                                mask=mask)
            conformer.update_coords()

        for i, e_z in enumerate(cistrans):
            ct = conformer.cistrans[i]
            conformer.set_cistrans(ct.index, e_z)

        for i, s_r in enumerate(chiral_centers):
            center = conformer.chiral_centers[i]
            conformer.set_chirality(center.index, s_r)

        conformer.update_coords_from("ase")

        conformers[index] = conformer.copy()

    logging.info("There are {} unique conformers generated".format(
        len(conformers)))

    def opt_conf(conformer, calculator, i):
        """
        A helper function to optimize the geometry of a conformer.
        Only for use within this parent function
        """

        labels = []
        for bond in conformer.bonds:
            labels.append(bond.atom_indices)

        if isinstance(conformer, TS):
            label = conformer.reaction_label
            ind1 = conformer.rmg_molecule.getLabeledAtom("*1").sortingLabel
            ind2 = conformer.rmg_molecule.getLabeledAtom("*3").sortingLabel
            labels.append([ind1, ind2])
            type = 'ts'
        else:
            label = conformer.smiles
            type = 'species'

        if isinstance(calc, FileIOCalculator):
            if calculator.directory:
                directory = calculator.directory
            else:
                directory = 'conformer_logs'
            calculator.label = "{}_{}".format(conformer.smiles, i)
            calculator.directory = os.path.join(
                directory, label, '{}_{}'.format(conformer.smiles, i))
            if not os.path.exists(calculator.directory):
                try:
                    os.makedirs(calculator.directory)
                except OSError:
                    logging.info("An error occured when creating {}".format(
                        calculator.directory))

            calculator.atoms = conformer.ase_molecule

        from ase.constraints import FixBondLengths
        c = FixBondLengths(labels)
        conformer.ase_molecule.set_constraint(c)

        conformer.ase_molecule.set_calculator(calculator)

        opt = BFGS(conformer.ase_molecule, logfile=None)
        opt.run()

        conformer.update_coords_from("ase")
        energy = get_energy(conformer)
        return_dict[i] = (energy, conformer.ase_molecule.arrays,
                          conformer.ase_molecule.get_all_distances())

    manager = Manager()
    return_dict = manager.dict()

    processes = []
    for i, conf in list(conformers.items()):
        p = Process(target=opt_conf, args=(conf, calc, i))
        processes.append(p)

    active_processes = []
    for process in processes:
        if len(active_processes) < multiprocessing.cpu_count():
            process.start()
            active_processes.append(process)
            continue

        else:
            one_done = False
            while not one_done:
                for i, p in enumerate(active_processes):
                    if not p.is_alive():
                        one_done = True
                        break

            process.start()
            active_processes[i] = process
    complete = np.zeros_like(active_processes, dtype=bool)
    while not np.all(complete):
        for i, p in enumerate(active_processes):
            if not p.is_alive():
                complete[i] = True

    from ase import units
    results = []
    for _, values in list(return_dict.items()):
        results.append(values)

    df = pd.DataFrame(results, columns=["energy", "arrays", 'distances'])
    df = df[df.energy < df.energy.min() +
            units.kcal / units.mol / units.eV].sort_values("energy")

    tolerance = 0.1
    scratch_index = []
    unique_index = []
    for index in df.index:
        if index in scratch_index:
            continue
        unique_index.append(index)
        scratch_index.append(index)
        distances = df.distances[index]
        for other_index in df.index:
            if other_index in scratch_index:
                continue

            other_distances = df.distances[other_index]

            if tolerance > np.sqrt((distances - other_distances)**2).mean():
                scratch_index.append(other_index)

    logging.info("We have identified {} unique conformers for {}".format(
        len(unique_index), conformer))
    confs = []
    i = 0
    for info in df[["energy", "arrays"]].loc[unique_index].values:
        copy_conf = conformer.copy()

        energy, array = info
        copy_conf.energy = energy
        copy_conf.ase_molecule.set_positions(array["positions"])
        copy_conf.update_coords_from("ase")
        c = copy_conf.copy()
        c.index = i
        confs.append(c)
        i += 1

    return confs
Exemplo n.º 5
0
def perform_simple_es(autotst_object,
                      initial_pop=None,
                      top_percent=0.3,
                      min_rms=60,
                      max_generations=500,
                      store_generations=False,
                      store_directory=".",
                      delta=30):
    """
    Performs evolutionary strategy to determine the lowest energy conformer of a TS or molecule.

    :param autotst_object: an autotst_ts, autotst_rxn, or autotst_molecule that you want to perform conformer analysis on
       * the ase_object of the autotst_object must have a calculator attached to it.
    :param initial_pop: a DataFrame containing the initial population
    :param top_percent: float of the top percentage of conformers you want to select
    :param min_rms: float of one of the possible cut off points for the analysis
    :param max_generations: int of one of the possible cut off points for the analysis
    :param store_generations: do you want to store pickle files of each generation
    :param store_directory: the director where you want the pickle files stored
    :param delta: the degree change in dihedral angle between each possible dihedral angle

    :return results: a DataFrame containing the final generation
    :return unique_conformers: a dictionary with indicies of unique torsion combinations and entries of energy of those torsions
    """
    assert autotst_object, "No AutoTST object provided..."
    if initial_pop is None:
        logging.info(
            "No initial population provided, creating one using base parameters..."
        )
        initial_pop = create_initial_population(autotst_object, delta=delta)

    possible_dihedrals = np.arange(0, 360, delta)
    top = select_top_population(initial_pop, top_percent=top_percent)

    population_size = initial_pop.shape[0]

    results = initial_pop

    if isinstance(autotst_object, autotst.species.Species):
        logging.info("The object given is a `Molecule` object")
        torsions = autotst_object.torsions
        ase_object = autotst_object.ase_molecule
        label = autotst_object.smiles

    if isinstance(autotst_object, autotst.reaction.Reaction):
        logging.info("The object given is a `Reaction` object")
        torsions = autotst_object.ts.torsions
        ase_object = autotst_object.ts.ase_ts
        label = autotst_object.label

    if isinstance(autotst_object, autotst.reaction.TS):
        logging.info("The object given is a `TS` object")
        torsions = autotst_object.torsions
        ase_object = autotst_object.ase_ts
        label = autotst_object.label

    assert ase_object.get_calculator(
    ), "To use ES, you must attach an ASE calculator to the `ase_molecule`."
    gen_number = 0
    complete = False
    unique_conformers = {}

    terminal_torsions, non_terminal_torsions = find_terminal_torsions(
        autotst_object)

    while complete == False:

        relaxed_top = []
        for combo in top.iloc[:, 1:].values:
            for index, torsion in enumerate(non_terminal_torsions):
                i, j, k, l = torsion.indices
                right_mask = torsion.right_mask

                dihedral = combo[index]

                ase_object.set_dihedral(a1=i,
                                        a2=j,
                                        a3=k,
                                        a4=l,
                                        angle=float(dihedral),
                                        mask=right_mask)
            update_from_ase(autotst_object)

            relaxed_e, relaxed_object = partial_optimize_mol(autotst_object)

            new_dihedrals = []

            for torsion in non_terminal_torsions:
                i, j, k, l = torsion.indices
                right_mask = torsion.right_mask

                d = relaxed_object.get_dihedral(a1=i, a2=j, a3=k, a4=l)

                new_dihedrals.append(d)

            relaxed_top.append([relaxed_e] + new_dihedrals)

        columns = top.columns
        top = pd.DataFrame(relaxed_top, columns=columns)

        if store_generations:
            save_name = "{}_relaxed_top_es_generation_{}.csv".format(
                label, gen_number)
            f = os.path.join(store_directory, save_name)
            top.to_csv(f)

        gen_number += 1
        logging.info("Performing ES on generation {}".format(gen_number))

        r = []

        for individual in range(population_size):
            dihedrals = []
            for index, torsion in enumerate(non_terminal_torsions):
                i, j, k, l = torsion.indices
                right_mask = torsion.right_mask

                dihedral = random.gauss(top.mean()["torsion_" + str(index)],
                                        top.std()["torsion_" + str(index)])
                dihedrals.append(dihedral)
                ase_object.set_dihedral(a1=i,
                                        a2=j,
                                        a3=k,
                                        a4=l,
                                        angle=float(dihedral),
                                        mask=right_mask)

            # Updating the molecule
            update_from_ase(autotst_object)

            energy = get_energy(autotst_object)

            r.append([energy] + dihedrals)

        results = pd.DataFrame(r)
        logging.info(
            "Creating the DataFrame of results for the {}th generation".format(
                gen_number))

        results.columns = top.columns
        results = results.sort_values("energy")

        unique_conformers = get_unique_conformers(results, unique_conformers,
                                                  min_rms)

        if store_generations:
            # This portion stores each generation if desired
            logging.info("Saving the results DataFrame")

            generation_name = "{0}_es_generation_{1}.csv".format(
                label, gen_number)
            f = os.path.join(store_directory, generation_name)
            results.to_csv(f)

        top = select_top_population(results, top_percent)

        best = top.iloc[0, 1:]
        worst = top.iloc[-1, 1:]

        rms = ((best - worst)**2).mean()

        if gen_number >= max_generations:
            complete = True
            logging.info("Max generations reached. ES complete.")
        if rms < min_rms:
            complete = True
            logging.info("Cutoff criteria reached. ES complete.")

    return results, unique_conformers
Exemplo n.º 6
0
def systematic_search(conformer,
                      delta=float(30),
                      cistrans=True,
                      chiral_centers=True,
                      store_results=False,
                      store_directory="."):
    """
    Perfoms a brute force conformer analysis of a molecule or a transition state

    :param autotst_object: am autotst_ts, autotst_rxn, or autotst_molecule that you want to perform conformer analysis on
       * the ase_object of the autotst_object must have a calculator attached to it.
    :param store_generations: do you want to store pickle files of each generation
    :param store_directory: the director where you want the pickle files stored
    :param delta: the degree change in dihedral angle between each possible dihedral angle

    :return results: a DataFrame containing the final generation
    :return unique_conformers: a dictionary with indicies of unique torsion combinations and entries of energy of those torsions
    """
    # Takes each of the molecule objects

    combos = find_all_combos(conformer,
                             delta=delta,
                             cistrans=cistrans,
                             chiral_centers=chiral_centers)

    terminal_torsions, torsions = find_terminal_torsions(conformer)
    file_name = conformer.smiles + "_brute_force.csv"

    calc = conformer.ase_molecule.get_calculator()

    results = []
    for combo in combos:

        torsions, cistrans, chiral_centers = combo

        for i, torsion in enumerate(torsions):

            tor = conformer.torsions[i]
            i, j, k, l = tor.atom_indices
            mask = tor.mask

            conformer.ase_molecule.set_dihedral(a1=i,
                                                a2=j,
                                                a3=k,
                                                a4=l,
                                                angle=torsion,
                                                mask=mask)
            conformer.update_coords()

        for i, e_z in enumerate(cistrans):
            ct = conformer.cistrans[i]
            conformer.set_cistrans(ct.index, e_z)

        for i, s_r in enumerate(chiral_centers):
            center = conformer.chiral_centers[i]
            conformer.set_chiral_center(center.index, s_r)

        conformer.ase_molecule.set_calculator(calc)
        ##### Something funky happening with this optimization
        #conformer.update_coords()
        #opt = BFGS(conformer.ase_molecule)
        #opt.run()
        conformer.update_coords()
        energy = get_energy(conformer)

        sample = ["torsion_{}", "cistrans_{}", "chiral_center_{}"]
        columns = ["energy"]
        long_combo = [energy]

        for c, name in zip(combo, sample):
            for i, info in enumerate(c):
                columns.append(name.format(i))
                long_combo.append(info)

        results.append(long_combo)

    brute_force = pd.DataFrame(results, columns=columns)

    if store_results:
        f = os.path.join(store_directory, file_name)
        brute_force.to_csv(f)

    from ase import units

    unique_conformers = []
    for i, ind in enumerate(brute_force[brute_force.energy < (
            brute_force.energy.min() +
            units.kcal / units.mol / units.eV)].index):
        copy_conf = conformer.copy()
        copy_conf.index = i
        for col in brute_force.columns[1:]:
            geometry = col.split("_")[0]
            index = int(col.split("_")[-1])
            value = brute_force.loc[ind][col]

            if "torsion" in geometry:
                copy_conf.set_torsion(index, float(value))
            elif "cistrans" in geometry.lower():
                copy_conf.set_cistrans(index, value)
            elif "chiral" in geometry.lower():
                copy_conf.set_chirality(index, value)

        unique_conformers.append(copy_conf)

    return brute_force, unique_conformers
Exemplo n.º 7
0
def systematic_search(conformer,
                      delta=float(120),
                      energy_cutoff = 10.0, #kcal/mol
                      rmsd_cutoff = 0.5, #angstroms
                      cistrans = True,
                      chiral_centers = True,
                      multiplicity = False,
                      ):
    """
    Perfoms a systematic conformer analysis of a `Conformer` or a `TS` object

    Variables:
    - conformer (`Conformer` or `TS`): a `Conformer` or `TS` object of interest
    - delta (int or float): a number between 0 and 180 or how many conformers to generate per dihedral
    - cistrans (bool): indication of if one wants to consider cistrans bonds
    - chiral_centers (bool): indication of if one wants to consider chiral centers bonds

    Returns:
    - confs (list): a list of unique `Conformer` objects within 1 kcal/mol of the lowest energy conformer determined
    """
    
    rmsd_cutoff_options = {
        'loose' : 1.0,
        'default': 0.5,
        'tight': 0.1
    }

    energy_cutoff_options = {
        'high' : 50.0,
        'default' : 10.0,
        'low' : 5.0
    }

    if isinstance(rmsd_cutoff,str):
        rmsd_cutoff = rmsd_cutoff.lower()
        assert rmsd_cutoff in rmsd_cutoff_options.keys(), 'rmsd_cutoff options are loose, default, and tight'
        rmsd_cutoff = rmsd_cutoff_options[rmsd_cutoff]

    if isinstance(energy_cutoff,str):
        energy_cutoff = energy_cutoff.lower()
        assert energy_cutoff in energy_cutoff_options.keys(), 'energy_cutoff options are low, default, and high'
        energy_cutoff = energy_cutoff_options[energy_cutoff]
    
    if not isinstance(conformer, TS):
        reference_mol = conformer.rmg_molecule.copy(deep=True)
        reference_mol = reference_mol.to_single_bonds()
    manager = Manager()
    return_dict = manager.dict()
    pool = multiprocessing.Pool()

    def opt_conf(i, rmsd_cutoff):
        """
        A helper function to optimize the geometry of a conformer.
        Only for use within this parent function
        """
        conformer = conformers[i]

        calculator = conformer.ase_molecule.get_calculator()

        labels = []
        for bond in conformer.get_bonds():
            labels.append(bond.atom_indices)
    
        if isinstance(conformer, TS):
            label = conformer.reaction_label
            ind1 = conformer.rmg_molecule.get_labeled_atoms("*1")[0].sorting_label
            ind2 = conformer.rmg_molecule.get_labeled_atoms("*3")[0].sorting_label
            labels.append([ind1, ind2])
            type = 'ts'
        else:
            label = conformer.smiles
            type = 'species'

        if isinstance(calc, FileIOCalculator):
            if calculator.directory:
                directory = calculator.directory 
            else: 
                directory = 'conformer_logs'
            calculator.label = "{}_{}".format(conformer.smiles, i)
            calculator.directory = os.path.join(directory, label,'{}_{}'.format(conformer.smiles, i))
            if not os.path.exists(calculator.directory):
                try:
                    os.makedirs(calculator.directory)
                except OSError:
                    logging.info("An error occured when creating {}".format(calculator.directory))

            calculator.atoms = conformer.ase_molecule

        conformer.ase_molecule.set_calculator(calculator)
        opt = BFGS(conformer.ase_molecule, logfile=None)

        if type == 'species':
            if isinstance(i,int):
                c = FixBondLengths(labels)
                conformer.ase_molecule.set_constraint(c)
            try:
                opt.run(steps=1e6)
            except RuntimeError:
                logging.info("Optimization failed...we will use the unconverged geometry")
                pass
            if str(i) == 'ref':
                conformer.update_coords_from("ase")
                try:
                    rmg_mol = Molecule()
                    rmg_mol.from_xyz(
                        conformer.ase_molecule.arrays["numbers"],
                        conformer.ase_molecule.arrays["positions"]
                    )
                    if not rmg_mol.is_isomorphic(reference_mol):
                        logging.info("{}_{} is not isomorphic with reference mol".format(conformer,str(i)))
                        return False
                except AtomTypeError:
                    logging.info("Could not create a RMG Molecule from optimized conformer coordinates...assuming not isomorphic")
                    return False
        
        if type == 'ts':
            c = FixBondLengths(labels)
            conformer.ase_molecule.set_constraint(c)
            try:
                opt.run(fmax=0.20, steps=1e6)
            except RuntimeError:
                logging.info("Optimization failed...we will use the unconverged geometry")
                pass

        conformer.update_coords_from("ase")  
        energy = get_energy(conformer)
        conformer.energy = energy
        if len(return_dict)>0:
            conformer_copy = conformer.copy()
            for index,post in return_dict.items():
                conf_copy = conformer.copy()
                conf_copy.ase_molecule.positions = post
                conf_copy.update_coords_from("ase")
                rmsd = rdMolAlign.GetBestRMS(conformer_copy.rdkit_molecule,conf_copy.rdkit_molecule)
                if rmsd <= rmsd_cutoff:
                    return True
        if str(i) != 'ref':
            return_dict[i] = conformer.ase_molecule.get_positions()
        return True

    #if not isinstance(conformer,TS):
    #    calc = conformer.ase_molecule.get_calculator()
    #    reference_conformer = conformer.copy()
    #    if opt_conf(reference_conformer, calc, 'ref', rmsd_cutoff):
    #        conformer = reference_conformer

    combos = find_all_combos(
        conformer,
        delta=delta,
        cistrans=cistrans,
        chiral_centers=chiral_centers)

    if len(combos) == 0:
        logging.info(
            "This species has no torsions, cistrans bonds, or chiral centers")
        logging.info("Returning origional conformer")
        return [conformer]

    _, torsions = find_terminal_torsions(conformer)

    calc = conformer.ase_molecule.get_calculator()
    if isinstance(calc, FileIOCalculator):
        logging.info("The calculator generates input and output files.")

    results = []
    global conformers
    conformers = {}
    combinations = {}
    logging.info("There are {} possible conformers to investigate...".format(len(combos)))
    for index, combo in enumerate(combos):

        combinations[index] = combo

        torsions, cistrans, chiral_centers = combo
        copy_conf = conformer.copy()

        for i, torsion in enumerate(torsions):

            tor = copy_conf.torsions[i]
            i, j, k, l = tor.atom_indices
            mask = tor.mask

            copy_conf.ase_molecule.set_dihedral(
                a1=i,
                a2=j,
                a3=k,
                a4=l,
                angle=torsion,
                mask=mask
            )
            copy_conf.update_coords()

        for i, e_z in enumerate(cistrans):
            ct = copy_conf.cistrans[i]
            copy_conf.set_cistrans(ct.index, e_z)

        for i, s_r in enumerate(chiral_centers):
            center = copy_conf.chiral_centers[i]
            copy_conf.set_chirality(center.index, s_r)

        copy_conf.update_coords_from("ase")
        copy_conf.ase_molecule.set_calculator(calc)
  
        conformers[index] = copy_conf


    processes = []
    for i, conf in list(conformers.items()):
        p = Process(target=opt_conf, args=(i, rmsd_cutoff))
        processes.append(p)

    active_processes = []
    for process in processes:
        if len(active_processes) < multiprocessing.cpu_count():
            process.start()
            active_processes.append(process)
            continue

        else:
            one_done = False
            while not one_done:
                for i, p in enumerate(active_processes):
                    if not p.is_alive():
                        one_done = True
                        break

            process.start()
            active_processes[i] = process
    complete = np.zeros_like(active_processes, dtype=bool)
    while not np.all(complete):
        for i, p in enumerate(active_processes):
            if not p.is_alive():
                complete[i] = True

    energies = []
    for positions in list(return_dict.values()):
        conf = conformer.copy()
        conf.ase_molecule.positions = positions
        conf.ase_molecule.set_calculator(calc)
        energy = conf.ase_molecule.get_potential_energy()
        conf.update_coords_from("ase")
        energies.append((conf,energy))

    df = pd.DataFrame(energies,columns=["conformer","energy"])
    df = df[df.energy < df.energy.min() + (energy_cutoff * units.kcal / units.mol /
            units.eV)].sort_values("energy").reset_index(drop=True)

    redundant = []
    conformer_copies = [conf.copy() for conf in df.conformer]
    for i,j in itertools.combinations(range(len(df.conformer)),2):
        copy_1 = conformer_copies[i].rdkit_molecule
        copy_2 = conformer_copies[j].rdkit_molecule
        rmsd = rdMolAlign.GetBestRMS(copy_1,copy_2)
        if rmsd <= rmsd_cutoff:
            redundant.append(j)

    redundant = list(set(redundant))
    df.drop(df.index[redundant], inplace=True)

    if multiplicity and conformer.rmg_molecule.multiplicity > 2:
        rads = conformer.rmg_molecule.get_radical_count()
        if rads % 2 == 0:
            multiplicities = range(1,rads+2,2)
        else:
            multiplicities = range(2,rads+2,2)
    else:
        multiplicities = [conformer.rmg_molecule.multiplicity]

    confs = []
    i = 0
    for conf in df.conformer:
        if multiplicity:
            for mult in multiplicities:
                conf_copy = conf.copy()
                conf_copy.index = i
                conf_copy.rmg_molecule.multiplicity = mult
                confs.append(conf_copy)
                i += 1
        else:
            conf.index = i
            confs.append(conf)
            i += 1

    logging.info("We have identified {} unique, low-energy conformers for {}".format(
        len(confs), conformer))
    
    return confs