Skip to content

Commit b25bdd3

Browse files
Merge pull request #99 from ccdc-opensource/pharmacophore_generator
NO_JIRA Added Pharmacophore generator script to create CrossMiner que…
2 parents 3eff9d4 + a4ba091 commit b25bdd3

10 files changed

Lines changed: 1038 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Pharmacophore Query Generator
2+
3+
This tool provides the user with the ability to create pharmacophore queries from the results of a ligand overlay.
4+
5+
The pharmacophore queries are produced to be used with CrossMiner.
6+
7+
## Requirements
8+
9+
- [CSD Python API](https://downloads.ccdc.cam.ac.uk/documentation/API/) installed.
10+
- Access to CSD CrossMiner and the feature definitions (`.cpf`) files.
11+
- Access to the CSD Ligand Overlay Tool.
12+
13+
## Licensing Requirements
14+
15+
CSD-Discovery, CSD-Enterprise and Research Partner suites would all be sufficient.
16+
17+
## Instructions on Running
18+
19+
### Feature Definitions
20+
21+
The CrossMiner feature definition (`.cpf`) files are **not** shipped with this repo.
22+
Supply the location of the feature definitions from your CrossMiner installation with
23+
`-f`/`--feature_definitions`; this should be the directory containing the definition files
24+
(either directly, or in `any`/`protein`/`small_molecule` subdirectories).
25+
Usually, the location is `C:\users\<username>\CCDC\ccdc-software\csd-crossminer\feature_definitions`
26+
27+
```
28+
python main.py -i <overlay_folder> -o <output_folder> -f <feature_definitions_folder>
29+
```
30+
31+
The output folder (`-o`/`--output_folder`) is optional; if it is not supplied, the queries are
32+
written to a `queries` folder created in the current directory.
33+
34+
### Options for Ligand Overlay Output
35+
36+
* `cluster`: Cluster the similar pharmacophore features based on proximity
37+
* `projected`: Treat pharmacophore features as projected when appropriate e.g. acceptors and donors
38+
39+
There is also the option to specify a specific Ligand Overlay from all the results. If this is not specified, all
40+
overlays are used.
41+
When all overlays are used, the pharmacophore query will be a union of all the features from all the overlays.
42+
These features are then clustered based on proximity AND prevalence.
43+
44+
### Using the Queries Generated
45+
46+
If you would like to use the queries generated with this tool, they can be opened in CrossMiner to run a search.
47+
A file `crossminer_search.py` has also been provided which contains a Python function for the most simply kind of
48+
CrossMiner search.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
This script can be used for any purpose without limitation subject to the
3+
conditions at https://www.ccdc.cam.ac.uk/Community/Pages/Licences/v2.aspx
4+
This permission notice and the following statement of attribution must be
5+
included in all copies or substantial portions of this script.
6+
7+
"07/08/2026n": created by the Cambridge Crystallographic Data Centre
8+
"""
9+
10+
from collections import defaultdict
11+
12+
import numpy as np
13+
14+
from datastructures import PharmFeaturePoint
15+
16+
17+
def cluster_features(features: list[PharmFeaturePoint]) -> list[PharmFeaturePoint]:
18+
"""Cluster all features where relevant."""
19+
groups = defaultdict(list)
20+
for feature in features:
21+
groups[feature.label].append(feature)
22+
23+
clustered_features = []
24+
for label, group in groups.items():
25+
if label == 'hydrophobe':
26+
clustered_features.extend(_cluster_similar_features(group))
27+
elif label in ('acceptor_projected', 'donor_projected'):
28+
clustered_features.extend(_cluster_similar_features(group, check_vp=True))
29+
else:
30+
clustered_features.extend(group)
31+
return clustered_features
32+
33+
34+
def _cluster_similar_features(
35+
features: list[PharmFeaturePoint], cluster_radius: float = 2.0, check_vp: bool = False
36+
) -> list[PharmFeaturePoint]:
37+
"""
38+
Cluster specific features that are close to each other.
39+
For projected features, also check virtual point distances.
40+
41+
Args:
42+
features: List of features to cluster
43+
cluster_radius: Distance between features to be included in clustering (Ang)
44+
check_vp: Whether to also require virtual points to be within the radius
45+
"""
46+
if len(features) < 2:
47+
return features
48+
49+
clusters = _connected_components(
50+
features,
51+
lambda a, b: _is_close(a, b, cluster_radius, check_vp),
52+
)
53+
return [_merge_cluster(cluster, check_vp) for cluster in clusters]
54+
55+
56+
def _is_close(
57+
a: PharmFeaturePoint, b: PharmFeaturePoint, cluster_radius: float, check_vp: bool
58+
) -> bool:
59+
"""Whether two features are within ``cluster_radius`` (and, if ``check_vp``, their virtual points too)."""
60+
if np.linalg.norm(a - b) >= cluster_radius:
61+
return False
62+
if check_vp and np.linalg.norm(a.virtual_point - b.virtual_point) >= cluster_radius:
63+
return False
64+
return True
65+
66+
67+
def _connected_components(
68+
features: list[PharmFeaturePoint], is_linked
69+
) -> list[list[PharmFeaturePoint]]:
70+
"""
71+
Single-linkage grouping: features are placed in the same cluster if a chain of
72+
``is_linked`` neighbours connects them.
73+
"""
74+
unclustered = features.copy()
75+
clusters = []
76+
77+
while unclustered:
78+
cluster = [unclustered.pop(0)]
79+
# Grow the cluster breadth-first: any unclustered feature linked to a member joins it.
80+
i = 0
81+
while i < len(cluster):
82+
member = cluster[i]
83+
remaining = []
84+
for feature in unclustered:
85+
if is_linked(feature, member):
86+
cluster.append(feature)
87+
else:
88+
remaining.append(feature)
89+
unclustered = remaining
90+
i += 1
91+
clusters.append(cluster)
92+
93+
return clusters
94+
95+
96+
def _merge_cluster(cluster: list[PharmFeaturePoint], check_vp: bool) -> PharmFeaturePoint:
97+
"""Collapse a cluster into a single feature at its centroid (singletons are returned unchanged)."""
98+
if len(cluster) == 1:
99+
return cluster[0]
100+
101+
centroid = np.mean([c.coordinates for c in cluster], axis=0).round(4)
102+
vp_centroid = (
103+
np.mean([c.virtual_point for c in cluster], axis=0).round(4) if check_vp else None
104+
)
105+
return PharmFeaturePoint(centroid, label=cluster[0].label, virtual_point=vp_centroid)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
This script can be used for any purpose without limitation subject to the
3+
conditions at https://www.ccdc.cam.ac.uk/Community/Pages/Licences/v2.aspx
4+
This permission notice and the following statement of attribution must be
5+
included in all copies or substantial portions of this script.
6+
7+
"07/08/2026": created by the Cambridge Crystallographic Data Centre
8+
"""
9+
10+
from pathlib import Path
11+
12+
from ccdc.pharmacophore import Pharmacophore
13+
14+
15+
def search(query_file: Path, database_file: Path):
16+
"""
17+
This is here as an example for users to perform a simple CrossMiner
18+
pharmacophore search using the CCDC Python API.
19+
It is not used in the main workflow.
20+
"""
21+
settings = Pharmacophore.Search.Settings()
22+
settings.max_hit_structures = 20
23+
settings.max_hits_per_structure = 1
24+
settings.max_hit_rmsd = 1.0
25+
searcher = Pharmacophore.Search(settings)
26+
feature_db = Pharmacophore.FeatureDatabase.from_file(database_file)
27+
query = Pharmacophore.Query.from_file(str(query_file))
28+
hits = searcher.search(
29+
model=query,
30+
database=feature_db,
31+
verbose=True,
32+
)
33+
return hits
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
This script can be used for any purpose without limitation subject to the
3+
conditions at https://www.ccdc.cam.ac.uk/Community/Pages/Licences/v2.aspx
4+
This permission notice and the following statement of attribution must be
5+
included in all copies or substantial portions of this script.
6+
7+
"07/08/2026": created by the Cambridge Crystallographic Data Centre
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass
13+
from pathlib import Path
14+
from typing import Iterable, Optional
15+
16+
import numpy as np
17+
18+
19+
@dataclass
20+
class FeatureTolerances:
21+
"""
22+
Allowed feature types and their tolerances.
23+
24+
Each field is a valid feature label; its value is the tolerance:
25+
a single weight, or a (parent, virtual_point) pair for projected features.
26+
"""
27+
acceptor: float = 1.0
28+
acceptor_projected: tuple[float, float] = (0.8, 0.8)
29+
donor_projected: tuple[float, float] = (1.0, 1.0)
30+
hydrophobe: float = 1.0
31+
ring_planar_projected: tuple[float, float] = (1.0, 1.0)
32+
ring_non_planar: tuple[float, float] = (1.0, 1.0)
33+
halogen: float = 1.0
34+
35+
def __getitem__(self, key: str) -> float | tuple[float, float]:
36+
return getattr(self, key)
37+
38+
39+
class PharmFeaturePoint(np.ndarray):
40+
def __new__(
41+
cls,
42+
*coordinates: float | Iterable[float],
43+
label: Optional[str] = None,
44+
virtual_point: Optional[np.ndarray] = None,
45+
):
46+
if len(coordinates) == 1:
47+
arr = np.asarray(coordinates[0], dtype=float)
48+
elif len(coordinates) == 3:
49+
arr = np.asarray(coordinates, dtype=float)
50+
else:
51+
raise TypeError("Coordinates must be either an iterable of length three, or three floats")
52+
if arr.shape != (3,):
53+
raise ValueError("Coordinates must be a 3-element array")
54+
obj = arr.view(cls)
55+
obj.label = label
56+
obj.virtual_point = virtual_point
57+
return obj
58+
59+
def __array_finalize__(self, obj):
60+
if obj is None:
61+
return
62+
self.label = getattr(obj, 'label', None)
63+
self.virtual_point = getattr(obj, 'virtual_point', None)
64+
65+
def __repr__(self) -> str:
66+
return (
67+
f"PharmFeaturePoint({self.x}, {self.y}, {self.z}, "
68+
f"label={self.label}, virtual_point={self.virtual_point}), "
69+
)
70+
71+
def __str__(self) -> str:
72+
return self.__repr__()
73+
74+
@property
75+
def coordinates(self) -> np.ndarray:
76+
return np.asarray(self)
77+
78+
@property
79+
def x(self):
80+
return self[0]
81+
82+
@property
83+
def y(self):
84+
return self[1]
85+
86+
@property
87+
def z(self):
88+
return self[2]
89+
90+
@property
91+
def tolerance(self) -> float | tuple[float, float]:
92+
"""
93+
Get the tolerance for the feature based on its label.
94+
Returns:
95+
A single tolerance for features with a single tolerance or a tuple for those with two tolerances.
96+
"""
97+
if self.label is None:
98+
raise LookupError("Feature label must be set to determine tolerances.")
99+
return FeatureTolerances()[self.label]
100+
101+
@property
102+
def weight_parent(self) -> float:
103+
if isinstance(self.tolerance, float):
104+
return self.tolerance
105+
elif isinstance(self.tolerance, tuple):
106+
return self.tolerance[0]
107+
else:
108+
raise ValueError("Incorrect tolerances loaded for feature point.")
109+
110+
@property
111+
def weight_vp(self) -> float:
112+
return self.tolerance[1]
113+
114+
115+
116+
@dataclass
117+
class OverlayData:
118+
input_folder: Path
119+
output_folder: Path
120+
# Pharmacophore file from the pharmacophores folder
121+
pharm_file: Path
122+
# Overlay solution file (the chosen one from the many produced)
123+
overlay_file: Path
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""
2+
This script can be used for any purpose without limitation subject to the
3+
conditions at https://www.ccdc.cam.ac.uk/Community/Pages/Licences/v2.aspx
4+
This permission notice and the following statement of attribution must be
5+
included in all copies or substantial portions of this script.
6+
7+
"07/08/2026": created by the Cambridge Crystallographic Data Centre
8+
"""
9+
10+
import argparse
11+
from pathlib import Path
12+
13+
from cluster import cluster_features
14+
from datastructures import OverlayData
15+
from overlay import OverlayToPharmFeatures
16+
from write_query import FeaturesToCrossMinerQuery
17+
18+
19+
def str_to_bool(value: str) -> bool:
20+
return value.lower() in {'t', 'true', '1', 'yes', 'y'}
21+
22+
23+
def parse_args():
24+
parser = argparse.ArgumentParser(
25+
description="Create Pharmacophore Features from a Ligand Overlay"
26+
)
27+
parser.add_argument('-i', '--input_folder', type=str, required=True,
28+
help='Input file(s) path.')
29+
parser.add_argument('-o', '--output_folder', type=str, default=None,
30+
help="Output folder path. Defaults to a 'queries' folder in the current directory.")
31+
parser.add_argument('-f', '--feature_definitions', type=str, required=True,
32+
help='Path to the directory containing the CrossMiner feature definition (.cpf) files.')
33+
parser.add_argument('-c', '--cluster', type=str_to_bool, default=False,
34+
help='Cluster features if they are close together or common across multiple inputs.')
35+
parser.add_argument('-p', '--projected', type=str_to_bool, default=False,
36+
help='Use projected acceptor features or point features.')
37+
parser.add_argument('-id', '--overlay_id', type=int, default=0,
38+
help='Overlay ID to process. If 0 or not specified, all overlays will be processed.')
39+
40+
return parser.parse_args()
41+
42+
43+
def main():
44+
args = parse_args()
45+
46+
input_folder = Path(args.input_folder)
47+
if not input_folder.exists():
48+
raise FileNotFoundError(f"Input folder {input_folder} does not exist.")
49+
50+
feature_definitions = Path(args.feature_definitions)
51+
if not feature_definitions.is_dir():
52+
raise FileNotFoundError(f"Feature definitions folder {feature_definitions} does not exist.")
53+
54+
output_folder = Path(args.output_folder) if args.output_folder else Path('queries')
55+
output_folder.mkdir(parents=True, exist_ok=True)
56+
57+
if (args.overlay_id == 0) or (args.overlay_id is None):
58+
overlay_files = sorted(input_folder.glob('solution_*.mol2'))
59+
pharm_files = sorted(input_folder.glob('pharmacophores/solution_pharm_*.mol2'))
60+
else:
61+
overlay_files = [input_folder / f'solution_{args.overlay_id:02}.mol2']
62+
pharm_files = [input_folder / f'pharmacophores/solution_pharm_{args.overlay_id:02}.mol2']
63+
feature_sets = []
64+
for pharm_file, overlay_file in zip(pharm_files, overlay_files):
65+
overlay_data = OverlayData(
66+
input_folder=input_folder,
67+
output_folder=output_folder,
68+
pharm_file=pharm_file,
69+
overlay_file=overlay_file
70+
)
71+
feature_sets.append(OverlayToPharmFeatures(overlay_data, projected=args.projected).features)
72+
73+
for i, feature_set in enumerate(feature_sets, 1):
74+
if args.cluster:
75+
feature_set = cluster_features(feature_set)
76+
77+
query = FeaturesToCrossMinerQuery(
78+
pharm_feature_points=feature_set,
79+
feature_definitions=feature_definitions,
80+
output_file=output_folder / f'features_{i}.cm',
81+
)
82+
query.write_feature_file()
83+
84+
85+
if __name__ == '__main__':
86+
main()

0 commit comments

Comments
 (0)