caddforge-descriptors 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ """CADDForge descriptors tool package."""
2
+
3
+ from .ani_conformers import AniConformersConfig, AniConformersWorkflow
4
+ from .ani_psa.pipeline import (
5
+ boltzmann_averaged_psa,
6
+ boltzmann_weights_from_energies,
7
+ compute_3d_psa_for_conformer,
8
+ compute_3d_psa_for_conformers,
9
+ pipeline_boltzmann_3d_psa,
10
+ )
11
+ from .ani_utils import (
12
+ ConformerSettings,
13
+ ani_energies_for_conformers,
14
+ cluster_conformers,
15
+ generate_conformers,
16
+ seed_everything,
17
+ select_top_n_by_ani,
18
+ )
19
+ from .ani_psa.workflow import AniPsaConfig, AniPsaWorkflow
20
+ from .cluster_molecules import ClusterMoleculesConfig, ClusterMoleculesWorkflow
21
+ from .phesa import PhesaConfig, PhesaWorkflow
22
+ from .tool import AniConformersTool, AniPsaTool, ClusterMoleculesTool, PhesaTool
23
+
24
+ __all__ = [
25
+ "AniPsaConfig",
26
+ "AniPsaWorkflow",
27
+ "AniConformersConfig",
28
+ "AniConformersWorkflow",
29
+ "AniPsaTool",
30
+ "AniConformersTool",
31
+ "ClusterMoleculesTool",
32
+ "PhesaTool",
33
+ "ClusterMoleculesConfig",
34
+ "ClusterMoleculesWorkflow",
35
+ "PhesaConfig",
36
+ "PhesaWorkflow",
37
+ "ConformerSettings",
38
+ "ani_energies_for_conformers",
39
+ "boltzmann_averaged_psa",
40
+ "boltzmann_weights_from_energies",
41
+ "cluster_conformers",
42
+ "compute_3d_psa_for_conformer",
43
+ "compute_3d_psa_for_conformers",
44
+ "generate_conformers",
45
+ "pipeline_boltzmann_3d_psa",
46
+ "seed_everything",
47
+ "select_top_n_by_ani",
48
+ ]
@@ -0,0 +1,11 @@
1
+ """ANI-driven conformer generation and ranking."""
2
+
3
+ from .pipeline import extract_conformer_mol, pipeline_ranked_conformers
4
+ from .workflow import AniConformersConfig, AniConformersWorkflow
5
+
6
+ __all__ = [
7
+ "AniConformersConfig",
8
+ "AniConformersWorkflow",
9
+ "extract_conformer_mol",
10
+ "pipeline_ranked_conformers",
11
+ ]
@@ -0,0 +1,72 @@
1
+ """ANI re-ranked conformer generation pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ import logging
7
+ from rdkit import Chem
8
+ import torch
9
+
10
+ from cadd_descriptors.ani_utils import (
11
+ ConformerSettings,
12
+ cluster_conformers,
13
+ generate_conformers,
14
+ mmff_energies_for_conformers,
15
+ seed_everything,
16
+ select_top_n_by_ani,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ __all__ = [
22
+ "extract_conformer_mol",
23
+ "pipeline_ranked_conformers",
24
+ ]
25
+
26
+
27
+ def extract_conformer_mol(mol: Chem.Mol, conf_id: int) -> Chem.Mol:
28
+ """Create a molecule copy containing only the specified conformer."""
29
+ mol_copy = Chem.Mol(mol)
30
+ mol_copy.RemoveAllConformers()
31
+ conf = Chem.Conformer(mol.GetConformer(conf_id))
32
+ mol_copy.AddConformer(conf, assignId=True)
33
+ return mol_copy
34
+
35
+
36
+ def pipeline_ranked_conformers(
37
+ mol: Chem.Mol,
38
+ settings: ConformerSettings | None = None,
39
+ *,
40
+ device: torch.device | None = None,
41
+ model_name: str = "ANI2x",
42
+ ) -> dict[str, Any]:
43
+ """Run the ANI re-ranked conformer pipeline for a single molecule."""
44
+ seed_everything()
45
+ if settings is None:
46
+ settings = ConformerSettings()
47
+
48
+ mol = generate_conformers(mol, settings)
49
+ logger.info("Generated %d conformers", mol.GetNumConformers())
50
+
51
+ logger.info("Clustering conformers...")
52
+ clusters, rep_conf_ids = cluster_conformers(mol, settings.rmsd_cluster_thresh)
53
+ logger.info(" %d clusters; using %d representatives", len(clusters), len(rep_conf_ids))
54
+
55
+ logger.info("Re-ranking with TorchANI...")
56
+ top_conf_ids, top_energies_hartree = select_top_n_by_ani(
57
+ mol,
58
+ rep_conf_ids,
59
+ settings,
60
+ device=device,
61
+ model_name=model_name,
62
+ )
63
+
64
+ rdkit_energies = mmff_energies_for_conformers(mol, top_conf_ids)
65
+ return {
66
+ "mol": mol,
67
+ "clusters": clusters,
68
+ "rep_conf_ids": rep_conf_ids,
69
+ "top_conf_ids": top_conf_ids,
70
+ "top_ani_energies_hartree": top_energies_hartree,
71
+ "top_rdkit_energies_kcal_mol": rdkit_energies,
72
+ }
@@ -0,0 +1,390 @@
1
+ """Workflow and config for ANI re-ranked conformer generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from uuid import uuid4
11
+
12
+ from pydantic import Field, model_validator
13
+ from rdkit import Chem
14
+ import torch
15
+
16
+ from caddforge.config import BaseConfig
17
+ from caddforge.core.job import Job, JobStatus
18
+ from caddforge.core.job_registry import record_job, update_status
19
+ from caddforge.core.result import Result
20
+ from caddforge.core.workflow import Workflow
21
+
22
+ from cadd_descriptors.ani_utils import ConformerSettings, mol_from_smiles
23
+ from cadd_descriptors.inputs import load_smiles_file, load_structure_file, records_from_smiles_list
24
+
25
+ from .pipeline import extract_conformer_mol, pipeline_ranked_conformers
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class AniConformersConfig(BaseConfig):
31
+ """Validated configuration for ANI re-ranked conformer generation."""
32
+
33
+ @classmethod
34
+ def from_smiles(cls, smiles: str, **kwargs: Any) -> "AniConformersConfig":
35
+ """Create a config from a single SMILES while keeping other defaults."""
36
+ return cls(smiles=smiles, **kwargs)
37
+
38
+ @classmethod
39
+ def from_smiles_list(cls, smiles_list: list[str], **kwargs: Any) -> "AniConformersConfig":
40
+ """Create a config from an inline list of SMILES."""
41
+ return cls(smiles_list=smiles_list, **kwargs)
42
+
43
+ @classmethod
44
+ def from_smiles_file(cls, smiles_file: Path, **kwargs: Any) -> "AniConformersConfig":
45
+ """Create a config from a SMILES file (headerless or with a column)."""
46
+ return cls(smiles_file=smiles_file, **kwargs)
47
+
48
+ @classmethod
49
+ def from_structure_file(cls, structure_file: Path, **kwargs: Any) -> "AniConformersConfig":
50
+ """Create a config from an SDF file."""
51
+ return cls(structure_file=structure_file, **kwargs)
52
+
53
+ smiles: str | None = Field(None, description="Single SMILES string to evaluate.")
54
+ smiles_list: list[str] | None = Field(
55
+ default=None,
56
+ description="Inline list of SMILES strings to evaluate as a batch.",
57
+ )
58
+ smiles_file: Path | None = Field(
59
+ default=None,
60
+ description="Path to a file containing SMILES (one per line, or delimited with a column).",
61
+ )
62
+ structure_file: Path | None = Field(
63
+ default=None,
64
+ description="Path to an SDF file containing molecules.",
65
+ )
66
+ smiles_column: str = Field(
67
+ default="smiles",
68
+ description="Column name to read from a delimited SMILES file (CSV/TSV).",
69
+ )
70
+ num_confs: int = Field(500, ge=1, description="Number of conformers to generate.")
71
+ rmsd_cluster_thresh: float = Field(
72
+ 0.5, gt=0, description="RMSD threshold (Angstrom) for Butina clustering."
73
+ )
74
+ max_mmff_iters: int = Field(
75
+ 200, ge=1, description="MMFF optimization steps per conformer."
76
+ )
77
+ top_n: int = Field(50, ge=1, description="Top-N ANI conformers to keep.")
78
+ model_name: str = Field(
79
+ "ANI2x",
80
+ description="TorchANI model name to use (e.g., ANI2x).",
81
+ )
82
+ device: str | None = Field(
83
+ default="cpu",
84
+ description="Torch device string (cpu, cuda, cuda:0). Auto-selects if None.",
85
+ )
86
+ output_sdf_name: str = Field(
87
+ default="ranked_conformers.sdf",
88
+ description="Filename for the output SDF containing ranked conformers.",
89
+ )
90
+
91
+ @model_validator(mode="after")
92
+ def _validate_inputs(self) -> "AniConformersConfig":
93
+ """Ensure exactly one input source is provided."""
94
+ provided = [
95
+ bool(self.smiles),
96
+ bool(self.smiles_list),
97
+ bool(self.smiles_file),
98
+ bool(self.structure_file),
99
+ ]
100
+ if sum(provided) != 1:
101
+ raise ValueError(
102
+ "Provide exactly one of: smiles, smiles_list, smiles_file, structure_file."
103
+ )
104
+ return self
105
+
106
+ def to_conformer_settings(self) -> ConformerSettings:
107
+ """Convert validated config into ConformerSettings."""
108
+ return ConformerSettings(
109
+ num_confs=self.num_confs,
110
+ rmsd_cluster_thresh=self.rmsd_cluster_thresh,
111
+ max_mmff_iters=self.max_mmff_iters,
112
+ top_n=self.top_n,
113
+ )
114
+
115
+ def resolved_inputs(self) -> list[dict[str, Any]]:
116
+ """Materialize the configured inputs into a list of records."""
117
+ if self.smiles:
118
+ return [{"smiles": self.smiles, "name": None, "mol": None}]
119
+ if self.smiles_list:
120
+ records = records_from_smiles_list(self.smiles_list)
121
+ return [{**rec, "mol": None} for rec in records]
122
+ if self.smiles_file:
123
+ records = load_smiles_file(self.smiles_file, self.smiles_column)
124
+ return [{**rec, "mol": None} for rec in records]
125
+ if self.structure_file:
126
+ return load_structure_file(self.structure_file)
127
+ raise ValueError("No inputs resolved.")
128
+
129
+ def input_source(self) -> str:
130
+ """Human-readable description of where inputs were loaded from."""
131
+ if self.smiles:
132
+ return "inline_smiles"
133
+ if self.smiles_list:
134
+ return "inline_smiles_list"
135
+ if self.smiles_file:
136
+ return f"file:{self.smiles_file}"
137
+ if self.structure_file:
138
+ return f"file:{self.structure_file}"
139
+ return "unknown"
140
+
141
+ @classmethod
142
+ def example_dict(cls) -> dict[str, Any]:
143
+ """Provide richer examples for batch and single-molecule usage."""
144
+ example = super().example_dict()
145
+ example.update(
146
+ {
147
+ "smiles": "CCO",
148
+ "smiles_list": ["CCO", "CCN"],
149
+ "smiles_file": "batch.smi",
150
+ "structure_file": "batch.sdf",
151
+ "smiles_column": "smiles",
152
+ }
153
+ )
154
+ return example
155
+
156
+
157
+ class AniConformersWorkflow(Workflow):
158
+ """Workflow that generates ANI re-ranked conformers."""
159
+
160
+ tool_name = "ani-conformers"
161
+ section = "descriptors"
162
+
163
+ def __init__(self, config: AniConformersConfig, **_compat):
164
+ self.config = config
165
+ self._resolved_inputs: list[dict[str, Any]] | None = None
166
+
167
+ def _default_run_directory(self, job_id: str) -> Path:
168
+ if self.config.run_directory:
169
+ return Path(self.config.run_directory)
170
+ date_folder = datetime.now().astimezone().date().isoformat()
171
+ return Path("runs") / date_folder / job_id
172
+
173
+ def _input_records(self) -> list[dict[str, Any]]:
174
+ if self._resolved_inputs is None:
175
+ self._resolved_inputs = self.config.resolved_inputs()
176
+ return self._resolved_inputs
177
+
178
+ def prepare_job(self) -> Job:
179
+ job_id = uuid4().hex
180
+ run_dir = self._default_run_directory(job_id)
181
+ run_dir.mkdir(parents=True, exist_ok=True)
182
+
183
+ job = Job(
184
+ id=job_id,
185
+ tool_name=self.tool_name,
186
+ run_directory=run_dir,
187
+ metadata={
188
+ "input_source": self.config.input_source(),
189
+ "model_name": self.config.model_name,
190
+ "section": self.section,
191
+ },
192
+ )
193
+
194
+ self._write_config(run_dir)
195
+ job.save_metadata(run_dir / "metadata.json")
196
+ record_job(job, registry_path=self.config.registry_path)
197
+ return job
198
+
199
+ def _write_config(self, run_dir: Path) -> None:
200
+ config_path = run_dir / "config.json"
201
+ config_path.write_text(
202
+ json.dumps(self.config.model_dump(), indent=2, default=str),
203
+ encoding="utf-8",
204
+ )
205
+
206
+ def _configure_logging(self, run_dir: Path) -> None:
207
+ log_path = run_dir / "stdout.log"
208
+ handler = logging.FileHandler(log_path, mode="w", encoding="utf-8")
209
+ formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
210
+ handler.setFormatter(formatter)
211
+
212
+ tool_logger = logging.getLogger("cadd_descriptors.ani_conformers")
213
+ tool_logger.setLevel(logging.INFO)
214
+ tool_logger.handlers.clear()
215
+ tool_logger.addHandler(handler)
216
+ tool_logger.propagate = False
217
+
218
+ def _write_ranked_conformers(
219
+ self,
220
+ writer: Chem.SDWriter,
221
+ mol: Chem.Mol,
222
+ smiles: str,
223
+ name: str | None,
224
+ top_conf_ids: list[int],
225
+ ani_energies: list[float],
226
+ ani_energies_kcal: list[float],
227
+ rdkit_energies: list[float],
228
+ ) -> None:
229
+ for rank, (cid, ani_energy, rdkit_energy) in enumerate(
230
+ zip(top_conf_ids, ani_energies, rdkit_energies), start=1
231
+ ):
232
+ conf_mol = extract_conformer_mol(mol, cid)
233
+ conf_mol.SetProp("source_smiles", smiles)
234
+ if name:
235
+ conf_mol.SetProp("source_name", name)
236
+ conf_mol.SetProp("conformer_rank", str(rank))
237
+ conf_mol.SetProp("conf_id", str(cid))
238
+ conf_mol.SetProp("ani_energy_hartree", f"{ani_energy:.10f}")
239
+ conf_mol.SetProp("ani_energy_kcal_mol", f"{ani_energies_kcal[rank - 1]:.6f}")
240
+ conf_mol.SetProp("rdkit_energy_kcal_mol", f"{rdkit_energy:.6f}")
241
+ writer.write(conf_mol)
242
+
243
+ def run(self, job: Job) -> Result:
244
+ run_dir = job.run_directory or self._default_run_directory(job.id)
245
+ run_dir.mkdir(parents=True, exist_ok=True)
246
+ self._configure_logging(run_dir)
247
+ started_at = datetime.now().astimezone()
248
+ job.started_at = started_at
249
+ update_status(
250
+ job.id,
251
+ JobStatus.RUNNING,
252
+ started_at=started_at,
253
+ registry_path=self.config.registry_path,
254
+ )
255
+
256
+ results_dir = run_dir / "results"
257
+ results_dir.mkdir(parents=True, exist_ok=True)
258
+ results_json_path = results_dir / "results.json"
259
+ output_sdf_path = results_dir / self.config.output_sdf_name
260
+
261
+ inputs = self._input_records()
262
+ job.metadata.update(
263
+ {
264
+ "num_inputs": len(inputs),
265
+ "smiles_preview": [record["smiles"] for record in inputs[:3]],
266
+ }
267
+ )
268
+ job.save_metadata(run_dir / "metadata.json")
269
+ molecule_results: list[dict[str, Any]] = []
270
+ failures: list[dict[str, str]] = []
271
+ result_obj: Result | None = None
272
+ error: Exception | None = None
273
+
274
+ try:
275
+ settings = self.config.to_conformer_settings()
276
+ device = torch.device(self.config.device) if self.config.device else None
277
+
278
+ writer = Chem.SDWriter(str(output_sdf_path))
279
+ try:
280
+ for idx, record in enumerate(inputs, start=1):
281
+ smiles = record.get("smiles") or ""
282
+ name = record.get("name")
283
+ mol_input = record.get("mol")
284
+ try:
285
+ mol = mol_input if isinstance(mol_input, Chem.Mol) else mol_from_smiles(smiles)
286
+ if not smiles:
287
+ smiles = Chem.MolToSmiles(mol)
288
+ pipeline_result = pipeline_ranked_conformers(
289
+ mol,
290
+ settings=settings,
291
+ device=device,
292
+ model_name=self.config.model_name,
293
+ )
294
+ top_conf_ids = pipeline_result["top_conf_ids"]
295
+ top_ani = pipeline_result["top_ani_energies_hartree"]
296
+ top_rdkit = pipeline_result["top_rdkit_energies_kcal_mol"]
297
+ hartree_to_kcal = 627.509474
298
+ top_ani_kcal = [float(val) * hartree_to_kcal for val in top_ani]
299
+ self._write_ranked_conformers(
300
+ writer,
301
+ pipeline_result["mol"],
302
+ smiles,
303
+ name,
304
+ top_conf_ids,
305
+ [float(val) for val in top_ani],
306
+ top_ani_kcal,
307
+ [float(val) for val in top_rdkit],
308
+ )
309
+ molecule_results.append(
310
+ {
311
+ "index": idx,
312
+ "smiles": smiles,
313
+ "name": name,
314
+ "num_conformers_written": len(top_conf_ids),
315
+ "top_conf_ids": [int(cid) for cid in top_conf_ids],
316
+ "top_ani_energies_hartree": [float(val) for val in top_ani],
317
+ "top_ani_energies_kcal_mol": top_ani_kcal,
318
+ "top_rdkit_energies_kcal_mol": [float(val) for val in top_rdkit],
319
+ "num_clusters": len(pipeline_result["clusters"]),
320
+ "num_representatives": len(pipeline_result["rep_conf_ids"]),
321
+ }
322
+ )
323
+ except Exception as exc: # pragma: no cover - per-molecule guard
324
+ logger.exception("Conformer workflow failed for %s: %s", smiles, exc)
325
+ failures.append({"smiles": smiles, "error": str(exc)})
326
+ finally:
327
+ writer.close()
328
+
329
+ status = JobStatus.COMPLETED if not failures else JobStatus.FAILED
330
+ metrics: dict[str, float | int | str] = {
331
+ "molecules_succeeded": len(molecule_results),
332
+ "molecules_failed": len(failures),
333
+ }
334
+ if molecule_results:
335
+ avg_written = sum(entry["num_conformers_written"] for entry in molecule_results) / len(
336
+ molecule_results
337
+ )
338
+ metrics["mean_conformers_written"] = float(avg_written)
339
+
340
+ results_payload = {"molecules": molecule_results, "failures": failures}
341
+ results_json_path.write_text(json.dumps(results_payload, indent=2), encoding="utf-8")
342
+
343
+ paths = {"results_json": results_json_path, "ranked_conformers_sdf": output_sdf_path}
344
+ result_obj = Result(
345
+ job_id=job.id,
346
+ tool_name=self.tool_name,
347
+ status=status,
348
+ metrics=metrics,
349
+ paths=paths,
350
+ extra=results_payload,
351
+ )
352
+ except Exception as exc: # pragma: no cover - defensive guard
353
+ error = exc
354
+ logger.exception("Conformer workflow failed: %s", exc)
355
+ status = JobStatus.FAILED
356
+ result_obj = Result(
357
+ job_id=job.id,
358
+ tool_name=self.tool_name,
359
+ status=status,
360
+ metrics={},
361
+ paths={},
362
+ extra={"error": str(exc)},
363
+ )
364
+
365
+ job.metadata.update(
366
+ {
367
+ "num_inputs": len(inputs),
368
+ "molecules_succeeded": len(molecule_results),
369
+ "molecules_failed": len(failures),
370
+ }
371
+ )
372
+
373
+ finished_at = datetime.now().astimezone()
374
+ job.finished_at = finished_at
375
+ job.status = status
376
+ job.save_metadata(run_dir / "metadata.json")
377
+ update_status(
378
+ job.id,
379
+ status,
380
+ finished_at=finished_at,
381
+ registry_path=self.config.registry_path,
382
+ )
383
+
384
+ if result_obj is not None:
385
+ result_obj.save(results_dir / "result_record.json")
386
+
387
+ if error:
388
+ raise error
389
+
390
+ return result_obj
@@ -0,0 +1,32 @@
1
+ """ANI-weighted PSA descriptor implementation."""
2
+
3
+ from .pipeline import (
4
+ ConformerSettings,
5
+ ani_energies_for_conformers,
6
+ boltzmann_averaged_psa,
7
+ boltzmann_weights_from_energies,
8
+ cluster_conformers,
9
+ compute_3d_psa_for_conformer,
10
+ compute_3d_psa_for_conformers,
11
+ generate_conformers,
12
+ pipeline_boltzmann_3d_psa,
13
+ seed_everything,
14
+ select_top_n_by_ani,
15
+ )
16
+ from .workflow import AniPsaConfig, AniPsaWorkflow
17
+
18
+ __all__ = [
19
+ "AniPsaConfig",
20
+ "AniPsaWorkflow",
21
+ "ConformerSettings",
22
+ "ani_energies_for_conformers",
23
+ "boltzmann_averaged_psa",
24
+ "boltzmann_weights_from_energies",
25
+ "cluster_conformers",
26
+ "compute_3d_psa_for_conformer",
27
+ "compute_3d_psa_for_conformers",
28
+ "generate_conformers",
29
+ "pipeline_boltzmann_3d_psa",
30
+ "seed_everything",
31
+ "select_top_n_by_ani",
32
+ ]