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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,12 @@ For macrocyclic systems (ring size ≥ 12) the default configuration trails ETKD
- `ConformerEnsemble` - Container for conformers and metadata
- `ConformerRecord` - Per-conformer metadata

### Exceptions

- `OpenConfError` - Base class for all library errors; catch this to handle any openconf exception
- `OpenConfValueError` - Bad input or configuration (also a `ValueError`)
- `OpenConfRuntimeError` - Chemistry-level failure during generation (also a `RuntimeError`)

### Lower-Level Components

- `prepare_molecule(mol)` - Sanitize and add hydrogens
Expand Down
4 changes: 4 additions & 0 deletions openconf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
)
from .config import ConformerConfig, ConformerPreset, ConstraintSpec, preset_config
from .dedupe import prism_dedupe
from .exceptions import OpenConfError, OpenConfRuntimeError, OpenConfValueError
from .io import mol_to_smiles, read_sdf, read_xyz, smiles_to_mol, write_sdf, write_xyz
from .perceive import Rotor, RotorModel, build_rotor_model, filter_constrained_rotors, prepare_molecule
from .relax import RDKitMMFFMinimizer, get_minimizer
Expand All @@ -19,6 +20,9 @@
"ConformerPreset",
"ConformerRecord",
"ConstraintSpec",
"OpenConfError",
"OpenConfRuntimeError",
"OpenConfValueError",
"RDKitMMFFMinimizer",
"Rotor",
"RotorModel",
Expand Down
27 changes: 14 additions & 13 deletions openconf/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from rdkit import Chem

