molify 0.0.1a0__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.
- molify/__init__.py +41 -0
- molify/ase2x.py +220 -0
- molify/bond_order.py +110 -0
- molify/com.py +75 -0
- molify/compress.py +73 -0
- molify/networkx2x.py +187 -0
- molify/pack.py +263 -0
- molify/rdkit2x.py +125 -0
- molify/smiles2x.py +149 -0
- molify/substructure.py +503 -0
- molify/utils.py +255 -0
- molify-0.0.1a0.dist-info/METADATA +75 -0
- molify-0.0.1a0.dist-info/RECORD +15 -0
- molify-0.0.1a0.dist-info/WHEEL +4 -0
- molify-0.0.1a0.dist-info/licenses/LICENSE +201 -0
molify/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from molify.ase2x import ase2networkx, ase2rdkit
|
|
2
|
+
from molify.com import get_centers_of_mass
|
|
3
|
+
from molify.compress import compress
|
|
4
|
+
from molify.networkx2x import networkx2ase, networkx2rdkit
|
|
5
|
+
from molify.pack import pack
|
|
6
|
+
from molify.rdkit2x import rdkit2ase, rdkit2networkx
|
|
7
|
+
from molify.smiles2x import smiles2atoms, smiles2conformers
|
|
8
|
+
from molify.substructure import (
|
|
9
|
+
get_substructures,
|
|
10
|
+
iter_fragments,
|
|
11
|
+
match_substructure,
|
|
12
|
+
select_atoms_flat_unique,
|
|
13
|
+
select_atoms_grouped,
|
|
14
|
+
visualize_selected_molecules,
|
|
15
|
+
)
|
|
16
|
+
from molify.utils import unwrap_structures
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ase2rdkit",
|
|
20
|
+
"smiles2atoms",
|
|
21
|
+
"pack",
|
|
22
|
+
"smiles2conformers",
|
|
23
|
+
"compress",
|
|
24
|
+
"match_substructure",
|
|
25
|
+
"get_substructures",
|
|
26
|
+
"iter_fragments",
|
|
27
|
+
"select_atoms_flat_unique",
|
|
28
|
+
"visualize_selected_molecules",
|
|
29
|
+
"unwrap_structures",
|
|
30
|
+
"select_atoms_grouped",
|
|
31
|
+
#
|
|
32
|
+
"ase2networkx",
|
|
33
|
+
#
|
|
34
|
+
"rdkit2networkx",
|
|
35
|
+
"rdkit2ase",
|
|
36
|
+
#
|
|
37
|
+
"networkx2rdkit",
|
|
38
|
+
"networkx2ase",
|
|
39
|
+
#
|
|
40
|
+
"get_centers_of_mass",
|
|
41
|
+
]
|
molify/ase2x.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import ase
|
|
2
|
+
import networkx as nx
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ase.neighborlist import natural_cutoffs, neighbor_list
|
|
5
|
+
from rdkit import Chem
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import vesin
|
|
9
|
+
except ImportError:
|
|
10
|
+
vesin = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _create_graph_from_connectivity(
|
|
14
|
+
atoms: ase.Atoms, connectivity, charges
|
|
15
|
+
) -> nx.Graph:
|
|
16
|
+
"""Create NetworkX graph from explicit connectivity information."""
|
|
17
|
+
graph = nx.Graph()
|
|
18
|
+
graph.graph["pbc"] = atoms.pbc
|
|
19
|
+
graph.graph["cell"] = atoms.cell
|
|
20
|
+
|
|
21
|
+
for i, atom in enumerate(atoms):
|
|
22
|
+
graph.add_node(
|
|
23
|
+
i,
|
|
24
|
+
position=atom.position,
|
|
25
|
+
atomic_number=int(atom.number),
|
|
26
|
+
original_index=atom.index,
|
|
27
|
+
charge=charges[i],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
for i, j, bond_order in connectivity:
|
|
31
|
+
graph.add_edge(i, j, bond_order=bond_order)
|
|
32
|
+
return graph
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _compute_connectivity_matrix(atoms: ase.Atoms, scale: float, pbc: bool):
|
|
36
|
+
"""Compute connectivity matrix from distance-based cutoffs."""
|
|
37
|
+
# non-bonding positive charged atoms / ions.
|
|
38
|
+
non_bonding_atomic_numbers = {3, 11, 19, 37, 55, 87}
|
|
39
|
+
|
|
40
|
+
atomic_numbers = atoms.get_atomic_numbers()
|
|
41
|
+
excluded_mask = np.isin(atomic_numbers, list(non_bonding_atomic_numbers))
|
|
42
|
+
|
|
43
|
+
atom_radii = np.array(natural_cutoffs(atoms, mult=scale))
|
|
44
|
+
pairwise_cutoffs = atom_radii[:, None] + atom_radii[None, :]
|
|
45
|
+
max_cutoff = np.max(pairwise_cutoffs)
|
|
46
|
+
|
|
47
|
+
if vesin is not None:
|
|
48
|
+
try:
|
|
49
|
+
i, j, d, s = vesin.ase_neighbor_list(
|
|
50
|
+
"ijdS", atoms, cutoff=max_cutoff, self_interaction=False
|
|
51
|
+
)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(f"vesin failed with {e}, trying native ASE implementation")
|
|
54
|
+
i, j, d, s = neighbor_list(
|
|
55
|
+
"ijdS", atoms, cutoff=max_cutoff, self_interaction=False
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
i, j, d, s = neighbor_list(
|
|
59
|
+
"ijdS", atoms, cutoff=max_cutoff, self_interaction=False
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# If pbc=False, filter out bonds that cross periodic boundaries
|
|
63
|
+
if not pbc:
|
|
64
|
+
non_periodic_mask = np.all(s == 0, axis=1)
|
|
65
|
+
i = i[non_periodic_mask]
|
|
66
|
+
j = j[non_periodic_mask]
|
|
67
|
+
d = d[non_periodic_mask]
|
|
68
|
+
|
|
69
|
+
d_ij = np.full((len(atoms), len(atoms)), np.inf)
|
|
70
|
+
d_ij[i, j] = d
|
|
71
|
+
np.fill_diagonal(d_ij, 0.0)
|
|
72
|
+
|
|
73
|
+
# mask out non-bonding atoms
|
|
74
|
+
d_ij[excluded_mask, :] = np.inf
|
|
75
|
+
d_ij[:, excluded_mask] = np.inf
|
|
76
|
+
|
|
77
|
+
connectivity_matrix = np.zeros((len(atoms), len(atoms)), dtype=int)
|
|
78
|
+
np.fill_diagonal(d_ij, np.inf)
|
|
79
|
+
connectivity_matrix[d_ij <= pairwise_cutoffs] = 1
|
|
80
|
+
|
|
81
|
+
return connectivity_matrix, non_bonding_atomic_numbers
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _add_node_properties(
|
|
85
|
+
graph: nx.Graph, atoms: ase.Atoms, charges, non_bonding_atomic_numbers
|
|
86
|
+
):
|
|
87
|
+
"""Add node properties to the graph."""
|
|
88
|
+
for i, atom in enumerate(atoms):
|
|
89
|
+
graph.nodes[i]["position"] = atom.position
|
|
90
|
+
graph.nodes[i]["atomic_number"] = int(atom.number)
|
|
91
|
+
graph.nodes[i]["original_index"] = atom.index
|
|
92
|
+
graph.nodes[i]["charge"] = float(charges[i])
|
|
93
|
+
if atom.number in non_bonding_atomic_numbers:
|
|
94
|
+
graph.nodes[i]["charge"] = 1.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ase2networkx(
|
|
98
|
+
atoms: ase.Atoms,
|
|
99
|
+
pbc: bool = True,
|
|
100
|
+
scale: float = 1.2,
|
|
101
|
+
) -> nx.Graph:
|
|
102
|
+
"""Convert an ASE Atoms object to a NetworkX graph.
|
|
103
|
+
|
|
104
|
+
Determines which atoms are bonded (connectivity).
|
|
105
|
+
All edges will have bond_order=None unless atoms.info['connectivity']
|
|
106
|
+
already has bond orders.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
atoms : ase.Atoms
|
|
111
|
+
The ASE Atoms object to convert into a graph.
|
|
112
|
+
pbc : bool, optional
|
|
113
|
+
Whether to consider periodic boundary conditions when calculating
|
|
114
|
+
distances (default is True). If False, only connections within
|
|
115
|
+
the unit cell are considered.
|
|
116
|
+
scale : float, optional
|
|
117
|
+
Scaling factor for the covalent radii when determining bond cutoffs
|
|
118
|
+
(default is 1.2).
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
networkx.Graph
|
|
123
|
+
An undirected NetworkX graph with connectivity information.
|
|
124
|
+
|
|
125
|
+
Notes
|
|
126
|
+
-----
|
|
127
|
+
The graph contains the following information:
|
|
128
|
+
|
|
129
|
+
- Nodes represent atoms with properties:
|
|
130
|
+
* position: Cartesian coordinates (numpy.ndarray)
|
|
131
|
+
* atomic_number: Element atomic number (int)
|
|
132
|
+
* original_index: Index in original Atoms object (int)
|
|
133
|
+
* charge: Formal charge (float)
|
|
134
|
+
- Edges represent bonds with:
|
|
135
|
+
* bond_order: Bond order (float or None if unknown)
|
|
136
|
+
- Graph properties include:
|
|
137
|
+
* pbc: Periodic boundary conditions
|
|
138
|
+
* cell: Unit cell vectors
|
|
139
|
+
|
|
140
|
+
Connectivity is determined by:
|
|
141
|
+
|
|
142
|
+
1. Using explicit connectivity if present in atoms.info
|
|
143
|
+
2. Otherwise using distance-based cutoffs (edges will have bond_order=None)
|
|
144
|
+
|
|
145
|
+
To get bond orders, pass the graph to networkx2rdkit().
|
|
146
|
+
|
|
147
|
+
Examples
|
|
148
|
+
--------
|
|
149
|
+
>>> from rdkit2ase import ase2networkx, smiles2atoms
|
|
150
|
+
>>> atoms = smiles2atoms(smiles="O")
|
|
151
|
+
>>> graph = ase2networkx(atoms)
|
|
152
|
+
>>> len(graph.nodes)
|
|
153
|
+
3
|
|
154
|
+
>>> len(graph.edges)
|
|
155
|
+
2
|
|
156
|
+
"""
|
|
157
|
+
if len(atoms) == 0:
|
|
158
|
+
return nx.Graph()
|
|
159
|
+
charges = atoms.get_initial_charges()
|
|
160
|
+
|
|
161
|
+
if "connectivity" in atoms.info:
|
|
162
|
+
connectivity = atoms.info["connectivity"]
|
|
163
|
+
# ensure connectivity is list[tuple[int, int, float|None]] and
|
|
164
|
+
# does not contain np.generic
|
|
165
|
+
connectivity = [
|
|
166
|
+
(int(i), int(j), float(bond_order) if bond_order is not None else None)
|
|
167
|
+
for i, j, bond_order in connectivity
|
|
168
|
+
]
|
|
169
|
+
return _create_graph_from_connectivity(atoms, connectivity, charges)
|
|
170
|
+
|
|
171
|
+
connectivity_matrix, non_bonding_atomic_numbers = _compute_connectivity_matrix(
|
|
172
|
+
atoms, scale, pbc
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
graph = nx.from_numpy_array(connectivity_matrix, edge_attr=None)
|
|
176
|
+
for u, v in graph.edges():
|
|
177
|
+
graph.edges[u, v]["bond_order"] = None
|
|
178
|
+
|
|
179
|
+
_add_node_properties(graph, atoms, charges, non_bonding_atomic_numbers)
|
|
180
|
+
|
|
181
|
+
graph.graph["pbc"] = atoms.pbc
|
|
182
|
+
graph.graph["cell"] = atoms.cell
|
|
183
|
+
|
|
184
|
+
return graph
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def ase2rdkit(atoms: ase.Atoms, suggestions: list[str] | None = None) -> Chem.Mol:
|
|
188
|
+
"""Convert an ASE Atoms object to an RDKit molecule.
|
|
189
|
+
|
|
190
|
+
Convenience function that chains:
|
|
191
|
+
ase2networkx() → networkx2rdkit(suggestions=...)
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
atoms : ase.Atoms
|
|
196
|
+
The ASE Atoms object to convert.
|
|
197
|
+
suggestions : list[str], optional
|
|
198
|
+
SMILES/SMARTS patterns for bond order determination.
|
|
199
|
+
Passed directly to networkx2rdkit().
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
rdkit.Chem.Mol
|
|
204
|
+
The resulting RDKit molecule with bond orders determined.
|
|
205
|
+
|
|
206
|
+
Examples
|
|
207
|
+
--------
|
|
208
|
+
>>> from rdkit2ase import ase2rdkit, smiles2atoms
|
|
209
|
+
>>> atoms = smiles2atoms(smiles="C=O")
|
|
210
|
+
>>> mol = ase2rdkit(atoms)
|
|
211
|
+
>>> mol.GetNumAtoms()
|
|
212
|
+
4
|
|
213
|
+
"""
|
|
214
|
+
if len(atoms) == 0:
|
|
215
|
+
return Chem.Mol()
|
|
216
|
+
|
|
217
|
+
from molify import ase2networkx, networkx2rdkit
|
|
218
|
+
|
|
219
|
+
graph = ase2networkx(atoms)
|
|
220
|
+
return networkx2rdkit(graph, suggestions=suggestions)
|
molify/bond_order.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import networkx as nx
|
|
2
|
+
from networkx.algorithms import isomorphism
|
|
3
|
+
|
|
4
|
+
from molify.utils import (
|
|
5
|
+
rdkit_determine_bonds,
|
|
6
|
+
suggestions2networkx,
|
|
7
|
+
unwrap_structures,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sort_templates(graphs: list[nx.Graph]) -> list[nx.Graph]:
|
|
12
|
+
return sorted(
|
|
13
|
+
graphs, key=lambda g: (g.number_of_nodes(), g.number_of_edges()), reverse=True
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def has_bond_order(graph: nx.Graph) -> bool:
|
|
18
|
+
return all(x[2]["bond_order"] is not None for x in graph.edges(data=True))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def update_graph_data(
|
|
22
|
+
working_graph: nx.Graph, graph: nx.Graph, mol_graph: nx.Graph
|
|
23
|
+
) -> bool:
|
|
24
|
+
graph_matcher = isomorphism.GraphMatcher(
|
|
25
|
+
working_graph,
|
|
26
|
+
mol_graph,
|
|
27
|
+
node_match=lambda n1, n2: n1["atomic_number"] == n2["atomic_number"],
|
|
28
|
+
)
|
|
29
|
+
match = next(graph_matcher.subgraph_isomorphisms_iter(), None)
|
|
30
|
+
if match is None:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
# Remove matched nodes from future matching
|
|
34
|
+
working_graph.remove_nodes_from(match.keys())
|
|
35
|
+
|
|
36
|
+
# Transfer bond order from template
|
|
37
|
+
inv_match = {v: k for k, v in match.items()}
|
|
38
|
+
for u, v, data in mol_graph.edges(data=True):
|
|
39
|
+
if "bond_order" in data:
|
|
40
|
+
if data["bond_order"] is None:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"Cannot update bond order with None value. "
|
|
43
|
+
"Please check the input templates."
|
|
44
|
+
)
|
|
45
|
+
u_g = inv_match[u]
|
|
46
|
+
v_g = inv_match[v]
|
|
47
|
+
if graph.has_edge(u_g, v_g):
|
|
48
|
+
graph[u_g][v_g]["bond_order"] = data["bond_order"]
|
|
49
|
+
|
|
50
|
+
# Assign charges for matched nodes
|
|
51
|
+
for graph_node, template_node in match.items():
|
|
52
|
+
if "charge" in mol_graph.nodes[template_node]:
|
|
53
|
+
graph.nodes[graph_node]["charge"] = mol_graph.nodes[template_node]["charge"]
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def update_bond_order_from_suggestions(graph, suggestions: list[nx.Graph]) -> None:
|
|
58
|
+
if has_bond_order(graph):
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
working_graph = graph.copy()
|
|
62
|
+
suggestions = sort_templates(suggestions)
|
|
63
|
+
|
|
64
|
+
for mol_graph in suggestions:
|
|
65
|
+
while True:
|
|
66
|
+
updated = update_graph_data(working_graph, graph, mol_graph)
|
|
67
|
+
if not updated:
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def update_bond_order(graph: nx.Graph, suggestions: list[str] | None = None) -> None:
|
|
72
|
+
if not has_bond_order(graph):
|
|
73
|
+
if suggestions is not None:
|
|
74
|
+
suggestion_graphs = suggestions2networkx(suggestions)
|
|
75
|
+
for component in nx.connected_components(graph):
|
|
76
|
+
subgraph = graph.subgraph(component)
|
|
77
|
+
update_bond_order_from_suggestions(subgraph, suggestion_graphs)
|
|
78
|
+
for u, v, data in subgraph.edges(data=True):
|
|
79
|
+
graph[u][v].update(data)
|
|
80
|
+
|
|
81
|
+
update_bond_order_determine(graph)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def update_bond_order_determine(graph: nx.Graph) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Update the bond order in the graph based on the suggestions.
|
|
87
|
+
If the graph already has bond order, it will not be updated.
|
|
88
|
+
"""
|
|
89
|
+
from molify.networkx2x import networkx2ase
|
|
90
|
+
from molify.rdkit2x import rdkit2networkx
|
|
91
|
+
|
|
92
|
+
if has_bond_order(graph):
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
for component in nx.connected_components(graph):
|
|
96
|
+
subgraph = graph.subgraph(component)
|
|
97
|
+
# add pbc and cell information to the subgraph
|
|
98
|
+
subgraph.graph.update(graph.graph)
|
|
99
|
+
missing = sum(
|
|
100
|
+
data.get("bond_order") is None for u, v, data in subgraph.edges(data=True)
|
|
101
|
+
)
|
|
102
|
+
# if any bond information is missing, we update the bond order
|
|
103
|
+
if missing > 0:
|
|
104
|
+
# Unwrapping could be made nicer, by utilizing the connectivity
|
|
105
|
+
atoms = networkx2ase(subgraph)
|
|
106
|
+
atoms = unwrap_structures(atoms)
|
|
107
|
+
rdkit_mol = rdkit_determine_bonds(atoms)
|
|
108
|
+
rdkit_graph = rdkit2networkx(rdkit_mol)
|
|
109
|
+
# update the bond order in the original graph
|
|
110
|
+
update_bond_order_from_suggestions(graph, [rdkit_graph])
|
molify/com.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import ase
|
|
2
|
+
import networkx as nx
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_centers_of_mass(atoms: ase.Atoms, unwrap: bool = True, **kwargs) -> ase.Atoms:
|
|
6
|
+
"""Compute the center of mass for each molecule in an ASE Atoms object.
|
|
7
|
+
|
|
8
|
+
Parameters
|
|
9
|
+
----------
|
|
10
|
+
atoms : ase.Atoms
|
|
11
|
+
The ASE Atoms object containing the molecular structures.
|
|
12
|
+
unwrap : bool, optional
|
|
13
|
+
If True, unwrap the structures before computing the center of mass.
|
|
14
|
+
**kwargs : dict
|
|
15
|
+
Additional keyword arguments to pass to the `ase2networkx` function.
|
|
16
|
+
|
|
17
|
+
Returns
|
|
18
|
+
-------
|
|
19
|
+
ase.Atoms
|
|
20
|
+
An ASE Atoms object containing the centers of mass of each molecule,
|
|
21
|
+
with the masses of the molecules as the `masses` attribute.
|
|
22
|
+
The species will start at 1 and increment
|
|
23
|
+
for each unique molecule type.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Example
|
|
27
|
+
-------
|
|
28
|
+
>>> from rdkit2ase import smiles2conformers, pack, get_centers_of_mass
|
|
29
|
+
>>> ethanol = smiles2conformers("CCO", numConfs=10)
|
|
30
|
+
>>> box = pack([ethanol], [10], density=786)
|
|
31
|
+
>>> centers = get_centers_of_mass(box)
|
|
32
|
+
>>> print(centers.get_positions().shape)
|
|
33
|
+
(10, 3)
|
|
34
|
+
"""
|
|
35
|
+
from molify import ase2networkx, networkx2ase, unwrap_structures
|
|
36
|
+
|
|
37
|
+
if unwrap:
|
|
38
|
+
graph = ase2networkx(atoms, **kwargs)
|
|
39
|
+
atoms = unwrap_structures(atoms) # Ensure structures are unwrapped
|
|
40
|
+
graph = ase2networkx(atoms, **kwargs)
|
|
41
|
+
|
|
42
|
+
centers = []
|
|
43
|
+
masses = []
|
|
44
|
+
|
|
45
|
+
counts = {}
|
|
46
|
+
|
|
47
|
+
for component in nx.connected_components(graph):
|
|
48
|
+
subgraph = graph.subgraph(component)
|
|
49
|
+
molecule = networkx2ase(subgraph)
|
|
50
|
+
center_of_mass = molecule.get_center_of_mass()
|
|
51
|
+
centers.append(center_of_mass)
|
|
52
|
+
masses.append(molecule.get_masses().sum())
|
|
53
|
+
|
|
54
|
+
for key in counts:
|
|
55
|
+
g = counts[key]["graph"]
|
|
56
|
+
if nx.is_isomorphic(subgraph, g):
|
|
57
|
+
counts[key]["count"] += 1
|
|
58
|
+
break
|
|
59
|
+
else:
|
|
60
|
+
counts[len(counts)] = {
|
|
61
|
+
"graph": subgraph,
|
|
62
|
+
"count": 1,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
numbers = []
|
|
66
|
+
for i, (key, value) in enumerate(counts.items()):
|
|
67
|
+
numbers.extend([i + 1] * value["count"])
|
|
68
|
+
|
|
69
|
+
return ase.Atoms(
|
|
70
|
+
positions=centers,
|
|
71
|
+
masses=masses,
|
|
72
|
+
cell=atoms.get_cell(),
|
|
73
|
+
pbc=atoms.pbc,
|
|
74
|
+
numbers=numbers,
|
|
75
|
+
)
|
molify/compress.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import ase
|
|
2
|
+
import numpy as np
|
|
3
|
+
from ase.cell import Cell
|
|
4
|
+
|
|
5
|
+
from molify.utils import calculate_box_dimensions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compress(
|
|
9
|
+
atoms: ase.Atoms,
|
|
10
|
+
density: float,
|
|
11
|
+
freeze_molecules: bool = False,
|
|
12
|
+
) -> ase.Atoms:
|
|
13
|
+
"""Compress an ASE Atoms object to a target density.
|
|
14
|
+
|
|
15
|
+
Arguments
|
|
16
|
+
---------
|
|
17
|
+
atoms : ase.Atoms
|
|
18
|
+
The Atoms object to compress.
|
|
19
|
+
density : float
|
|
20
|
+
The target density in g/cm^3.
|
|
21
|
+
freeze_molecules : bool
|
|
22
|
+
If True, freeze the internal degrees of freedom of the molecules
|
|
23
|
+
during compression, to prevent bond compression.
|
|
24
|
+
"""
|
|
25
|
+
atoms = atoms.copy()
|
|
26
|
+
new_dimensions = np.array(calculate_box_dimensions([atoms], density))
|
|
27
|
+
|
|
28
|
+
if freeze_molecules:
|
|
29
|
+
new_cell = Cell.new(new_dimensions)
|
|
30
|
+
old_cell = atoms.get_cell()
|
|
31
|
+
connectivity = atoms.info.get("connectivity")
|
|
32
|
+
if connectivity is None:
|
|
33
|
+
raise ValueError("No connectivity info found for freeze_molecules=True")
|
|
34
|
+
|
|
35
|
+
# Build molecular fragments
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
|
|
38
|
+
groups = defaultdict(set)
|
|
39
|
+
parent = {}
|
|
40
|
+
|
|
41
|
+
def find(x):
|
|
42
|
+
while parent.get(x, x) != x:
|
|
43
|
+
x = parent[x]
|
|
44
|
+
return x
|
|
45
|
+
|
|
46
|
+
def union(x, y):
|
|
47
|
+
rx, ry = find(x), find(y)
|
|
48
|
+
if rx != ry:
|
|
49
|
+
parent[ry] = rx
|
|
50
|
+
|
|
51
|
+
for i, j, _ in connectivity:
|
|
52
|
+
union(i, j)
|
|
53
|
+
|
|
54
|
+
for idx in range(len(atoms)):
|
|
55
|
+
root = find(idx)
|
|
56
|
+
groups[root].add(idx)
|
|
57
|
+
|
|
58
|
+
t_mat = np.linalg.solve(old_cell.T, new_cell.T)
|
|
59
|
+
|
|
60
|
+
positions = atoms.get_positions()
|
|
61
|
+
for group in groups.values():
|
|
62
|
+
group = list(group)
|
|
63
|
+
com = atoms[group].get_center_of_mass()
|
|
64
|
+
com_new = com @ t_mat
|
|
65
|
+
shift = com_new - com
|
|
66
|
+
positions[group] += shift
|
|
67
|
+
|
|
68
|
+
atoms.set_positions(positions)
|
|
69
|
+
atoms.set_cell(new_cell, scale_atoms=False)
|
|
70
|
+
else:
|
|
71
|
+
atoms.set_cell(new_dimensions, scale_atoms=True)
|
|
72
|
+
|
|
73
|
+
return atoms
|
molify/networkx2x.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import ase
|
|
2
|
+
import networkx as nx
|
|
3
|
+
import numpy as np
|
|
4
|
+
from rdkit import Chem
|
|
5
|
+
|
|
6
|
+
from molify.utils import bond_type_from_order
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def networkx2ase(graph: nx.Graph) -> ase.Atoms:
|
|
10
|
+
"""Convert a NetworkX graph to an ASE Atoms object.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
graph : networkx.Graph
|
|
15
|
+
The molecular graph to convert. Node attributes must include:
|
|
16
|
+
* position (numpy.ndarray): Cartesian coordinates
|
|
17
|
+
* atomic_number (int): Element atomic number
|
|
18
|
+
* charge (float, optional): Formal charge
|
|
19
|
+
Edge attributes must include:
|
|
20
|
+
* bond_order (float): Bond order
|
|
21
|
+
Optional graph attributes:
|
|
22
|
+
* pbc (bool): Periodic boundary conditions
|
|
23
|
+
* cell (numpy.ndarray): Unit cell vectors
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
ase.Atoms
|
|
28
|
+
The resulting Atoms object with:
|
|
29
|
+
* Atomic positions and numbers
|
|
30
|
+
* Initial charges if present in graph
|
|
31
|
+
* Connectivity information stored in atoms.info
|
|
32
|
+
* PBC and cell if present in graph
|
|
33
|
+
|
|
34
|
+
Examples
|
|
35
|
+
--------
|
|
36
|
+
>>> import networkx as nx
|
|
37
|
+
>>> from rdkit2ase import networkx2ase
|
|
38
|
+
>>> graph = nx.Graph()
|
|
39
|
+
>>> graph.add_node(0, position=[0,0,0], atomic_number=1, charge=0)
|
|
40
|
+
>>> graph.add_node(1, position=[1,0,0], atomic_number=1, charge=0)
|
|
41
|
+
>>> graph.add_edge(0, 1, bond_order=1.0)
|
|
42
|
+
>>> atoms = networkx2ase(graph)
|
|
43
|
+
>>> len(atoms)
|
|
44
|
+
2
|
|
45
|
+
"""
|
|
46
|
+
# Create mapping from original node indices to new sequential indices
|
|
47
|
+
node_mapping = {node: i for i, node in enumerate(graph.nodes)}
|
|
48
|
+
|
|
49
|
+
positions = np.array([graph.nodes[n]["position"] for n in graph.nodes])
|
|
50
|
+
numbers = np.array([graph.nodes[n]["atomic_number"] for n in graph.nodes])
|
|
51
|
+
charges = np.array([graph.nodes[n]["charge"] for n in graph.nodes])
|
|
52
|
+
|
|
53
|
+
atoms = ase.Atoms(
|
|
54
|
+
positions=positions,
|
|
55
|
+
numbers=numbers,
|
|
56
|
+
charges=charges,
|
|
57
|
+
pbc=graph.graph.get("pbc", False),
|
|
58
|
+
cell=graph.graph.get("cell", None),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
connectivity = []
|
|
62
|
+
for u, v, data in graph.edges(data=True):
|
|
63
|
+
bond_order = data["bond_order"]
|
|
64
|
+
# Map original node indices to new sequential indices
|
|
65
|
+
new_u = node_mapping[u]
|
|
66
|
+
new_v = node_mapping[v]
|
|
67
|
+
connectivity.append((new_u, new_v, bond_order))
|
|
68
|
+
|
|
69
|
+
atoms.info["connectivity"] = connectivity
|
|
70
|
+
|
|
71
|
+
return atoms
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def networkx2rdkit(graph: nx.Graph, suggestions: list[str] | None = None) -> Chem.Mol:
|
|
75
|
+
"""Convert a NetworkX graph to an RDKit molecule.
|
|
76
|
+
|
|
77
|
+
Automatically determines bond orders for edges with bond_order=None.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
graph : networkx.Graph
|
|
82
|
+
The molecular graph to convert
|
|
83
|
+
|
|
84
|
+
Node attributes:
|
|
85
|
+
* atomic_number (int): Element atomic number
|
|
86
|
+
* charge (float, optional): Formal charge
|
|
87
|
+
|
|
88
|
+
Edge attributes:
|
|
89
|
+
* bond_order (float or None): Bond order. If None, will be determined
|
|
90
|
+
automatically.
|
|
91
|
+
|
|
92
|
+
suggestions : list[str], optional
|
|
93
|
+
Optional SMILES/SMARTS patterns to help determine bond orders.
|
|
94
|
+
Used when edges have bond_order=None.
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
rdkit.Chem.Mol
|
|
99
|
+
The resulting RDKit molecule with:
|
|
100
|
+
* Atoms and bonds from the graph
|
|
101
|
+
* Formal charges if specified
|
|
102
|
+
* All bond orders determined
|
|
103
|
+
* Sanitized molecular structure
|
|
104
|
+
|
|
105
|
+
Raises
|
|
106
|
+
------
|
|
107
|
+
ValueError
|
|
108
|
+
If nodes are missing atomic_number attribute, or if bond order
|
|
109
|
+
determination fails for any edge.
|
|
110
|
+
|
|
111
|
+
Examples
|
|
112
|
+
--------
|
|
113
|
+
>>> import networkx as nx
|
|
114
|
+
>>> from rdkit2ase import networkx2rdkit
|
|
115
|
+
>>> graph = nx.Graph()
|
|
116
|
+
>>> graph.add_node(0, atomic_number=6, charge=0)
|
|
117
|
+
>>> graph.add_node(1, atomic_number=8, charge=0)
|
|
118
|
+
>>> graph.add_edge(0, 1, bond_order=2.0)
|
|
119
|
+
>>> mol = networkx2rdkit(graph)
|
|
120
|
+
>>> mol.GetNumAtoms()
|
|
121
|
+
2
|
|
122
|
+
|
|
123
|
+
>>> # With automatic bond order determination
|
|
124
|
+
>>> graph2 = nx.Graph()
|
|
125
|
+
>>> graph2.add_node(0, atomic_number=6, charge=0, position=[0, 0, 0])
|
|
126
|
+
>>> graph2.add_node(1, atomic_number=8, charge=0, position=[1.2, 0, 0])
|
|
127
|
+
>>> graph2.add_edge(0, 1, bond_order=None) # Will be determined
|
|
128
|
+
>>> mol2 = networkx2rdkit(graph2)
|
|
129
|
+
"""
|
|
130
|
+
from molify.bond_order import update_bond_order
|
|
131
|
+
|
|
132
|
+
# Check if any edges have bond_order=None and determine them if needed
|
|
133
|
+
has_none_bond_orders = any(
|
|
134
|
+
data.get("bond_order") is None for u, v, data in graph.edges(data=True)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if has_none_bond_orders:
|
|
138
|
+
# Make a copy to avoid modifying the input graph
|
|
139
|
+
graph = graph.copy()
|
|
140
|
+
update_bond_order(graph, suggestions)
|
|
141
|
+
|
|
142
|
+
# Verify all bond orders are now determined
|
|
143
|
+
for u, v, data in graph.edges(data=True):
|
|
144
|
+
bond_order = data.get("bond_order")
|
|
145
|
+
if bond_order is None:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Failed to determine bond order for edge ({u}, {v}).\n"
|
|
148
|
+
f"This can happen with:\n"
|
|
149
|
+
f" - Unusual bonding situations\n"
|
|
150
|
+
f" - Disconnected atoms\n"
|
|
151
|
+
f" - Invalid atomic coordinates\n\n"
|
|
152
|
+
f"Try:\n"
|
|
153
|
+
f" 1. Check atom positions are reasonable\n"
|
|
154
|
+
f" 2. Provide SMILES suggestions for complex molecules\n"
|
|
155
|
+
f" 3. Manually set connectivity with bond orders"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
mol = Chem.RWMol()
|
|
159
|
+
nx_to_rdkit_atom_map = {}
|
|
160
|
+
|
|
161
|
+
for node_id, attributes in graph.nodes(data=True):
|
|
162
|
+
atomic_number = attributes.get("atomic_number")
|
|
163
|
+
charge = attributes.get("charge", 0)
|
|
164
|
+
|
|
165
|
+
if atomic_number is None:
|
|
166
|
+
raise ValueError(f"Node {node_id} is missing 'atomic_number' attribute.")
|
|
167
|
+
|
|
168
|
+
atom = Chem.Atom(int(atomic_number))
|
|
169
|
+
atom.SetFormalCharge(int(charge))
|
|
170
|
+
idx = mol.AddAtom(atom)
|
|
171
|
+
nx_to_rdkit_atom_map[node_id] = idx
|
|
172
|
+
|
|
173
|
+
for u, v, data in graph.edges(data=True):
|
|
174
|
+
bond_order = data.get("bond_order")
|
|
175
|
+
|
|
176
|
+
if bond_order is None:
|
|
177
|
+
raise ValueError(f"Edge ({u}, {v}) is missing 'bond_order' attribute.")
|
|
178
|
+
|
|
179
|
+
mol.AddBond(
|
|
180
|
+
nx_to_rdkit_atom_map[u],
|
|
181
|
+
nx_to_rdkit_atom_map[v],
|
|
182
|
+
bond_type_from_order(bond_order),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
Chem.SanitizeMol(mol)
|
|
186
|
+
|
|
187
|
+
return mol.GetMol()
|