diff --git a/README.md b/README.md index d594563..5ce6c4c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/openconf/__init__.py b/openconf/__init__.py index 9c3c8ec..c1c5c63 100644 --- a/openconf/__init__.py +++ b/openconf/__init__.py @@ -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 @@ -19,6 +20,9 @@ "ConformerPreset", "ConformerRecord", "ConstraintSpec", + "OpenConfError", + "OpenConfRuntimeError", + "OpenConfValueError", "RDKitMMFFMinimizer", "Rotor", "RotorModel", diff --git a/openconf/api.py b/openconf/api.py index 23671cd..3f69e46 100644 --- a/openconf/api.py +++ b/openconf/api.py @@ -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, @@ -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 @@ -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 @@ -158,11 +159,11 @@ 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], @@ -170,7 +171,7 @@ def boltzmann_weights(self, temperature: float = 298.15) -> np.ndarray: ) 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) @@ -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) @@ -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: @@ -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 @@ -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) @@ -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: @@ -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." ) diff --git a/openconf/config.py b/openconf/config.py index 403f9bd..496d2d7 100644 --- a/openconf/config.py +++ b/openconf/config.py @@ -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"] @@ -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: @@ -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 @@ -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: @@ -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") @@ -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'." ) diff --git a/openconf/exceptions.py b/openconf/exceptions.py new file mode 100644 index 0000000..8b444ac --- /dev/null +++ b/openconf/exceptions.py @@ -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.""" diff --git a/openconf/io.py b/openconf/io.py index 9964488..d821308 100644 --- a/openconf/io.py +++ b/openconf/io.py @@ -5,6 +5,8 @@ from rdkit import Chem +from .exceptions import OpenConfValueError + def write_sdf( mol: Chem.Mol, @@ -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) @@ -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 @@ -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) diff --git a/openconf/perceive.py b/openconf/perceive.py index 25eee6e..548986c 100644 --- a/openconf/perceive.py +++ b/openconf/perceive.py @@ -5,6 +5,8 @@ from rdkit import Chem +from .exceptions import OpenConfRuntimeError + @dataclass class Rotor: @@ -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) diff --git a/openconf/propose/hybrid.py b/openconf/propose/hybrid.py index 426f3f6..8c57f56 100644 --- a/openconf/propose/hybrid.py +++ b/openconf/propose/hybrid.py @@ -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 ( @@ -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." ) @@ -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 = { @@ -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() diff --git a/openconf/relax.py b/openconf/relax.py index 07a436a..f812d37 100644 --- a/openconf/relax.py +++ b/openconf/relax.py @@ -6,6 +6,8 @@ from rdkit import Chem from rdkit.Chem import AllChem +from .exceptions import OpenConfValueError + _METAL_POSITION_FORCE_CONSTANT = 1e4 _METAL_LIGAND_DISTANCE_TOLERANCE = 0.05 _METAL_LIGAND_DISTANCE_FORCE_CONSTANT = 100000.0 @@ -220,8 +222,8 @@ def get_minimizer(name: str = "rdkit_mmff", metal_atom_indices: frozenset[int] = Minimizer instance Raises: - ValueError: unknown minimizer name + OpenConfValueError: unknown minimizer name """ if name == "rdkit_mmff": return RDKitMMFFMinimizer(metal_atom_indices=metal_atom_indices, **kwargs) - raise ValueError(f"Unknown minimizer: {name}") + raise OpenConfValueError(f"Unknown minimizer: {name}") diff --git a/openconf/torsionlib.py b/openconf/torsionlib.py index 34e7735..259a4f3 100644 --- a/openconf/torsionlib.py +++ b/openconf/torsionlib.py @@ -9,6 +9,8 @@ from rdkit import Chem +from .exceptions import OpenConfValueError + @dataclass class TorsionRule: @@ -32,7 +34,7 @@ def __post_init__(self) -> None: self.weights = [1.0] * len(self.angles_deg) if len(self.weights) != len(self.angles_deg): msg = f"weights length ({len(self.weights)}) must match angles_deg length ({len(self.angles_deg)})" - raise ValueError(msg) + raise OpenConfValueError(msg) def _load_default_rules() -> list[TorsionRule]: diff --git a/uv.lock b/uv.lock index 2147439..d2c5ea0 100644 --- a/uv.lock +++ b/uv.lock @@ -260,7 +260,7 @@ wheels = [ [[package]] name = "openconf" -version = "0.0.15" +version = "0.0.16" source = { editable = "." } dependencies = [ { name = "numpy" },