#!/usr/bin/env amspython
# coding: utf-8
# ## Initial imports
import os
import sys
import matplotlib.pyplot as plt
import numpy as np
from scm.conformers import ConformersJob
from scm.plams import *
# this line is not required in AMS2025+
init()
# ## Initial structure
molecule = from_smiles("OC(CC1c2ccccc2Sc2ccccc21)CN1CCCC1")
plot_molecule(molecule)
# ## Generate conformers with RDKit and UFF
# The fastest way to generate conformers is to use RDKit with the UFF force field.
#
# Below we specify to generate 16 initial conformers. The final number of conformers may be smaller, as the geometry optimization may cause several structures to enter the same minimum.
# ### Conformer generation settings
s = Settings()
s.input.ams.Task = "Generate" # default
s.input.ams.Generator.Method = "RDKit" # default
s.input.ams.Generator.RDKit.InitialNConformers = 16 # optional, non-default
s.input.ForceField.Type = "UFF" # default
# ### Conformer generation input file
print(ConformersJob(settings=s).get_input())
# ### Run conformer generation
generate_job = ConformersJob(name="generate", molecule=molecule, settings=s)
generate_job.run()
# ## Conformer generation results
# ### Some helper functions
def get_energies(job: ConformersJob, temperature=298, unit="kcal/mol"):
return job.results.get_relative_energies(unit)
def get_populations(job: ConformersJob, temperature=298, unit="kcal/mol"):
return job.results.get_boltzmann_distribution(temperature)
def get_energy_header(unit="kcal/mol"):
return f"ΔE [{unit}]"
def get_population_header(temperature=298):
return f"Pop. (T = {temperature} K)"
def get_conformers(job: ConformersJob):
return job.results.get_conformers()
def plot_conformers(job: ConformersJob, indices=None, temperature=298, unit="kcal/mol", lowest=True):
molecules = get_conformers(job)
energies = get_energies(job, unit)
populations = get_populations(job, temperature)
if isinstance(indices, int):
N_plot = min(indices, len(energies))
if lowest:
indices = list(range(N_plot))
else:
indices = np.linspace(0, len(energies) - 1, N_plot, dtype=np.int32)
if indices is None:
indices = list(range(min(3, len(energies))))
fig, axes = plt.subplots(1, len(indices), figsize=(12, 3))
if len(indices) == 1:
axes = [axes]
for ax, i in zip(axes, indices):
mol = molecules[i]
E = energies[i]
population = populations[i]
plot_molecule(mol, ax=ax)
ax.set_title(f"#{i+1}\nΔE = {E:.2f} kcal/mol\nPop.: {population:.3f} (T = {temperature} K)")
try:
# For AMS2025+ can use JobAnalysis class to perform results analysis
from scm.plams import JobAnalysis
def print_results(job: ConformersJob, temperature=298, unit="kcal/mol"):
ja = (
JobAnalysis(standard_fields=None)
.add_job(job)
.add_field(
"Id",
lambda j: list(range(1, len(get_conformers(j)) + 1)),
display_name="Conformer Id",
expansion_depth=1,
)
.add_field("Energies", get_energies, display_name=get_energy_header(), expansion_depth=1, fmt=".2f")
.add_field(
"Populations", get_populations, display_name=get_population_header(), expansion_depth=1, fmt=".3f"
)
)
# Pretty-print if running in a notebook
if "ipykernel" in sys.modules:
ja.display_table()
else:
print(ja.to_table())
except ImportError:
def print_results(job: ConformersJob, temperature=298, unit="kcal/mol"):
energies = get_energies(job, temperature, unit)
populations = get_populations(job, temperature, unit)
print(f"Total # conformers in set: {len(energies)}")
dE_header = get_energy_header(unit)
pop_header = get_population_header(temperature)
print(f'{"#":>4s} {dE_header:>14s} {pop_header:>18s}')
for i, (E, pop) in enumerate(zip(energies, populations)):
print(f"{i+1:4d} {E:14.2f} {pop:18.3f}")
# ### Actual results
#
# Below we see that the **conformer generation gave 14 distinct conformers**, where the highest-energy conformer is 18 kcal/mol higher in energy than the lowest energy conformer.
#
# You can also see the **relative populations** of these conformers at the specified temperature. The populations are calculated from the **Boltzmann distribution** and the relative energies.
unit = "kcal/mol"
temperature = 298
print_results(generate_job, temperature, unit)
plot_conformers(generate_job, 4, temperature=temperature, unit=unit, lowest=True)
# ## Re-optimize conformers with GFNFF
#
# The UFF force field is not very accurate for geometries and energies. From an initial conformer set you can reoptimize it with a better level of theory.
#
# The **Optimize** task performs **GeometryOptimization** jobs on each conformer in a set.
#
# Below, the most stable conformers (within 8 kcal/mol of the most stable conformer) at the UFF level of theory are re-optimized with GFNFF, which gives more accurate geometries.
s = Settings()
s.input.ams.Task = "Optimize"
s.input.ams.InputConformersSet = os.path.abspath(generate_job.results.rkfpath()) # must be absolute path
s.input.ams.InputMaxEnergy = 8.0 # only conformers within 8 kcal/mol at the PREVIOUS level of theory
s.input.GFNFF # or choose a different engine if you don't have a GFNFF license
reoptimize_job = ConformersJob(settings=s, name="reoptimize")
print(reoptimize_job.get_input())
reoptimize_job.run()
print_results(reoptimize_job, temperature=temperature, unit=unit)
plot_conformers(reoptimize_job, 4, temperature=temperature, unit=unit, lowest=True)
# ## Score conformers with DFTB
#
# If you have many conformers or a very large molecule, it can be computationally expensive to do the conformer generation or reoptimization and a high level of theory.
#
# The **Score** task runs **SinglePoint** jobs on the conformers in a set. This lets you use a more computationally expensive method. Here, we choose DFTB, although normally you may choose some DFT method.
s = Settings()
s.input.ams.Task = "Score"
s.input.ams.InputConformersSet = os.path.abspath(reoptimize_job.results.rkfpath()) # must be absolute path
s.input.ams.InputMaxEnergy = 4.0 # only conformers within 4 kcal/mol at the PREVIOUS level of theory
s.input.DFTB.Model = "GFN1-xTB" # or choose a different engine if you don't have a DFTB license
# s.input.adf.XC.GGA = 'PBE' # to use ADF PBE
# s.input.adf.XC.DISPERSION = 'GRIMME3 BJDAMP' # to use ADF PBE with Grimme D3(BJ) dispersion
score_job = ConformersJob(settings=s, name="score")
score_job.run()
print_results(score_job, temperature=temperature, unit=unit)
plot_conformers(score_job, 4, temperature=temperature, unit=unit, lowest=True)
# Here, you see that from the conformers in the set, **DFTB predicts a different lowest-energy conformer than GFNFF** (compare to previous figure).
# ## Filter a conformer set
#
# In practice, you may have generated thousands of conformers for a particular structure. Many of those conformers may be so high in energy that their Boltzmann weights are very small.
#
# The **Filter** task only filters the conformers, it does not perform any additional calculations. It can be used to reduce a conformer set so that it is more convenient to work with.
#
# Below, we filter the conformers set to only the conformers within 1 kcal/mol of the minimum.
s = Settings()
s.input.ams.Task = "Filter"
s.input.ams.InputConformersSet = os.path.abspath(score_job.results.rkfpath())
s.input.ams.InputMaxEnergy = 1.0
filter_job = ConformersJob(settings=s, name="filter")
filter_job.run()
print_results(filter_job, temperature=temperature, unit=unit)
plot_conformers(filter_job, 4, temperature=temperature, unit=unit, lowest=True)
# The structures and energies are identical to before. However, the relative populations changed slightly as there are now fewer conformers in the set.
# ## More about conformers
#
# * Try **CREST** instead of RDKit to generate the initial conformer set
#
# * The **Expand** task can be used to expand a set of conformers.