Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ memory/
static/iridium/*
scripts/
benchmarks/
datasets/
GDB20*
TMCONF40/
TMCONF5/
TMCONF_Econf.ods
tmconf5.tar

docs/

TM_CONFORMER_BENCHMARK.md
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ ensemble.to_xyz("output.xyz")

### Named Presets

Six use-case presets are available out of the box:
Seven use-case presets are available out of the box:

```python
from openconf import generate_conformers
Expand All @@ -49,12 +49,19 @@ ensemble = generate_conformers(mol, preset="ensemble") # property predictio
ensemble = generate_conformers(mol, preset="spectroscopic") # NMR / IR / VCD
ensemble = generate_conformers(mol, preset="docking") # docking pose recovery
ensemble = generate_conformers(mol, preset="macrocycle") # macrocyclic ring systems
ensemble = generate_conformers(mol, preset="transition_metal") # organometallics
```

For FEP-style analogue generation from a fixed pose, see [`generate_conformers_from_pose`](#5-analogue--fep-style-r-group-exploration) below.

For macrocyclic ring systems, use the `"macrocycle"` preset, which enables low-mode following and a wide energy window to capture the full range of ring-pucker conformations.

For transition-metal complexes with 3D input coordinates, use the
`"transition_metal"` preset. Other presets also add a transition-metal move
budget automatically when a metal atom is detected; set
`ConformerConfig(auto_transition_metal_moves=False)` to preserve an exact custom
move schedule.

### Custom Configuration

For full control, pass a `ConformerConfig` directly. You can also use
Expand Down Expand Up @@ -82,7 +89,7 @@ ensemble = generate_conformers(mol, config=config)

## Use-Case Examples

The right configuration depends on the downstream task. Four named presets
The right configuration depends on the downstream task. Named presets
cover the most common workflows:

```python
Expand All @@ -97,7 +104,7 @@ config.max_out = 200 # override a single field
ensemble = generate_conformers(mol, config=config)
```

Available presets: `"rapid"`, `"ensemble"`, `"spectroscopic"`, `"docking"`, `"analogue"`, `"macrocycle"`.
Available presets: `"rapid"`, `"ensemble"`, `"spectroscopic"`, `"docking"`, `"analogue"`, `"macrocycle"`, `"transition_metal"`.

Below are representative wall-clock timings measured on a single CPU core
(Apple M3 Pro), mean over 3 runs.
Expand Down
44 changes: 42 additions & 2 deletions openconf/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .exceptions import OpenConfRuntimeError, OpenConfValueError
from .perceive import (
StereoSignature,
_is_metal,
build_rotor_model,
conformer_matches_specified_stereochemistry,
prepare_molecule,
Expand All @@ -24,6 +25,38 @@

# Gas constant in kcal/(mol·K); kT at 298.15 K ≈ 0.5924 kcal/mol.
_R_KCAL_PER_MOL_K = 1.987204e-3
_AUTO_TM_MOVE_BUDGET = 0.35
_AUTO_TM_MOVE_WEIGHTS = {
"tm_ligand_rotate": 0.75,
"tm_haptic_rotate": 0.25,
}
_TM_MOVE_TYPES = frozenset(_AUTO_TM_MOVE_WEIGHTS)


def _has_transition_metal_move_budget(config: ConformerConfig) -> bool:
"""Return whether config already assigns positive budget to TM moves."""
return any(config.move_probs.get(move_type, 0.0) > 0.0 for move_type in _TM_MOVE_TYPES)


def _with_auto_transition_metal_sampling(
config: ConformerConfig,
*,
has_metal: bool,
has_metal_input: bool,
) -> ConformerConfig:
"""Return config with automatic TM sampling overlay when appropriate."""
if not has_metal or not config.auto_transition_metal_moves or _has_transition_metal_move_budget(config):
return config

total = sum(config.move_probs.values())
move_probs = {move_type: prob * (1.0 - _AUTO_TM_MOVE_BUDGET) for move_type, prob in config.move_probs.items()}
for move_type, weight in _AUTO_TM_MOVE_WEIGHTS.items():
move_probs[move_type] = move_probs.get(move_type, 0.0) + total * _AUTO_TM_MOVE_BUDGET * weight

updates: dict[str, object] = {"move_probs": move_probs}
if has_metal_input and config.tm_seed_move_attempts == 0:
updates["tm_seed_move_attempts"] = 2
return dataclasses.replace(config, **updates)


def _filter_stereochemistry_consistent_conformers(
Expand Down Expand Up @@ -355,8 +388,9 @@ def generate_conformers(
method: generation method; `"hybrid"` is the default and recommended
config: configuration options; defaults are used when omitted
preset: named use-case preset; one of `"rapid"`,
`"ensemble"`, `"spectroscopic"`, `"docking"`. Mutually
exclusive with *config*; raises ValueError if both are supplied.
`"ensemble"`, `"spectroscopic"`, `"docking"`, `"analogue"`,
`"macrocycle"`, or `"transition_metal"`. Mutually exclusive with
*config*; raises ValueError if both are supplied.
torsion_library: torsion library override; when omitted, uses
the bundled cached CrystalFF-derived library.
add_hs: add explicit hydrogens before embedding; set to
Expand Down Expand Up @@ -397,8 +431,14 @@ def generate_conformers(

if method == "hybrid":
low_flex_tuning = get_runtime_tuning().low_flex_path
has_metal = any(_is_metal(atom) for atom in mol.GetAtoms())
has_metal_input = has_metal and mol.GetNumConformers() > 0
config = _with_auto_transition_metal_sampling(config, has_metal=has_metal, has_metal_input=has_metal_input)
has_tm_sampling_profile = has_metal and config.move_probs.get("tm_ligand_rotate", 0.0) > 0.0
use_low_flex_path = (
config.constraint_spec is None
and not has_metal_input
and not has_tm_sampling_profile
and rotor_model.n_rotatable <= low_flex_tuning.max_rotatable
and (low_flex_tuning.allow_macrocycles or not rotor_model.ring_info.get("has_macrocycle"))
and (low_flex_tuning.allow_rings or not rotor_model.ring_info.get("ring_sizes"))
Expand Down
68 changes: 64 additions & 4 deletions openconf/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
from .exceptions import OpenConfValueError
from .tuning import get_default_move_probs

ConformerPreset = Literal["rapid", "ensemble", "spectroscopic", "docking", "analogue", "macrocycle"]
ConformerPreset = Literal[
"rapid",
"ensemble",
"spectroscopic",
"docking",
"analogue",
"macrocycle",
"transition_metal",
]
_SUPPORTED_MOVE_TYPES = frozenset(
{
"single_rotor",
Expand All @@ -17,6 +25,8 @@
"crankshaft",
"ring_kic",
"amide_flip",
"tm_ligand_rotate",
"tm_haptic_rotate",
}
)
_SUPPORTED_PARENT_STRATEGIES = frozenset({"softmax", "uniform", "best"})
Expand Down Expand Up @@ -130,7 +140,7 @@ class ConformerConfig:
values flatten the distribution (more exploration); smaller values
concentrate sampling on the lowest-energy pool members. Default 2.0
matches typical MCMM practice and is unrelated to physical temperature.
final_select: How the final conformer set is chosen.
final_select: How final conformer set is chosen.
skip_clash_check: skip the pre-minimization clash check entirely.
When False (default), use a fast numpy-based clash filter that avoids
expensive minimization of heavily clashed structures. For large or
Expand Down Expand Up @@ -191,6 +201,10 @@ class ConformerConfig:
preserved.
collect_stats: record stage timings and counters for benchmark
analysis and attach them to the returned ensemble.
auto_transition_metal_moves: add transition-metal move budget
automatically when molecule contains metal atoms and configured
move probabilities do not already include TM-specific moves.
Set to False to preserve exact custom move probabilities.
torsion_multitry_attempts: pre-minimization torsion proposal count
to try for clash-filtered torsion moves. The candidate with the
lowest clash score is kept, reducing wasted minimizations on crowded
Expand Down Expand Up @@ -230,6 +244,11 @@ class ConformerConfig:
skipped for low mode following but still enter the MCMM pool
normally. Default 1 (only the lowest-energy seed) keeps the
Hessian cost fixed regardless of total seed count.
tm_seed_move_attempts: number of seed conformers to attempt per
available TM-specific move type when starting from input
coordinates. This supplements the protected input seed with
early metal-ligand rotation and haptic-rotation variants while
reusing the normal move and minimization machinery.
"""

max_out: int = 200
Expand Down Expand Up @@ -270,6 +289,7 @@ class ConformerConfig:
patience: int = 150
auto_tune_large_flexible: bool = True
collect_stats: bool = False
auto_transition_metal_moves: bool = True
torsion_multitry_attempts: int = 4
use_low_mode_following: bool = False
low_mode_eigenvalue_threshold: float = 100.0
Expand All @@ -278,6 +298,7 @@ class ConformerConfig:
low_mode_scan_energy_threshold: float = 2390.0
low_mode_scan_max_steps: int = 10
low_mode_n_source_seeds: int = 1
tm_seed_move_attempts: int = 0

def __post_init__(self) -> None:
_require_at_least("max_out", self.max_out, 1)
Expand Down Expand Up @@ -309,6 +330,7 @@ def __post_init__(self) -> None:
_require_greater_than("low_mode_scan_energy_threshold", self.low_mode_scan_energy_threshold, 0.0)
_require_at_least("low_mode_scan_max_steps", self.low_mode_scan_max_steps, 1)
_require_at_least("low_mode_n_source_seeds", self.low_mode_n_source_seeds, 1)
_require_at_least("tm_seed_move_attempts", self.tm_seed_move_attempts, 0)
_validate_move_probs(self.move_probs)

if self.minimizer != "rdkit_mmff":
Expand Down Expand Up @@ -364,9 +386,15 @@ def preset_config(preset: ConformerPreset) -> ConformerConfig:
and low-mode following enabled to supplement ETKDG seeds with
Hessian-guided scan points along collective ring-puckering modes.

- `"transition_metal"` — Organometallic and coordination complexes.
Keeps wide energy filtering, uses uniform parent sampling, enables
low-mode following, and allocates move budget to metal-ligand fragment
rotations while relying on shared metal-shell constraints.

Args:
preset: one of `"rapid"`, `"ensemble"`, `"spectroscopic"`,
`"docking"`, `"analogue"`, `"macrocycle"`.
`"docking"`, `"analogue"`, `"macrocycle"`,
`"transition_metal"`.

Returns:
Configuration for requested use case
Expand All @@ -384,6 +412,9 @@ def preset_config(preset: ConformerPreset) -> ConformerConfig:
>>> config = preset_config("macrocycle")
>>> config.energy_window_kcal
100.0
>>> config = preset_config("transition_metal")
>>> config.use_low_mode_following
True
"""
match preset:
case "rapid":
Expand Down Expand Up @@ -468,8 +499,37 @@ def preset_config(preset: ConformerPreset) -> ConformerConfig:
parent_strategy="softmax",
final_select="diverse",
)
case "transition_metal":
return ConformerConfig(
max_out=100,
pool_max=500,
n_steps=250,
energy_window_kcal=100.0,
move_probs={
"single_rotor": 0.25,
"multi_rotor": 0.15,
"correlated": 0.1,
"ring_flip": 0.05,
"tm_ligand_rotate": 0.25,
"tm_haptic_rotate": 0.1,
},
use_low_mode_following=True,
low_mode_max_modes=3,
low_mode_scan_max_steps=4,
low_mode_n_source_seeds=1,
tm_seed_move_attempts=2,
seed_n_per_rotor=1,
seed_prune_rms_thresh=1.5,
prism_max_deviation=0.005,
do_final_refine=False,
minimize_batch_size=1,
parent_strategy="uniform",
final_select="diverse",
adaptive_moves=True,
)
case _:
raise OpenConfValueError(
f"Unknown preset {preset!r}. "
"Choose from: 'rapid', 'ensemble', 'spectroscopic', 'docking', 'analogue', 'macrocycle'."
"Choose from: 'rapid', 'ensemble', 'spectroscopic', 'docking', 'analogue', 'macrocycle', "
"'transition_metal'."
)
Loading
Loading