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 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()