Source code for qm_atlas.tasks.conformer_generation

"""Module collecting methods for force-field based conformer generation
"""
import logging
import traceback
from copy import copy
from pathlib import Path
from typing import Annotated, Literal

from hpc_funcs.files import generate_name
from pydantic import BaseModel, ConfigDict, Field, field_validator
from rdkit import Chem
from rdkit.Chem import rdDistGeom

from qm_atlas import constants
from qm_atlas.command_line.utils.format_utils import write_sdf
from qm_atlas.tasks.utils import conformer_geometry
from qm_atlas.wrappers import corina, macromodel, moe, openeye

_logger = logging.getLogger(__name__)


DEFAULT_RMSD_THRESHOLD: float = 0.3


[docs] def generate_corina_conformers(mol: Chem.Mol, **kwargs): """Generate a 3D conformer for *mol* using CORINA (explicit Hs are added first).""" return corina.generate_conformers_molobj(Chem.AddHs(mol), **kwargs)
[docs] def generate_omega_conformers(mol: Chem.Mol, **kwargs): """Generate conformers for *mol* using OpenEye OMEGA.""" return openeye.generate_conformers_molobj(mol, **kwargs)
[docs] def generate_moe_conformers(mol: Chem.Mol, **kwargs): """Generate conformers for *mol* using MOE.""" return moe.generate_conformers(mol, **kwargs)
[docs] def generate_macromodel_conformers(mol: Chem.Mol, **kwargs): """Generate conformers for *mol* using MacroModel.""" return macromodel.generate_conformers(mol, **kwargs)
[docs] def generate_macrocycle_conformers(mol: Chem.Mol, **kwargs): """Generate macrocycle conformers for *mol* using MacroModel.""" return macromodel.generate_macrocycle_conformers(mol, **kwargs)
RDKIT_METHODS = { "ETDG": rdDistGeom.ETDG, "ETDGv2": rdDistGeom.ETDGv2, "ETKDG": rdDistGeom.ETKDG, "ETKDGv2": rdDistGeom.ETKDGv2, "ETKDGv3": rdDistGeom.ETKDGv3, "KDG": rdDistGeom.KDG, "srETKDGv3": rdDistGeom.srETKDGv3, }
[docs] def generate_rdkit_conformers( mol: Chem.Mol, method: str = "KDG", max_conformers: int = 1000, rms_threshold: float = DEFAULT_RMSD_THRESHOLD, random_seed: int = 1, scr: Path | None = None, ) -> Chem.Mol | None: """Calls rdkit to generate conformers and deduplicates them according to the RMSD between them. Args: mol (Chem.Mol): The input graph. method (str, optional): The RDKit embedding method (a key of RDKIT_METHODS, e.g. "ETKDGv3"). Defaults to "KDG". max_conformers (int, optional): The maximal number of conformers to generate. Defaults to 1000. rms_threshold (float, optional): The threshold value for the RMSD to use for the deduplication. Defaults to DEFAULT_RMSD_THRESHOLD. random_seed (int, optional): A random seed to get reproducible results from the rdkit conformer generator. Defaults to 1. scr (Path | None, optional): Unused; accepted for API compatibility with the other generators. Defaults to None. Returns: Optional[Chem.Mol]: The molecule with conformers. Returns None if the code fails. """ del scr # un-used but API-required if method not in RDKIT_METHODS: _logger.error(f"rdkit conformer generation method {method} not recognized") raise ValueError(f"rdkit conformer generation method {method} not recognized") rdk_conf_gen = RDKIT_METHODS[method]() rdk_conf_gen.verbose = False rdk_conf_gen.randomSeed = random_seed rdk_conf_gen.pruneRmsThresh = rms_threshold # fresh start mol_3d = Chem.AddHs(mol) mol_3d.RemoveAllConformers() try: rdDistGeom.EmbedMultipleConfs(mol_3d, numConfs=max_conformers, params=rdk_conf_gen) except Exception as exc: # pylint: disable=broad-except _logger.error(f"rdkit conformer generation failed with Exception {exc}") _logger.error(f"Exception traceback: {traceback.format_exc()}") return None return mol_3d
[docs] class ConformerGeneratorOptions(BaseModel): """Base class for conformer generator backends. Each concrete backend declares a unique ``backend`` literal that acts as the discriminator for (de)serialization within :data:`ConformerGeneratorConfig`. """
[docs] class MacromodelOptions(ConformerGeneratorOptions): """Options for Macromodel conformer generation.""" # Every field only fills the MacroModel input template, so reject unknown keys as typos. model_config = ConfigDict(extra="forbid") backend: Literal["macromodel"] = Field( default="macromodel", description="Conformer generator backend to use", ) rms_threshold: float = Field( default=0.3, description="RMSD threshold for duplicate detection", ) max_conformers: int = Field( default=1000, description="Maximum number of conformers to generate (0=unlimited)", ) mode: str = Field( default="MCMM", description="Conformer search type: MCMM or LMCS", ) force_field: int = Field( default=14, description="Force field: 10=MMFF94s, 14=OPLS2005, 16=OPLS4", ) solvent_num: int = Field( default=1, description="Solvent number: 1=water, 9=octanol", ) license_buffer: int = Field( default=1, description="Number of licenses to leave available", ) license_timeout: int = Field( default=60, description="Timeout in minutes for waiting on license", )
[docs] class OmegaOptions(ConformerGeneratorOptions): """Options for OpenEye Omega conformer generation.""" # The Omega wrapper takes only explicit parameters, so reject unknown keys as typos. model_config = ConfigDict(extra="forbid") backend: Literal["omega"] = Field( default="omega", description="Conformer generator backend to use", ) rms_threshold: float = Field( default=0.3, description="RMSD threshold for duplicate detection", ) max_conformers: int = Field( default=1000, description="Maximum number of conformers to generate", ) start_from_corina: bool = Field( default=False, description="Use CORINA to generate 3D input conformation", ) n_cores: int = Field( default=1, description="Number of cores to use", )
[docs] class MoeOptions(ConformerGeneratorOptions): """Options for MOE conformer generation.""" # Every field only fills the MOE input template, so reject unknown keys as typos. model_config = ConfigDict(extra="forbid") backend: Literal["moe"] = Field( default="moe", description="Conformer generator backend to use", ) rms_threshold: float = Field( default=0.3, description="RMSD threshold for duplicate detection", ) max_conformers: int = Field( default=1000, description="Maximum number of conformers to generate", ) mode: str = Field( default="'LowModeMD'", description="Conformer search mode (wrapped in quotes for MOE)", )
[docs] class CorinaOptions(ConformerGeneratorOptions): """Options for CORINA conformer generation.""" # The CORINA wrapper takes only explicit parameters, so reject unknown keys as typos. model_config = ConfigDict(extra="forbid") backend: Literal["corina"] = Field( default="corina", description="Conformer generator backend to use", )
[docs] class RdkitOptions(ConformerGeneratorOptions): """Options for RDKit conformer generation.""" # The RDKit generator takes only explicit parameters, so reject unknown keys as typos. model_config = ConfigDict(extra="forbid") backend: Literal["rdkit"] = Field( default="rdkit", description="Conformer generator backend to use", ) method: str = Field( default="KDG", description="Embedding method: ETDG, ETKDG, ETKDGv2, ETKDGv3, KDG, srETKDGv3", ) max_conformers: int = Field( default=1000, description="Maximum number of conformers to generate", ) rms_threshold: float = Field( default=0.3, description="RMSD threshold for deduplication", ) random_seed: int = Field( default=1, description="Random seed for reproducibility", )
[docs] @field_validator("method") @classmethod def validate_method(cls, v: str) -> str: if v not in RDKIT_METHODS: allowed = ", ".join(RDKIT_METHODS.keys()) raise ValueError(f"method must be one of: {allowed}") return v
[docs] class MacrocycleOptions(ConformerGeneratorOptions): """Options for Macrocycle conformer generation.""" # The macrocycle wrapper forwards extra kwargs to the script as CLI arguments. model_config = ConfigDict(extra="allow") backend: Literal["macrocycle"] = Field( default="macrocycle", description="Conformer generator backend to use", ) max_conformers: int = Field( default=1000, description="Maximum number of conformers to generate", ) energy_cutoff: float = Field( default=30.0, description="Energy cutoff in kcal/mol", ) rms_threshold: float = Field( default=0.3, description="RMS threshold in Angstroms", ) num_cores: int = Field( default=1, description="Number of cores to use", ) license_buffer: int = Field( default=1, description="Number of licenses to leave available", ) license_timeout: int = Field( default=60, description="Timeout in minutes for waiting on license", )
GENERATOR_FUNCTIONS_AND_OPTIONS = { "corina": (generate_corina_conformers, CorinaOptions), "macrocycle": (generate_macrocycle_conformers, MacrocycleOptions), "macromodel": (generate_macromodel_conformers, MacromodelOptions), "moe": (generate_moe_conformers, MoeOptions), "omega": (generate_omega_conformers, OmegaOptions), "rdkit": (generate_rdkit_conformers, RdkitOptions), }
[docs] def get_generator_function(name: str): """Get the conformer generator function for a given generator name.""" if name not in GENERATOR_FUNCTIONS_AND_OPTIONS: available = ", ".join(GENERATOR_FUNCTIONS_AND_OPTIONS.keys()) raise ValueError( f"Unknown conformer generator '{name}'. " f"Available generators: {available}" ) return GENERATOR_FUNCTIONS_AND_OPTIONS[name][0]
# Discriminated union of all conformer generator config types. The ``backend`` # literal field acts as the discriminator so Pydantic selects the concrete class # automatically when deserializing from a dict, and serializes all fields during # model_dump(). ConformerGeneratorConfig = Annotated[ MacromodelOptions | OmegaOptions | MoeOptions | CorinaOptions | RdkitOptions | MacrocycleOptions, Field(discriminator="backend"), ]
[docs] def deserialize_conformer_generator_config(v: object) -> object: """Convert a dict into the appropriate conformer generator options class.""" if isinstance(v, dict): backend = v.get("backend") if backend not in GENERATOR_FUNCTIONS_AND_OPTIONS: available = ", ".join(GENERATOR_FUNCTIONS_AND_OPTIONS.keys()) raise ValueError( f"Unknown conformer generator '{backend}'. Available generators: {available}" ) options_cls = GENERATOR_FUNCTIONS_AND_OPTIONS[backend][1] return options_cls(**v) return v
[docs] def join_conformers( mol_1: Chem.Mol | None, mol_2: Chem.Mol | None, ) -> Chem.Mol | None: """Merges the conformers of two molecule objects representing the same graph. For convenience either of the input molecules can be None, in this case the other is returned. Args: mol_1 (Optional[Chem.Mol]): An rdkit molecule or None. mol_2 (Optional[Chem.Mol]): An rdkit molecule or None. Returns: ret_mol (Optional[Chem.Mol]): A molecule representing the same graph, with all conformers set on either of the input molecules. """ if mol_1 is None: if mol_2 is None: return None return mol_2 if mol_2 is None: return mol_1 ret_mol = Chem.Mol(mol_1) for conformer in mol_2.GetConformers(): ret_mol.AddConformer(conformer, assignId=True) return ret_mol
[docs] def generate_conformers_with_fallback( mol: Chem.Mol, conf_gens: list[ConformerGeneratorConfig], ) -> Chem.Mol | None: """Allows to specify fall-backs for failed conformer generations. The conformer generators are called in order until one succeeds. Args: mol (Chem.Mol): The rdkit molecule. conf_gens (list[ConformerGeneratorConfig]): The conformer generator option objects to use, in order. Each is a typed options object whose ``backend`` field selects the generator. Returns: mol_3d (Optional[Chem.Mol]): The molecule with all conformers. Returns None if all generators fail. """ for conf_gen_entry in copy(conf_gens): conf_gen = get_generator_function(conf_gen_entry.backend) options = conf_gen_entry.model_dump(exclude={"backend"}) try: mol_3d = conf_gen(mol, **options) if mol_3d is not None and mol_3d.GetNumConformers() > 0: _logger.info(f"Used {conf_gen_entry.backend} to generate conformers") return mol_3d except Exception as exc: # pylint: disable=broad-except _logger.warning( f"Conformer generation with {conf_gen_entry.backend} failed with Exception {exc}" ) _logger.warning("Relying on Falback method.") _logger.error("All conformer generators failed") raise RuntimeError("All conformer generators failed")
[docs] def generate_joint_conformer_set( mol: Chem.Mol, conf_gens: list[ConformerGeneratorConfig], rms_threshold: float = DEFAULT_RMSD_THRESHOLD, rmsd_method: str = "tradeoff", num_cores: int = 1, trace_dir: Path = constants.DEFAULT_SCR, write_intermediates: bool = False, ) -> Chem.Mol | None: """Calls different conformer generators, merges their results and deduplicates according to RMSD between conformers. Args: mol (Chem.Mol): The rdkit molecule. conf_gens (list[ConformerGeneratorConfig]): The conformer generator option objects to use. Each is a typed options object whose ``backend`` field selects the generator. rms_threshold (float, optional): The rmsd threshold to use for the deduplication. Defaults to DEFAULT_RMSD_THRESHOLD. If you pass None, no deduplication will be performed. rmsd_method (str, optional): The RMSD algorithm used during deduplication. Defaults to "tradeoff". num_cores (int, optional): Number of CPU cores to use for the deduplication. Defaults to 1. trace_dir (Path, optional): Directory to write per-generator intermediate SDFs to when *write_intermediates* is True. Defaults to constants.DEFAULT_SCR. write_intermediates (bool, optional): If True, writes each generator's conformers to *trace_dir* for debugging. Defaults to False. Returns: mol_3d (Optional[Chem.Mol]): The molecule with all conformers. Returns None if all generators fail. """ mol_3d = None for conf_gen_entry in copy(conf_gens): conf_gen = get_generator_function(conf_gen_entry.backend) options = conf_gen_entry.model_dump(exclude={"backend"}) try: new_mol_3d = conf_gen(mol, **options) except Exception as exc: # pylint: disable=broad-except _logger.warning( f"Conformer generation with {conf_gen_entry.backend} failed with Exception {exc}" ) _logger.warning("Skipping this conformer generator.") continue if new_mol_3d is None or new_mol_3d.GetNumConformers() == 0: _logger.warning(f"Conformer generator {conf_gen_entry.backend} returned no conformers") _logger.warning("Skipping this conformer generator.") continue if write_intermediates: random_name = generate_name() sdf_file = trace_dir / f"confs_{conf_gen_entry.backend}_{random_name}.sdf" _logger.debug(f"Writing {conf_gen_entry.backend} results to {sdf_file.resolve()}") write_sdf(new_mol_3d, sdf_file) mol_3d = join_conformers(mol_3d, new_mol_3d) if mol_3d is None: _logger.error("All conformer generators failed") raise RuntimeError("All conformer generators failed") if rms_threshold is not None: conformer_geometry.deduplicate_conformers( mol_3d, rms_threshold=rms_threshold, method=rmsd_method, num_cores=num_cores ) if write_intermediates: random_name = generate_name() sdf_file = trace_dir / f"confs_joint_{random_name}.sdf" _logger.debug(f"Writing joint conformer set to {sdf_file.resolve()}") write_sdf(mol_3d, sdf_file) return mol_3d
[docs] class ConformerGenerationOptions(BaseModel): """Options for the generate_conformers task.""" conf_gens: list[ConformerGeneratorConfig] = Field( ..., description="List of conformer generator option objects to use. Each is a typed " "options object whose 'backend' field selects the generator.", ) combination_name: Literal["joint_set", "fallback"] = Field( default="joint_set", description="Method to combine multiple conformer generators: 'joint_set' or 'fallback'", ) rms_threshold: float = Field( default=DEFAULT_RMSD_THRESHOLD, description="RMSD threshold for deduplication of joint conformer set. Ignored if combination_name is 'fallback'.", ) rmsd_method: str = Field( default="tradeoff", description="RMSD method for deduplication of joint conformer set. Ignored if combination_name is 'fallback'.", )
[docs] @field_validator("conf_gens", mode="before") @classmethod def deserialize_conf_gens(cls, v: object) -> object: """Deserialize conformer generator configs from dicts.""" if isinstance(v, list): return [deserialize_conformer_generator_config(item) for item in v] return v
[docs] def generate_conformers( mol: Chem.Mol, conformer_generation_options: ConformerGenerationOptions, num_cores: int = 1, trace_dir: Path = constants.DEFAULT_SCR, write_intermediates: bool = False, ) -> Chem.Mol | None: """Main entry point for conformer generation. Dispatches to the appropriate method based on the combination_name.""" if conformer_generation_options.combination_name == "joint_set": return generate_joint_conformer_set( mol=mol, conf_gens=conformer_generation_options.conf_gens, rms_threshold=conformer_generation_options.rms_threshold, rmsd_method=conformer_generation_options.rmsd_method, num_cores=num_cores, trace_dir=trace_dir, write_intermediates=write_intermediates, ) elif conformer_generation_options.combination_name == "fallback": return generate_conformers_with_fallback( mol=mol, conf_gens=conformer_generation_options.conf_gens, )