from .config import ConformerConfig, ConformerPreset, ConstraintSpec, preset_config
from .exceptions import OpenConfRuntimeError, OpenConfValueError
from .perceive import (
StereoSignature,
build_rotor_model,
Expand Down Expand Up @@ -43,7 +44,7 @@ def _filter_stereochemistry_consistent_conformers(
Filtered conformer IDs and energies

Raises:
ValueError: all final conformers changed specified stereochemistry
OpenConfRuntimeError: all final conformers changed specified stereochemistry
"""
if not reference_stereo.tetrahedral and not reference_stereo.bonds:
return conf_ids, energies
Expand All @@ -62,7 +63,7 @@ def _filter_stereochemistry_consistent_conformers(
mol.RemoveConformer(conf_id)

if conf_ids and not kept_ids:
raise ValueError("All generated conformers changed input-specified stereochemistry.")
raise OpenConfRuntimeError("All generated conformers changed input-specified stereochemistry.")

return kept_ids, kept_energies

Expand Down Expand Up @@ -158,19 +159,19 @@ def boltzmann_weights(self, temperature: float = 298.15) -> np.ndarray:
Normalized weights aligned with `records`

Raises:
ValueError: temperature is not positive
ValueError: ensemble has no conformers with finite energies
OpenConfValueError: temperature is not positive
OpenConfRuntimeError: ensemble has no conformers with finite energies
"""
if temperature <= 0.0:
raise ValueError(f"temperature must be > 0, got {temperature}.")
raise OpenConfValueError(f"temperature must be > 0, got {temperature}.")

energies = np.array(
[r.energy_kcal if r.energy_kcal is not None else np.inf for r in self.records],
dtype=float,
)
finite = np.isfinite(energies)
if not finite.any():
raise ValueError("Cannot compute Boltzmann weights: no conformer has a finite energy.")
raise OpenConfRuntimeError("Cannot compute Boltzmann weights: no conformer has a finite energy.")

shifted = energies - energies[finite].min()
weights = np.zeros_like(energies)
Expand Down Expand Up @@ -330,7 +331,7 @@ def from_sdf(cls, input_path: str) -> Self:
)

if mol is None:
raise ValueError(f"No valid molecules in {input_path}")
raise OpenConfValueError(f"No valid molecules in {input_path}")

return cls(mol=mol, records=records)

Expand Down Expand Up @@ -367,7 +368,7 @@ def generate_conformers(
Generated conformers with metadata

Raises:
ValueError: mol is invalid, method is unknown, or both *config*
OpenConfValueError: mol is invalid, method is unknown, or both *config*
and *preset* are supplied.

Examples:
Expand All @@ -378,7 +379,7 @@ def generate_conformers(
>>> ensemble = generate_conformers(mol, config=ConformerConfig(max_out=100)) # doctest: +SKIP
"""
if config is not None and preset is not None:
raise ValueError("Specify at most one of 'config' or 'preset', not both.")
raise OpenConfValueError("Specify at most one of 'config' or 'preset', not both.")

if isinstance(mol, str):
from .io import smiles_to_mol
Expand All @@ -405,7 +406,7 @@ def generate_conformers(
runner = run_low_flex_generation if use_low_flex_path else run_hybrid_generation
mol, conf_ids, energies, generation_stats = runner(mol, rotor_model, config, torsion_library=torsion_library)
else:
raise ValueError(f"Unknown method: {method}. Available: 'hybrid'")
raise OpenConfValueError(f"Unknown method: {method}. Available: 'hybrid'")

conf_ids, energies = _filter_stereochemistry_consistent_conformers(mol, conf_ids, energies, reference_stereo)

Expand Down Expand Up @@ -458,7 +459,7 @@ def generate_conformers_from_pose(
preserving the input core geometry.

Raises:
ValueError: mol has no conformers, both *config* and *preset* are
OpenConfValueError: mol has no conformers, both *config* and *preset* are
supplied, or if the method is unknown.

Examples:
Expand All @@ -471,10 +472,10 @@ def generate_conformers_from_pose(
... )
"""
if config is not None and preset is not None:
raise ValueError("Specify at most one of 'config' or 'preset', not both.")
raise OpenConfValueError("Specify at most one of 'config' or 'preset', not both.")

if mol.GetNumConformers() == 0:
raise ValueError(
raise OpenConfValueError(
"mol must have at least one conformer for pose-constrained generation. "
"Supply the MCS-aligned pose as conformer 0."
)
Expand Down
33 changes: 17 additions & 16 deletions openconf/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from dataclasses import dataclass, field
from typing import Literal

from .exceptions import OpenConfValueError
from .tuning import get_default_move_probs

ConformerPreset = Literal["rapid", "ensemble", "spectroscopic", "docking", "analogue", "macrocycle"]
Expand All @@ -24,7 +25,7 @@

def _require_at_least(name: str, value: float, minimum: float) -> None:
if value < minimum:
raise ValueError(f"{name} must be >= {minimum}, got {value}.")
raise OpenConfValueError(f"{name} must be >= {minimum}, got {value}.")


def _require_optional_at_least(name: str, value: float | None, minimum: float) -> None:
Expand All @@ -34,30 +35,28 @@ def _require_optional_at_least(name: str, value: float | None, minimum: float) -

def _require_greater_than(name: str, value: float, minimum: float) -> None:
if value <= minimum:
raise ValueError(f"{name} must be > {minimum}, got {value}.")
raise OpenConfValueError(f"{name} must be > {minimum}, got {value}.")


def _require_fraction(name: str, value: float) -> None:
"""Validate a value constrained to the unit interval."""
if not 0.0 <= value <= 1.0:
raise ValueError(f"{name} must be between 0.0 and 1.0, got {value}.")
raise OpenConfValueError(f"{name} must be between 0.0 and 1.0, got {value}.")


def _validate_move_probs(move_probs: dict[str, float]) -> None:
"""Validate move probability configuration."""
if not move_probs:
raise ValueError("move_probs must contain at least one supported move type.")
raise OpenConfValueError("move_probs must contain at least one supported move type.")

unknown = set(move_probs) - _SUPPORTED_MOVE_TYPES
if unknown:
unknown_str = ", ".join(sorted(unknown))
raise ValueError(f"move_probs contains unsupported move types: {unknown_str}.")
raise OpenConfValueError(f"move_probs contains unsupported move types: {unknown_str}.")

if any(prob < 0.0 for prob in move_probs.values()):
raise ValueError("move_probs values must be >= 0.0.")
raise OpenConfValueError("move_probs values must be >= 0.0.")

if sum(move_probs.values()) <= 0.0:
raise ValueError("move_probs must sum to a positive value.")
raise OpenConfValueError("move_probs must sum to a positive value.")


@dataclass
Expand Down Expand Up @@ -313,24 +312,26 @@ def __post_init__(self) -> None:
_validate_move_probs(self.move_probs)

if self.minimizer != "rdkit_mmff":
raise ValueError(f"Unsupported minimizer {self.minimizer!r}. Only 'rdkit_mmff' is supported.")
raise OpenConfValueError(f"Unsupported minimizer {self.minimizer!r}. Only 'rdkit_mmff' is supported.")

if self.parent_strategy not in _SUPPORTED_PARENT_STRATEGIES:
raise ValueError(
raise OpenConfValueError(
f"Unsupported parent_strategy {self.parent_strategy!r}. Choose from 'softmax', 'uniform', 'best'."
)

if self.final_select not in _SUPPORTED_FINAL_SELECTIONS:
raise ValueError(f"Unsupported final_select {self.final_select!r}. Choose from 'energy' or 'diverse'.")
raise OpenConfValueError(
f"Unsupported final_select {self.final_select!r}. Choose from 'energy' or 'diverse'."
)

if self.seed_prune_rms_thresh < 0.0 and self.seed_prune_rms_thresh != -1.0:
raise ValueError("seed_prune_rms_thresh must be >= 0.0, or exactly -1.0 to disable pruning.")
raise OpenConfValueError("seed_prune_rms_thresh must be >= 0.0, or exactly -1.0 to disable pruning.")

if self.pool_max is None:
self.pool_max = max(self.max_out, min(max(self.n_steps, 1) * 5, 2500))

if self.pool_max < self.max_out:
raise ValueError(f"pool_max must be >= max_out ({self.max_out}), got {self.pool_max}.")
raise OpenConfValueError(f"pool_max must be >= max_out ({self.max_out}), got {self.pool_max}.")


def preset_config(preset: ConformerPreset) -> ConformerConfig:
Expand Down Expand Up @@ -371,7 +372,7 @@ def preset_config(preset: ConformerPreset) -> ConformerConfig:
Configuration for requested use case

Raises:
ValueError: preset is not recognized
OpenConfValueError: preset is not recognized

Examples:
>>> config = preset_config("docking")
Expand Down Expand Up @@ -468,7 +469,7 @@ def preset_config(preset: ConformerPreset) -> ConformerConfig:
final_select="diverse",
)
case _:
raise ValueError(
raise OpenConfValueError(
f"Unknown preset {preset!r}. "
"Choose from: 'rapid', 'ensemble', 'spectroscopic', 'docking', 'analogue', 'macrocycle'."
)
13 changes: 13 additions & 0 deletions openconf/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Custom exception types for OpenConf."""


class OpenConfError(Exception):
"""Base class for all OpenConf errors."""


class OpenConfValueError(OpenConfError, ValueError):
"""Invalid configuration or parameter value."""


class OpenConfRuntimeError(OpenConfError, RuntimeError):
"""Chemistry-level failure during conformer generation."""
12 changes: 7 additions & 5 deletions openconf/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from rdkit import Chem

from .exceptions import OpenConfValueError


def write_sdf(
mol: Chem.Mol,
Expand Down Expand Up @@ -106,13 +108,13 @@ def read_xyz(input_path: str | Path) -> Chem.Mol:
Molecule with bonds and one conformer

Raises:
ValueError: XYZ file cannot be parsed
OpenConfValueError: XYZ file cannot be parsed
"""
from rdkit.Chem import rdDetermineBonds

mol = Chem.MolFromXYZFile(str(input_path))
if mol is None:
raise ValueError(f"Could not read XYZ file: {input_path}")
raise OpenConfValueError(f"Could not read XYZ file: {input_path}")

rw = Chem.RWMol(mol)
rdDetermineBonds.DetermineConnectivity(rw, useHueckel=False)
Expand Down Expand Up @@ -176,7 +178,7 @@ def read_sdf(input_path: str | Path) -> tuple[Chem.Mol, list[int], list[float]]:
energies.append(float("inf"))

if mol is None:
raise ValueError(f"No valid molecules in {input_path}")
raise OpenConfValueError(f"No valid molecules in {input_path}")

return mol, conf_ids, energies

Expand Down Expand Up @@ -207,11 +209,11 @@ def smiles_to_mol(smiles: str, add_hs: bool = True) -> Chem.Mol:
Parsed molecule

Raises:
ValueError: SMILES cannot be parsed
OpenConfValueError: SMILES cannot be parsed
"""
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError(f"Invalid SMILES: {smiles}")
raise OpenConfValueError(f"Invalid SMILES: {smiles}")

if add_hs:
mol = Chem.AddHs(mol)
Expand Down
6 changes: 4 additions & 2 deletions openconf/perceive.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from rdkit import Chem

from .exceptions import OpenConfRuntimeError


@dataclass
class Rotor:
Expand Down Expand Up @@ -288,14 +290,14 @@ def prepare_molecule(mol: Chem.Mol, add_hs: bool = True) -> Chem.Mol:
Prepared molecule with hydrogens and sanitization

Raises:
ValueError: molecule cannot be sanitized
OpenConfRuntimeError: molecule cannot be sanitized
"""
mol = Chem.Mol(mol) # Make a copy

try:
Chem.SanitizeMol(mol)
except (RuntimeError, ValueError) as e:
raise ValueError(f"Could not sanitize molecule: {e}") from e
raise OpenConfRuntimeError(f"Could not sanitize molecule: {e}") from e

# Assign stereochemistry
Chem.AssignStereochemistry(mol, cleanIt=True, force=True)
Expand Down
26 changes: 25 additions & 1 deletion openconf/propose/hybrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from rdkit.Geometry import rdGeometry

from ..config import ConformerConfig, ConstraintSpec
from ..dedupe import prism_dedupe
from ..exceptions import OpenConfValueError
from ..perceive import RotorModel, _is_metal, filter_constrained_rotors
from ..pool import ConformerPool
from ..relax import (
Expand Down Expand Up @@ -944,7 +946,7 @@ def run_hybrid_generation(
if constraint_spec is not None:
existing_ids = [c.GetId() for c in mol.GetConformers()]
if not existing_ids:
raise ValueError(
raise OpenConfValueError(
"Constrained conformer generation requires a starting conformer. "
"Use generate_conformers_from_pose to supply one."
)
Expand Down Expand Up @@ -1075,6 +1077,17 @@ def run_hybrid_generation(
)
if stats:
stats["final_refine_time_s"] = time.perf_counter() - final_refine_start

# Post-refinement dedupe: full MMFF geometry changes can merge conformers
# that were distinct at the fast-minimization stage.
energy_map_post = dict(zip(final_ids, final_energies, strict=True))
final_ids = prism_dedupe(
mol,
final_ids,
use_heavy_atoms_only=effective_config.use_heavy_atoms_only,
max_deviation=effective_config.prism_max_deviation,
)
final_energies = [energy_map_post[cid] for cid in final_ids]
else:
# return the fast-minimized energies already stored in the pool
energy_map = {
Expand Down Expand Up @@ -1170,6 +1183,17 @@ def run_low_flex_generation(
)
if stats:
stats["final_refine_time_s"] = time.perf_counter() - final_refine_start

# Post-refinement dedupe: full MMFF geometry changes can merge conformers
# that were distinct at the fast-minimization stage.
energy_map_post = dict(zip(final_ids, final_energies, strict=True))
final_ids = prism_dedupe(
mol,
final_ids,
use_heavy_atoms_only=config.use_heavy_atoms_only,
max_deviation=config.prism_max_deviation,
)
final_energies = [energy_map_post[cid] for cid in final_ids]
else:
energy_map = {
cid: (rec.energy_kcal if rec.energy_kcal is not None else float("inf")) for cid, rec in pool.records.items()
Expand Down
Loading
Loading