molcrys-kit 0.3.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.
- molcrys_kit/__init__.py +26 -0
- molcrys_kit/analysis/__init__.py +44 -0
- molcrys_kit/analysis/charge.py +183 -0
- molcrys_kit/analysis/chemical_env.py +1133 -0
- molcrys_kit/analysis/disorder/__init__.py +9 -0
- molcrys_kit/analysis/disorder/diagnostics.py +209 -0
- molcrys_kit/analysis/disorder/edge_priority.py +85 -0
- molcrys_kit/analysis/disorder/graph.py +1630 -0
- molcrys_kit/analysis/disorder/info.py +3 -0
- molcrys_kit/analysis/disorder/process.py +76 -0
- molcrys_kit/analysis/disorder/solver.py +1607 -0
- molcrys_kit/analysis/formula_moiety.py +203 -0
- molcrys_kit/analysis/interactions.py +219 -0
- molcrys_kit/analysis/packing_shell.py +1061 -0
- molcrys_kit/analysis/shape.py +733 -0
- molcrys_kit/analysis/species.py +54 -0
- molcrys_kit/analysis/stoichiometry.py +156 -0
- molcrys_kit/constants/__init__.py +261 -0
- molcrys_kit/constants/atomic_masses.json +120 -0
- molcrys_kit/constants/atomic_radii.json +98 -0
- molcrys_kit/constants/config.py +234 -0
- molcrys_kit/io/__init__.py +11 -0
- molcrys_kit/io/cif.py +812 -0
- molcrys_kit/io/output.py +307 -0
- molcrys_kit/io/xyz.py +59 -0
- molcrys_kit/operations/__init__.py +69 -0
- molcrys_kit/operations/builders.py +76 -0
- molcrys_kit/operations/defects.py +320 -0
- molcrys_kit/operations/desolvation.py +114 -0
- molcrys_kit/operations/hydrogen_completion.py +847 -0
- molcrys_kit/operations/molecule_manipulation.py +603 -0
- molcrys_kit/operations/perturbation.py +122 -0
- molcrys_kit/operations/rotation.py +86 -0
- molcrys_kit/operations/surface.py +1235 -0
- molcrys_kit/structures/__init__.py +24 -0
- molcrys_kit/structures/atom.py +68 -0
- molcrys_kit/structures/crystal.py +419 -0
- molcrys_kit/structures/molecule.py +410 -0
- molcrys_kit/structures/polyhedra.py +688 -0
- molcrys_kit/utils/__init__.py +29 -0
- molcrys_kit/utils/geometry.py +928 -0
- molcrys_kit-0.3.0.dist-info/METADATA +286 -0
- molcrys_kit-0.3.0.dist-info/RECORD +46 -0
- molcrys_kit-0.3.0.dist-info/WHEEL +5 -0
- molcrys_kit-0.3.0.dist-info/licenses/LICENSE +21 -0
- molcrys_kit-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chemical environment analyzer for molecular crystals.
|
|
3
|
+
|
|
4
|
+
This module provides classes and functions to analyze the local environment
|
|
5
|
+
of atoms in a molecule or molecular fragment, including coordination numbers,
|
|
6
|
+
bond angles, and ring detection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
import numpy as np
|
|
11
|
+
from typing import List, Dict, Tuple, Optional
|
|
12
|
+
import networkx as nx
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from ..structures.crystal import MolecularCrystal
|
|
15
|
+
from ..utils.geometry import angle_between_vectors
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChemicalEnvironment:
|
|
19
|
+
"""
|
|
20
|
+
Analyzes the local chemical environment of atoms in a molecule.
|
|
21
|
+
|
|
22
|
+
Provides methods to calculate coordination numbers, bond angles,
|
|
23
|
+
planarity, and ring membership for atoms in a molecular graph.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, crystal_molecule, anion_positions=None, cation_positions=None):
|
|
27
|
+
"""
|
|
28
|
+
Initialize with a CrystalMolecule or graph + positions.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
crystal_molecule : CrystalMolecule or tuple
|
|
33
|
+
Either a CrystalMolecule object or a tuple containing
|
|
34
|
+
(graph, positions) where graph is a NetworkX graph and
|
|
35
|
+
positions is a list of 3D coordinates.
|
|
36
|
+
anion_positions : Optional[List]
|
|
37
|
+
Caller-supplied Cartesian positions of likely anion sites in the
|
|
38
|
+
parent crystal. Used for charge-aware protonation heuristics.
|
|
39
|
+
cation_positions : Optional[List]
|
|
40
|
+
Caller-supplied Cartesian positions of likely cation sites in the
|
|
41
|
+
parent crystal. Used for context-aware isolated ion/water heuristics.
|
|
42
|
+
"""
|
|
43
|
+
if hasattr(crystal_molecule, 'graph') and hasattr(crystal_molecule, 'get_positions'):
|
|
44
|
+
# It's a CrystalMolecule object
|
|
45
|
+
self.graph = crystal_molecule.graph
|
|
46
|
+
self.positions = crystal_molecule.get_positions()
|
|
47
|
+
else:
|
|
48
|
+
# It's a (graph, positions) tuple
|
|
49
|
+
self.graph, self.positions = crystal_molecule
|
|
50
|
+
|
|
51
|
+
self.anion_positions = (
|
|
52
|
+
np.array(anion_positions, dtype=float)
|
|
53
|
+
if anion_positions is not None and len(anion_positions) > 0
|
|
54
|
+
else np.empty((0, 3), dtype=float)
|
|
55
|
+
)
|
|
56
|
+
self.cation_positions = (
|
|
57
|
+
np.array(cation_positions, dtype=float)
|
|
58
|
+
if cation_positions is not None and len(cation_positions) > 0
|
|
59
|
+
else np.empty((0, 3), dtype=float)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Precompute cycle basis ONCE per molecule to avoid repeated heavy calculations
|
|
63
|
+
self._precompute_ring_info()
|
|
64
|
+
|
|
65
|
+
def _precompute_ring_info(self):
|
|
66
|
+
"""Precompute ring membership for all atoms to avoid repeated heavy calculations.
|
|
67
|
+
|
|
68
|
+
Note: nx.minimum_cycle_basis can fail when graph node IDs are numpy integer
|
|
69
|
+
types (np.int64) because networkx 3.5's internal Dijkstra compares node IDs
|
|
70
|
+
with ``==`` which may raise a ValueError for numpy arrays. We work around
|
|
71
|
+
this by relabelling the graph with plain Python ints before calling the
|
|
72
|
+
function, then mapping the results back.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
# Convert node IDs to plain Python ints to avoid numpy comparison issues
|
|
76
|
+
node_ids = list(self.graph.nodes())
|
|
77
|
+
int_to_orig = {i: n for i, n in enumerate(node_ids)}
|
|
78
|
+
orig_to_int = {n: i for i, n in enumerate(node_ids)}
|
|
79
|
+
relabelled = nx.relabel_nodes(self.graph, orig_to_int)
|
|
80
|
+
|
|
81
|
+
raw_cycles = nx.minimum_cycle_basis(relabelled, weight=None)
|
|
82
|
+
|
|
83
|
+
self._atom_rings = {}
|
|
84
|
+
for idx in self.graph.nodes():
|
|
85
|
+
int_idx = orig_to_int[idx]
|
|
86
|
+
atom_cycles = [
|
|
87
|
+
[int_to_orig[n] for n in cycle]
|
|
88
|
+
for cycle in raw_cycles
|
|
89
|
+
if int_idx in cycle
|
|
90
|
+
]
|
|
91
|
+
self._atom_rings[idx] = atom_cycles
|
|
92
|
+
except (ValueError, TypeError, nx.NetworkXError) as e:
|
|
93
|
+
warnings.warn(
|
|
94
|
+
f"Ring detection failed ({type(e).__name__}: {e}). "
|
|
95
|
+
"Hydrogen completion may be inaccurate for ring atoms. "
|
|
96
|
+
"This is usually caused by unusual graph node types or a "
|
|
97
|
+
"disconnected graph — please report if it occurs on a valid CIF.",
|
|
98
|
+
RuntimeWarning,
|
|
99
|
+
stacklevel=4,
|
|
100
|
+
)
|
|
101
|
+
self._atom_rings = {}
|
|
102
|
+
|
|
103
|
+
self._precompute_aromatic_rings()
|
|
104
|
+
|
|
105
|
+
def _precompute_aromatic_rings(self):
|
|
106
|
+
"""
|
|
107
|
+
Identify aromatic rings and cache membership per atom.
|
|
108
|
+
|
|
109
|
+
Aromaticity is detected geometrically (no Hückel) using three criteria:
|
|
110
|
+
1. Ring size ∈ {5, 6, 7}
|
|
111
|
+
2. All ring atoms are C, N, O (S excluded: C-S ~1.71 Å exceeds length window)
|
|
112
|
+
3. All consecutive ring-bond lengths ∈ [1.20, 1.45] Å
|
|
113
|
+
The lower bound is 1.20 (not 1.30) to accommodate short N=N double bonds
|
|
114
|
+
(~1.25 Å) in pseudo-aromatic rings such as sydnones, oxadiazines, and
|
|
115
|
+
triazine-like heterocycles. Bonds below ~1.20 Å do not occur between
|
|
116
|
+
ring heavy atoms in organic molecules.
|
|
117
|
+
4. Ring is planar by the max-out-of-plane criterion
|
|
118
|
+
|
|
119
|
+
Result: self._atom_aromatic_ring_sizes[atom_idx] -> list of ring sizes
|
|
120
|
+
(empty list means not in any aromatic ring).
|
|
121
|
+
"""
|
|
122
|
+
_AROM_ATOMS = {'C', 'N', 'O'}
|
|
123
|
+
_BOND_MIN = 1.20 # allows N=N (~1.25 Å) in pseudo-aromatic rings
|
|
124
|
+
_BOND_MAX = 1.45
|
|
125
|
+
|
|
126
|
+
self._atom_aromatic_ring_sizes: Dict[int, List[int]] = {
|
|
127
|
+
idx: [] for idx in self.graph.nodes()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
all_cycles = []
|
|
131
|
+
for cycles in self._atom_rings.values():
|
|
132
|
+
for c in cycles:
|
|
133
|
+
# Use frozenset to deduplicate cycles across atoms
|
|
134
|
+
key = frozenset(c)
|
|
135
|
+
if key not in {frozenset(x) for x in all_cycles}:
|
|
136
|
+
all_cycles.append(c)
|
|
137
|
+
|
|
138
|
+
for cycle in all_cycles:
|
|
139
|
+
n = len(cycle)
|
|
140
|
+
if n not in (5, 6, 7):
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Criterion 2: all atoms must be C/N/O
|
|
144
|
+
symbols = [self.graph.nodes[i].get('symbol', '') for i in cycle]
|
|
145
|
+
if not all(s in _AROM_ATOMS for s in symbols):
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Criterion 3: consecutive ring-bond lengths in aromatic window
|
|
149
|
+
ok = True
|
|
150
|
+
for k in range(n):
|
|
151
|
+
a, b = cycle[k], cycle[(k + 1) % n]
|
|
152
|
+
if not self.graph.has_edge(a, b):
|
|
153
|
+
ok = False
|
|
154
|
+
break
|
|
155
|
+
d = np.linalg.norm(self.positions[b] - self.positions[a])
|
|
156
|
+
if not (_BOND_MIN <= d <= _BOND_MAX):
|
|
157
|
+
ok = False
|
|
158
|
+
break
|
|
159
|
+
if not ok:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
# Criterion 4: planarity
|
|
163
|
+
if not self._is_ring_planar(cycle):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
# All criteria met -> mark atoms
|
|
167
|
+
for idx in cycle:
|
|
168
|
+
self._atom_aromatic_ring_sizes[idx].append(n)
|
|
169
|
+
|
|
170
|
+
def atom_aromatic_ring_sizes(self, atom_index: int) -> List[int]:
|
|
171
|
+
"""
|
|
172
|
+
Return list of aromatic ring sizes the atom belongs to (empty if none).
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
atom_index : int
|
|
177
|
+
Index of the atom to query.
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
List[int]
|
|
182
|
+
Sizes of aromatic rings containing this atom. Empty list means the
|
|
183
|
+
atom is not in any detected aromatic ring.
|
|
184
|
+
"""
|
|
185
|
+
return self._atom_aromatic_ring_sizes.get(atom_index, [])
|
|
186
|
+
|
|
187
|
+
def _heavy_neighbors(self, atom_index: int) -> List[int]:
|
|
188
|
+
"""Return non-hydrogen neighbours of an atom."""
|
|
189
|
+
return [
|
|
190
|
+
nb for nb in self.graph.neighbors(atom_index)
|
|
191
|
+
if self.graph.nodes[nb].get('symbol', '') != 'H'
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
def _is_sulfonate_like_S(self, atom_index: int) -> bool:
|
|
195
|
+
"""Return True for S centers with three or more terminal O neighbours."""
|
|
196
|
+
if self.graph.nodes[atom_index].get('symbol', '') != 'S':
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
oxygen_neighbors = [
|
|
200
|
+
nb for nb in self._heavy_neighbors(atom_index)
|
|
201
|
+
if self.graph.nodes[nb].get('symbol', '') == 'O'
|
|
202
|
+
]
|
|
203
|
+
terminal_oxygen_count = sum(
|
|
204
|
+
len(self._heavy_neighbors(o_idx)) == 1
|
|
205
|
+
for o_idx in oxygen_neighbors
|
|
206
|
+
)
|
|
207
|
+
return terminal_oxygen_count >= 3
|
|
208
|
+
|
|
209
|
+
def _is_phosphonate_like_P(self, atom_index: int) -> bool:
|
|
210
|
+
"""Return True for P centers with three or more terminal O neighbours."""
|
|
211
|
+
if self.graph.nodes[atom_index].get('symbol', '') != 'P':
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
oxygen_neighbors = [
|
|
215
|
+
nb for nb in self._heavy_neighbors(atom_index)
|
|
216
|
+
if self.graph.nodes[nb].get('symbol', '') == 'O'
|
|
217
|
+
]
|
|
218
|
+
terminal_oxygen_count = sum(
|
|
219
|
+
len(self._heavy_neighbors(o_idx)) == 1
|
|
220
|
+
for o_idx in oxygen_neighbors
|
|
221
|
+
)
|
|
222
|
+
return terminal_oxygen_count >= 3
|
|
223
|
+
|
|
224
|
+
def _is_hypercoordinate_oxo_center(self, atom_index: int) -> bool:
|
|
225
|
+
"""Return True for inorganic oxo anion centers such as ClO4 or NO3."""
|
|
226
|
+
center_symbol = self.graph.nodes[atom_index].get('symbol', '')
|
|
227
|
+
if center_symbol not in {'Cl', 'S', 'P', 'N'}:
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
oxygen_neighbors = [
|
|
231
|
+
nb for nb in self._heavy_neighbors(atom_index)
|
|
232
|
+
if self.graph.nodes[nb].get('symbol', '') == 'O'
|
|
233
|
+
]
|
|
234
|
+
terminal_oxygen_count = sum(
|
|
235
|
+
len(self._heavy_neighbors(o_idx)) == 1
|
|
236
|
+
for o_idx in oxygen_neighbors
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if center_symbol == 'N':
|
|
240
|
+
return terminal_oxygen_count >= 3
|
|
241
|
+
if center_symbol == 'Cl':
|
|
242
|
+
return terminal_oxygen_count >= 4
|
|
243
|
+
return terminal_oxygen_count >= 3
|
|
244
|
+
|
|
245
|
+
def _is_carboxylate_like_C(self, atom_index: int) -> bool:
|
|
246
|
+
"""Return True for carbon centers with two terminal O neighbours."""
|
|
247
|
+
if self.graph.nodes[atom_index].get('symbol', '') != 'C':
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
oxygen_neighbors = [
|
|
251
|
+
nb for nb in self._heavy_neighbors(atom_index)
|
|
252
|
+
if self.graph.nodes[nb].get('symbol', '') == 'O'
|
|
253
|
+
]
|
|
254
|
+
if len(oxygen_neighbors) != 2:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
if not all(len(self._heavy_neighbors(o_idx)) == 1 for o_idx in oxygen_neighbors):
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
c_pos = self.positions[atom_index]
|
|
261
|
+
co_lengths = [
|
|
262
|
+
float(np.linalg.norm(self.positions[o_idx] - c_pos))
|
|
263
|
+
for o_idx in oxygen_neighbors
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
# In carboxylates the two C-O distances are both short and similar.
|
|
267
|
+
# Neutral carboxylic acids have one short C=O and one longer C-OH and
|
|
268
|
+
# should still receive the hydroxyl H on the longer terminal O.
|
|
269
|
+
return max(co_lengths) <= 1.32 and (max(co_lengths) - min(co_lengths)) <= 0.08
|
|
270
|
+
|
|
271
|
+
def _is_carbonyl_like_C(self, atom_index: int) -> bool:
|
|
272
|
+
"""Return True for C centers with a terminal C=O-like oxygen."""
|
|
273
|
+
if self.graph.nodes[atom_index].get('symbol', '') != 'C':
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
c_pos = self.positions[atom_index]
|
|
277
|
+
for nb in self._heavy_neighbors(atom_index):
|
|
278
|
+
if self.graph.nodes[nb].get('symbol', '') != 'O':
|
|
279
|
+
continue
|
|
280
|
+
if len(self._heavy_neighbors(nb)) != 1:
|
|
281
|
+
continue
|
|
282
|
+
co_len = float(np.linalg.norm(self.positions[nb] - c_pos))
|
|
283
|
+
if co_len < 1.30:
|
|
284
|
+
return True
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
def nearest_anion_distance(self, atom_index: int) -> float:
|
|
288
|
+
"""Return nearest distance to caller-supplied likely anion positions."""
|
|
289
|
+
if len(self.anion_positions) == 0:
|
|
290
|
+
return float('inf')
|
|
291
|
+
|
|
292
|
+
center = self.positions[atom_index]
|
|
293
|
+
distances = np.linalg.norm(self.anion_positions - center, axis=1)
|
|
294
|
+
return float(distances.min())
|
|
295
|
+
|
|
296
|
+
def nearest_cation_distance(self, atom_index: int) -> float:
|
|
297
|
+
"""Return nearest distance to caller-supplied likely cation positions."""
|
|
298
|
+
if len(self.cation_positions) == 0:
|
|
299
|
+
return float('inf')
|
|
300
|
+
|
|
301
|
+
center = self.positions[atom_index]
|
|
302
|
+
distances = np.linalg.norm(self.cation_positions - center, axis=1)
|
|
303
|
+
return float(distances.min())
|
|
304
|
+
|
|
305
|
+
def has_conjugated_ring_bond(self, atom_index: int, threshold: float = 1.43) -> bool:
|
|
306
|
+
"""
|
|
307
|
+
Return True if any ring bond *not* directly involving *atom_index* has length
|
|
308
|
+
≤ *threshold* Å.
|
|
309
|
+
|
|
310
|
+
This is a robust indicator of π-delocalization in the ring around the atom.
|
|
311
|
+
Unlike checking the atom's own bond lengths (which can be contracted by ring
|
|
312
|
+
strain), the OTHER ring bonds sharply distinguish aromatic and sp3 rings:
|
|
313
|
+
|
|
314
|
+
* Aromatic C–C / C–N: ~1.33–1.42 Å → comfortably ≤ 1.43 Å
|
|
315
|
+
* sp3 C–C: ~1.50–1.54 Å → clearly > 1.43 Å (Δ ≈ 0.10 Å)
|
|
316
|
+
* sp3 C–N: ~1.46–1.48 Å → clearly > 1.43 Å (Δ ≈ 0.07 Å)
|
|
317
|
+
|
|
318
|
+
Checking only bonds that do NOT touch the query atom avoids the ambiguity of
|
|
319
|
+
the atom's own bonds, which may be shortened by strain (sp3 cage O–C ~1.38 Å)
|
|
320
|
+
yet still fail the conjugated-ring criterion because the adjacent C–C bonds
|
|
321
|
+
(~1.52 Å) exceed the threshold.
|
|
322
|
+
|
|
323
|
+
Parameters
|
|
324
|
+
----------
|
|
325
|
+
atom_index : int
|
|
326
|
+
The ring atom whose neighbouring ring bonds are checked.
|
|
327
|
+
threshold : float
|
|
328
|
+
Upper bound for "aromatic-like" bond length (Å). Default 1.43 Å.
|
|
329
|
+
|
|
330
|
+
Returns
|
|
331
|
+
-------
|
|
332
|
+
bool
|
|
333
|
+
True if the ring contains at least one bond (not touching atom_index)
|
|
334
|
+
shorter than *threshold*.
|
|
335
|
+
"""
|
|
336
|
+
rings = self._atom_rings.get(atom_index, [])
|
|
337
|
+
for ring in rings:
|
|
338
|
+
n = len(ring)
|
|
339
|
+
for k in range(n):
|
|
340
|
+
a, b = ring[k], ring[(k + 1) % n]
|
|
341
|
+
if atom_index in (a, b):
|
|
342
|
+
continue # skip bonds that touch the query atom
|
|
343
|
+
d = float(np.linalg.norm(self.positions[a] - self.positions[b]))
|
|
344
|
+
if d <= threshold:
|
|
345
|
+
return True
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
def get_local_geometry_stats(self, atom_index: int) -> Dict:
|
|
349
|
+
"""
|
|
350
|
+
Calculate raw geometry statistics. Does NOT make decisions (e.g. is_planar).
|
|
351
|
+
Returns raw angles and lengths for heuristics to decide.
|
|
352
|
+
|
|
353
|
+
Parameters
|
|
354
|
+
----------
|
|
355
|
+
atom_index : int
|
|
356
|
+
Index of the atom to analyze
|
|
357
|
+
|
|
358
|
+
Returns
|
|
359
|
+
-------
|
|
360
|
+
dict
|
|
361
|
+
Dictionary containing:
|
|
362
|
+
- coordination_number: Number of neighbors
|
|
363
|
+
- bond_angle_sum: Sum of angles between all neighbor pairs
|
|
364
|
+
- bond_angle_single: Specifically for coordination=2, the angle between the two neighbors
|
|
365
|
+
- average_bond_length: Average distance to neighbors
|
|
366
|
+
"""
|
|
367
|
+
neighbors = list(self.graph.neighbors(atom_index))
|
|
368
|
+
n_neighbors = len(neighbors)
|
|
369
|
+
|
|
370
|
+
center_pos = self.positions[atom_index]
|
|
371
|
+
|
|
372
|
+
distances = []
|
|
373
|
+
neighbor_positions = []
|
|
374
|
+
for neighbor_idx in neighbors:
|
|
375
|
+
neighbor_pos = self.positions[neighbor_idx]
|
|
376
|
+
dist = np.linalg.norm(neighbor_pos - center_pos)
|
|
377
|
+
distances.append(dist)
|
|
378
|
+
neighbor_positions.append(neighbor_pos)
|
|
379
|
+
avg_bond_length = sum(distances) / len(distances) if distances else 0.0
|
|
380
|
+
|
|
381
|
+
# Calculate angle statistics
|
|
382
|
+
bond_angle_sum = 0.0
|
|
383
|
+
bond_angle_avg = 0.0 # Only meaningful for coord=2
|
|
384
|
+
|
|
385
|
+
if n_neighbors >= 2:
|
|
386
|
+
angles = []
|
|
387
|
+
for i in range(len(neighbor_positions)):
|
|
388
|
+
for j in range(i + 1, len(neighbor_positions)):
|
|
389
|
+
vec1 = neighbor_positions[i] - center_pos
|
|
390
|
+
vec2 = neighbor_positions[j] - center_pos
|
|
391
|
+
angle = np.degrees(angle_between_vectors(vec1, vec2))
|
|
392
|
+
angles.append(angle)
|
|
393
|
+
|
|
394
|
+
bond_angle_sum = sum(angles)
|
|
395
|
+
if n_neighbors == 2 and angles:
|
|
396
|
+
bond_angle_avg = angles[0] # For coord=2, there is only 1 angle
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
'coordination_number': n_neighbors,
|
|
400
|
+
'bond_angle_sum': bond_angle_sum,
|
|
401
|
+
'bond_angle_single': bond_angle_avg, # Specifically for coord=2
|
|
402
|
+
'average_bond_length': avg_bond_length
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
def detect_ring_info(self, atom_index: int) -> Dict:
|
|
406
|
+
"""
|
|
407
|
+
Detect ring information for an atom using precomputed ring info.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
atom_index : int
|
|
412
|
+
Index of the atom to analyze
|
|
413
|
+
|
|
414
|
+
Returns
|
|
415
|
+
-------
|
|
416
|
+
dict
|
|
417
|
+
Dictionary containing:
|
|
418
|
+
- in_ring: Boolean indicating if atom is in a ring
|
|
419
|
+
- ring_sizes: List of sizes of rings this atom belongs to
|
|
420
|
+
- is_ring_planar: Boolean for the smallest ring, whether it's planar
|
|
421
|
+
"""
|
|
422
|
+
atom_cycles = self._atom_rings.get(atom_index, [])
|
|
423
|
+
|
|
424
|
+
if not atom_cycles:
|
|
425
|
+
return {'in_ring': False, 'ring_sizes': [], 'is_ring_planar': False}
|
|
426
|
+
|
|
427
|
+
ring_sizes = [len(cycle) for cycle in atom_cycles]
|
|
428
|
+
|
|
429
|
+
# Check planarity of the smallest ring. _is_ring_planar does SVD on
|
|
430
|
+
# a small point set; wrap only that call so a degenerate geometry
|
|
431
|
+
# (e.g. collinear atoms after coordinate noise) produces a warning
|
|
432
|
+
# rather than a hard crash.
|
|
433
|
+
is_ring_planar = False
|
|
434
|
+
smallest_ring = min(atom_cycles, key=len)
|
|
435
|
+
try:
|
|
436
|
+
is_ring_planar = self._is_ring_planar(smallest_ring)
|
|
437
|
+
except (ValueError, np.linalg.LinAlgError) as e:
|
|
438
|
+
warnings.warn(
|
|
439
|
+
f"Planarity check failed for ring {smallest_ring} "
|
|
440
|
+
f"({type(e).__name__}: {e}); treating ring as non-planar.",
|
|
441
|
+
RuntimeWarning,
|
|
442
|
+
stacklevel=3,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
'in_ring': True,
|
|
447
|
+
'ring_sizes': sorted(ring_sizes),
|
|
448
|
+
'is_ring_planar': is_ring_planar,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
def _is_ring_planar(self, ring_atom_indices: List[int], max_dev_tolerance: float = 0.25) -> bool:
|
|
452
|
+
"""
|
|
453
|
+
Check if the atoms in a ring lie on a plane using max-out-of-plane deviation.
|
|
454
|
+
|
|
455
|
+
Uses SVD to find the best-fit plane normal, then reports the maximum
|
|
456
|
+
perpendicular distance of any ring atom from that plane. This is
|
|
457
|
+
more robust than the previous smallest-singular-value criterion, which
|
|
458
|
+
grows with ring size and was sensitive to CSD refinement noise (~0.1 Å).
|
|
459
|
+
|
|
460
|
+
Parameters
|
|
461
|
+
----------
|
|
462
|
+
ring_atom_indices : List[int]
|
|
463
|
+
Indices of atoms forming the ring.
|
|
464
|
+
max_dev_tolerance : float
|
|
465
|
+
Maximum allowed out-of-plane deviation in Angstroms. Default 0.25 Å
|
|
466
|
+
is chosen to accept aromatic rings in structures refined to R~0.07
|
|
467
|
+
(typical CSD noise ±0.04-0.06 Å) while still rejecting sp3 chair-
|
|
468
|
+
like distortions (~0.5 Å).
|
|
469
|
+
|
|
470
|
+
Returns
|
|
471
|
+
-------
|
|
472
|
+
bool
|
|
473
|
+
True if the maximum out-of-plane deviation is below the tolerance.
|
|
474
|
+
"""
|
|
475
|
+
if len(ring_atom_indices) < 3:
|
|
476
|
+
return False
|
|
477
|
+
|
|
478
|
+
pts = np.array([self.positions[idx] for idx in ring_atom_indices], dtype=float)
|
|
479
|
+
centroid = pts.mean(axis=0)
|
|
480
|
+
centered = pts - centroid
|
|
481
|
+
|
|
482
|
+
_, _, Vt = np.linalg.svd(centered, full_matrices=False)
|
|
483
|
+
# Last row of Vt is the direction of minimum variance (ring normal)
|
|
484
|
+
normal = Vt[-1]
|
|
485
|
+
|
|
486
|
+
deviations = np.abs(centered @ normal)
|
|
487
|
+
return float(deviations.max()) < max_dev_tolerance
|
|
488
|
+
|
|
489
|
+
def get_site(self, atom_index: int) -> 'HybridizedSite':
|
|
490
|
+
"""
|
|
491
|
+
Factory method to create the appropriate HybridizedSite subclass based on the atom's symbol.
|
|
492
|
+
|
|
493
|
+
Parameters
|
|
494
|
+
----------
|
|
495
|
+
atom_index : int
|
|
496
|
+
Index of the atom in the molecule
|
|
497
|
+
|
|
498
|
+
Returns
|
|
499
|
+
-------
|
|
500
|
+
HybridizedSite
|
|
501
|
+
An instance of the appropriate subclass based on the atom's symbol
|
|
502
|
+
"""
|
|
503
|
+
atom_symbol = self.graph.nodes[atom_index]['symbol'] if 'symbol' in self.graph.nodes[atom_index] else self.graph.nodes[atom_index]
|
|
504
|
+
if isinstance(atom_symbol, dict):
|
|
505
|
+
atom_symbol = atom_symbol.get('symbol', 'X') # Fallback to 'X' if no symbol
|
|
506
|
+
elif hasattr(atom_symbol, 'symbol'):
|
|
507
|
+
atom_symbol = atom_symbol.symbol
|
|
508
|
+
|
|
509
|
+
if atom_symbol == 'C':
|
|
510
|
+
return CarbonSite(atom_index, atom_symbol, self)
|
|
511
|
+
elif atom_symbol == 'N':
|
|
512
|
+
return NitrogenSite(atom_index, atom_symbol, self)
|
|
513
|
+
else:
|
|
514
|
+
return GenericSite(atom_index, atom_symbol, self)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class HybridizedSite(ABC):
|
|
518
|
+
"""
|
|
519
|
+
Abstract base class for representing hybridized sites in a chemical environment.
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
def __init__(self, atom_index: int, element: str, env: 'ChemicalEnvironment'):
|
|
523
|
+
"""
|
|
524
|
+
Initialize with atom index, element, and chemical environment.
|
|
525
|
+
|
|
526
|
+
Parameters
|
|
527
|
+
----------
|
|
528
|
+
atom_index : int
|
|
529
|
+
Index of the atom in the molecule
|
|
530
|
+
element : str
|
|
531
|
+
Element symbol
|
|
532
|
+
env : ChemicalEnvironment
|
|
533
|
+
The chemical environment of the molecule
|
|
534
|
+
"""
|
|
535
|
+
self.atom_index = atom_index
|
|
536
|
+
self.element = element
|
|
537
|
+
self.env = env
|
|
538
|
+
|
|
539
|
+
@property
|
|
540
|
+
def geometry_stats(self) -> Dict:
|
|
541
|
+
"""
|
|
542
|
+
Lazy access to local geometry statistics.
|
|
543
|
+
"""
|
|
544
|
+
return self.env.get_local_geometry_stats(self.atom_index)
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def ring_info(self) -> Dict:
|
|
548
|
+
"""
|
|
549
|
+
Lazy access to ring information.
|
|
550
|
+
"""
|
|
551
|
+
return self.env.detect_ring_info(self.atom_index)
|
|
552
|
+
|
|
553
|
+
@abstractmethod
|
|
554
|
+
def get_hydrogen_completion_strategy(self) -> Dict:
|
|
555
|
+
"""
|
|
556
|
+
Abstract method to determine hydrogen_completion strategy for this site.
|
|
557
|
+
|
|
558
|
+
Returns
|
|
559
|
+
-------
|
|
560
|
+
dict
|
|
561
|
+
Contains 'num_h', 'geometry', and 'bond_length' keys
|
|
562
|
+
"""
|
|
563
|
+
pass
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class CarbonSite(HybridizedSite):
|
|
567
|
+
"""
|
|
568
|
+
Carbon-specific hybridized site implementation.
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
def get_hydrogen_completion_strategy(self) -> Dict:
|
|
572
|
+
"""
|
|
573
|
+
Determine hydrogen_completion strategy for carbon based on its environment.
|
|
574
|
+
|
|
575
|
+
Aromatic-ring membership (detected by geometric pre-pass) short-circuits
|
|
576
|
+
the local-geometry heuristic to avoid misclassifying 5-membered aromatic
|
|
577
|
+
ring carbons as sp3 (their internal angle ~108° is nearly identical to the
|
|
578
|
+
tetrahedral ideal of 109.5°).
|
|
579
|
+
|
|
580
|
+
Isolated carbon (coord=0) defaults to methane-like tetrahedral C-H4.
|
|
581
|
+
|
|
582
|
+
Returns
|
|
583
|
+
-------
|
|
584
|
+
dict
|
|
585
|
+
Contains 'num_h', 'geometry', and 'bond_length' keys
|
|
586
|
+
"""
|
|
587
|
+
from ..constants.config import BOND_LENGTHS
|
|
588
|
+
|
|
589
|
+
bond_length = BOND_LENGTHS.get(f"{self.element}-H", 1.0)
|
|
590
|
+
|
|
591
|
+
# Aromatic short-circuit: if the atom is in a detected aromatic ring,
|
|
592
|
+
# decide purely from coordination number (no geometry scoring needed).
|
|
593
|
+
arom_sizes = self.env.atom_aromatic_ring_sizes(self.atom_index)
|
|
594
|
+
if arom_sizes:
|
|
595
|
+
coord = self.geometry_stats['coordination_number']
|
|
596
|
+
if coord == 2:
|
|
597
|
+
# Aromatic CH (e.g. indole C2, pyrrole C3/C4, benzene CH)
|
|
598
|
+
return {'num_h': 1, 'geometry': 'trigonal_planar', 'bond_length': bond_length}
|
|
599
|
+
elif coord == 3:
|
|
600
|
+
# Aromatic C with three ring/substituent bonds — no H needed
|
|
601
|
+
return {'num_h': 0, 'geometry': 'trigonal_planar', 'bond_length': bond_length}
|
|
602
|
+
# coord==1 or coord==4 in an aromatic ring: fall through to general logic
|
|
603
|
+
# (unusual but possible in distorted/disordered structures)
|
|
604
|
+
|
|
605
|
+
coord = self.geometry_stats['coordination_number']
|
|
606
|
+
avg_len = self.geometry_stats['average_bond_length']
|
|
607
|
+
|
|
608
|
+
# Defaults
|
|
609
|
+
num_h = 0
|
|
610
|
+
geometry = 'tetrahedral'
|
|
611
|
+
|
|
612
|
+
if coord == 0:
|
|
613
|
+
num_h = 4
|
|
614
|
+
geometry = 'tetrahedral'
|
|
615
|
+
|
|
616
|
+
elif coord == 3:
|
|
617
|
+
# Case: sp2 (Planar, ~360 sum) vs sp3 (Pyramidal, ~328.5 sum)
|
|
618
|
+
# Previous logic was flawed: it prioritized ring planarity over local geometry
|
|
619
|
+
# New logic: Local geometry takes precedence over global ring properties
|
|
620
|
+
|
|
621
|
+
angle_sum = self.geometry_stats['bond_angle_sum']
|
|
622
|
+
# --- NEW LOGIC: Local Geometry First ---
|
|
623
|
+
|
|
624
|
+
# 1. Definitely sp3 region (Pyramidal)
|
|
625
|
+
# Ideal tetrahedral is 328.5 degrees. With tolerance to 345 degrees.
|
|
626
|
+
# If less than this value, regardless of ring environment, it must be pyramidal.
|
|
627
|
+
if angle_sum < 345.0:
|
|
628
|
+
num_h = 1
|
|
629
|
+
geometry = 'tetrahedral'
|
|
630
|
+
|
|
631
|
+
# 2. Definitely sp2 region (Planar)
|
|
632
|
+
# Close to 360 degrees, definitely planar.
|
|
633
|
+
elif angle_sum > 355.0:
|
|
634
|
+
num_h = 0
|
|
635
|
+
geometry = 'trigonal_planar'
|
|
636
|
+
|
|
637
|
+
# 3. Ambiguous region (Distorted/Intermediate)
|
|
638
|
+
# E.g. 348 degrees. Could be a strained sp3 or a distorted sp2.
|
|
639
|
+
# Only in this case do we consider the ring environment for arbitration.
|
|
640
|
+
else:
|
|
641
|
+
if self.ring_info['in_ring'] and self.ring_info['is_ring_planar']:
|
|
642
|
+
# Ring is planar -> likely aromatic/conjugated system -> sp2
|
|
643
|
+
num_h = 0
|
|
644
|
+
geometry = 'trigonal_planar'
|
|
645
|
+
else:
|
|
646
|
+
# Ring not planar, or not in ring -> default to sp3
|
|
647
|
+
num_h = 1
|
|
648
|
+
geometry = 'tetrahedral'
|
|
649
|
+
|
|
650
|
+
elif coord == 2:
|
|
651
|
+
# Case: -CH2- (sp3) vs =CH- (sp2 aromatic) vs =C= (sp linear)
|
|
652
|
+
angle = self.geometry_stats['bond_angle_single']
|
|
653
|
+
|
|
654
|
+
# --- SCORING SYSTEM ---
|
|
655
|
+
# Ideal models
|
|
656
|
+
# sp: Angle 180, Len ~1.2-1.3 (cumulene)
|
|
657
|
+
# sp2: Angle 120, Len ~1.34-1.42 (aromatic)
|
|
658
|
+
# sp3: Angle 109.5, Len ~1.50-1.54 (aliphatic)
|
|
659
|
+
|
|
660
|
+
# 1. Angle Penalty (Weighted heavily)
|
|
661
|
+
score_sp = abs(angle - 180.0)
|
|
662
|
+
score_sp2 = abs(angle - 120.0)
|
|
663
|
+
score_sp3 = abs(angle - 109.5)
|
|
664
|
+
|
|
665
|
+
# 2. Bond Length Bias (Adjust scores based on length)
|
|
666
|
+
# If length > 1.46 (typical single bond), heavily penalize sp/sp2
|
|
667
|
+
if avg_len > 1.46:
|
|
668
|
+
score_sp3 -= 15.0 # Strong bonus for sp3
|
|
669
|
+
score_sp += 20.0 # Penalty for sp
|
|
670
|
+
# If length < 1.42 (aromatic / double bond range), penalize sp3.
|
|
671
|
+
# Threshold raised from 1.38 to 1.42: covers aromatic C-C/C-N up to
|
|
672
|
+
# ~1.42 Å (e.g. indole C2-C3 1.385 Å), acting as a safety net when
|
|
673
|
+
# the aromatic pre-pass misses a distorted ring.
|
|
674
|
+
elif avg_len < 1.42:
|
|
675
|
+
score_sp2 -= 10.0 # Bonus for sp2
|
|
676
|
+
score_sp3 += 20.0 # Penalty for sp3
|
|
677
|
+
|
|
678
|
+
# 3. Decision
|
|
679
|
+
best_match = min(score_sp, score_sp2, score_sp3)
|
|
680
|
+
|
|
681
|
+
if best_match == score_sp and score_sp < 15.0: # Must be reasonably close
|
|
682
|
+
num_h = 0
|
|
683
|
+
geometry = 'linear'
|
|
684
|
+
elif best_match == score_sp2:
|
|
685
|
+
num_h = 1
|
|
686
|
+
geometry = 'trigonal_planar'
|
|
687
|
+
else:
|
|
688
|
+
# Default to sp3 if ambiguous or matches sp3 best
|
|
689
|
+
num_h = 2
|
|
690
|
+
geometry = 'tetrahedral'
|
|
691
|
+
|
|
692
|
+
elif coord == 1:
|
|
693
|
+
# Case: Terminal Carbon
|
|
694
|
+
# Possibilities:
|
|
695
|
+
# 1. Alkyne (-C#C-H) or Isonitrile (-NC): sp, Triple bond (~1.20 A)
|
|
696
|
+
# 2. Terminal Alkene (=CH2) or Imine (=CH2): sp2, Double bond (~1.34 A)
|
|
697
|
+
# 3. Methyl (-CH3): sp3, Single bond (~1.54 A, C-N ~1.47 A)
|
|
698
|
+
|
|
699
|
+
# Thresholds:
|
|
700
|
+
# Triple < 1.28 (Safe cutoff for 1.20)
|
|
701
|
+
# 1.28 <= Double < 1.42 (Safe cutoff between 1.34 and 1.47)
|
|
702
|
+
# Single >= 1.42
|
|
703
|
+
|
|
704
|
+
if avg_len < 1.28 and avg_len > 0.1:
|
|
705
|
+
# Case 1: Triple Bond (sp) -> Add 1 H
|
|
706
|
+
num_h = 1
|
|
707
|
+
geometry = 'linear'
|
|
708
|
+
|
|
709
|
+
elif avg_len <= 1.42:
|
|
710
|
+
# Case 2: Double Bond (sp2) -> Add 2 H (Terminal Alkene)
|
|
711
|
+
# Note: "trigonal_planar" for a terminal atom means adding 2 H
|
|
712
|
+
# in the plane defined by the double bond vector (if possible)
|
|
713
|
+
num_h = 2
|
|
714
|
+
geometry = 'trigonal_planar'
|
|
715
|
+
|
|
716
|
+
else:
|
|
717
|
+
# Case 3: Single Bond (sp3) -> Add 3 H (Methyl)
|
|
718
|
+
num_h = 3
|
|
719
|
+
geometry = 'tetrahedral'
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
'num_h': num_h,
|
|
723
|
+
'geometry': geometry,
|
|
724
|
+
'bond_length': bond_length
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
class NitrogenSite(HybridizedSite):
|
|
729
|
+
"""
|
|
730
|
+
Nitrogen-specific hybridized site implementation.
|
|
731
|
+
|
|
732
|
+
Hybridization is inferred primarily from the shortest heavy-atom bond length,
|
|
733
|
+
which is the most reliable single indicator of bond order and hybridization.
|
|
734
|
+
|
|
735
|
+
Reference bond lengths (literature values, Å):
|
|
736
|
+
N≡C (nitrile): ~1.16 → sp
|
|
737
|
+
N=C (imine): ~1.28 → sp2
|
|
738
|
+
N-C(amide): ~1.34 → sp2 (partial double bond character)
|
|
739
|
+
N-C(aniline): ~1.40 → sp2 (resonance)
|
|
740
|
+
N-C(amine sp3): ~1.47 → sp3
|
|
741
|
+
N=N (azo): ~1.25 → sp2
|
|
742
|
+
N-N (hydrazine): ~1.45 → sp3
|
|
743
|
+
"""
|
|
744
|
+
|
|
745
|
+
def _min_heavy_bond_len(self) -> float:
|
|
746
|
+
"""Return the shortest bond length to a non-H neighbor."""
|
|
747
|
+
import numpy as np
|
|
748
|
+
graph = self.env.graph
|
|
749
|
+
positions = self.env.positions
|
|
750
|
+
center = positions[self.atom_index]
|
|
751
|
+
dists = [
|
|
752
|
+
np.linalg.norm(positions[nb] - center)
|
|
753
|
+
for nb in graph.neighbors(self.atom_index)
|
|
754
|
+
if graph.nodes[nb].get('symbol', '') != 'H'
|
|
755
|
+
]
|
|
756
|
+
return min(dists) if dists else self.geometry_stats['average_bond_length']
|
|
757
|
+
|
|
758
|
+
def _is_secondary_amide_like(self) -> bool:
|
|
759
|
+
"""Return True for coord-2 N attached to at least one carbonyl C."""
|
|
760
|
+
if self.geometry_stats['coordination_number'] != 2:
|
|
761
|
+
return False
|
|
762
|
+
|
|
763
|
+
min_heavy = self._min_heavy_bond_len()
|
|
764
|
+
if min_heavy < 1.30:
|
|
765
|
+
return False
|
|
766
|
+
|
|
767
|
+
neighbors = self.env._heavy_neighbors(self.atom_index)
|
|
768
|
+
return any(
|
|
769
|
+
self.env.graph.nodes[nb].get('symbol', '') == 'C'
|
|
770
|
+
and self.env._is_carbonyl_like_C(nb)
|
|
771
|
+
for nb in neighbors
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
def _is_protonated_tertiary_amine_like(self) -> bool:
|
|
775
|
+
"""Return True for tertiary amine N close to an anion site."""
|
|
776
|
+
neighbors = self.env._heavy_neighbors(self.atom_index)
|
|
777
|
+
if len(neighbors) != 3:
|
|
778
|
+
return False
|
|
779
|
+
if any(self.env.graph.nodes[nb].get('symbol', '') != 'C' for nb in neighbors):
|
|
780
|
+
return False
|
|
781
|
+
|
|
782
|
+
center = self.env.positions[self.atom_index]
|
|
783
|
+
n_c_lengths = [
|
|
784
|
+
float(np.linalg.norm(self.env.positions[nb] - center))
|
|
785
|
+
for nb in neighbors
|
|
786
|
+
]
|
|
787
|
+
if min(n_c_lengths) < 1.42:
|
|
788
|
+
return False
|
|
789
|
+
|
|
790
|
+
return self.env.nearest_anion_distance(self.atom_index) < 3.20
|
|
791
|
+
|
|
792
|
+
def get_hydrogen_completion_strategy(self) -> Dict:
|
|
793
|
+
"""
|
|
794
|
+
Determine hydrogen_completion strategy for nitrogen based on its environment.
|
|
795
|
+
|
|
796
|
+
Aromatic-ring membership (detected by geometric pre-pass) short-circuits
|
|
797
|
+
the heuristic to correctly distinguish pyridine-like N (coord=2, 0H) from
|
|
798
|
+
pyrrole-like N (coord=2, 1H) and N-substituted aromatic N (coord=3, 0H).
|
|
799
|
+
|
|
800
|
+
Primary decision criterion (non-aromatic): shortest heavy-atom bond length.
|
|
801
|
+
Secondary criterion: ring planarity (for aromatic systems).
|
|
802
|
+
Tertiary criterion: bond angle (for linear sp detection).
|
|
803
|
+
|
|
804
|
+
Isolated nitrogen (coord=0) defaults to NH3, or NH4-like when a
|
|
805
|
+
caller-supplied anion probe is nearby.
|
|
806
|
+
"""
|
|
807
|
+
from ..constants.config import BOND_LENGTHS
|
|
808
|
+
|
|
809
|
+
bond_length = BOND_LENGTHS.get(f"{self.element}-H", 1.0)
|
|
810
|
+
|
|
811
|
+
# Aromatic short-circuit
|
|
812
|
+
arom_sizes = self.env.atom_aromatic_ring_sizes(self.atom_index)
|
|
813
|
+
if arom_sizes:
|
|
814
|
+
coord = self.geometry_stats['coordination_number']
|
|
815
|
+
if coord == 2:
|
|
816
|
+
min_heavy = self._min_heavy_bond_len()
|
|
817
|
+
# Pyridine-like (lone pair in plane, 0H): 6-membered ring, OR
|
|
818
|
+
# bond to a neighbouring aromatic C is short (imidazole C2-N ~1.32 Å)
|
|
819
|
+
# indicating genuine imine character.
|
|
820
|
+
if 6 in arom_sizes or min_heavy < 1.32:
|
|
821
|
+
return {'num_h': 0, 'geometry': 'planar_aromatic', 'bond_length': bond_length}
|
|
822
|
+
else:
|
|
823
|
+
# Pyrrole-like (lone pair in π system, 1H): typically 5-ring,
|
|
824
|
+
# N–C bonds ~1.37–1.40 Å (longer than the imine threshold).
|
|
825
|
+
return {'num_h': 1, 'geometry': 'planar_bisector', 'bond_length': bond_length}
|
|
826
|
+
elif coord == 3:
|
|
827
|
+
# N-substituted aromatic N (e.g. indole N, N-methyl pyrrole) — no H
|
|
828
|
+
return {'num_h': 0, 'geometry': 'trigonal_planar', 'bond_length': bond_length}
|
|
829
|
+
# coord==1 or coord==4: unusual in aromatic context, fall through
|
|
830
|
+
|
|
831
|
+
coord = self.geometry_stats['coordination_number']
|
|
832
|
+
avg_len = self.geometry_stats['average_bond_length']
|
|
833
|
+
|
|
834
|
+
# Defaults
|
|
835
|
+
num_h = 0
|
|
836
|
+
geometry = 'tetrahedral'
|
|
837
|
+
|
|
838
|
+
# Shortest heavy-atom bond: most reliable hybridization indicator
|
|
839
|
+
min_heavy = self._min_heavy_bond_len()
|
|
840
|
+
|
|
841
|
+
in_ring = self.ring_info['in_ring']
|
|
842
|
+
is_planar_ring = self.ring_info['is_ring_planar']
|
|
843
|
+
ring_sizes = self.ring_info['ring_sizes']
|
|
844
|
+
|
|
845
|
+
if coord == 0:
|
|
846
|
+
if self.env.nearest_anion_distance(self.atom_index) < 3.20:
|
|
847
|
+
num_h = 4
|
|
848
|
+
else:
|
|
849
|
+
num_h = 3
|
|
850
|
+
geometry = 'tetrahedral'
|
|
851
|
+
return {'num_h': num_h, 'geometry': geometry, 'bond_length': bond_length}
|
|
852
|
+
|
|
853
|
+
# Strained 3-membered rings (aziridine): usually sp3, BUT if there is an
|
|
854
|
+
# exocyclic bond with double-bond character (min_heavy < 1.38, e.g. C=N
|
|
855
|
+
# amidine, C=O amide attached to ring N), the N is sp2 (planar).
|
|
856
|
+
# The lone pair participates in conjugation with the exocyclic π system.
|
|
857
|
+
if in_ring and ring_sizes and min(ring_sizes) <= 3:
|
|
858
|
+
# Check for exocyclic double-bond character
|
|
859
|
+
min_heavy = self._min_heavy_bond_len()
|
|
860
|
+
if min_heavy < 1.38:
|
|
861
|
+
# Exocyclic conjugation (amidine, amide, imine) → sp2
|
|
862
|
+
geometry = 'trigonal_planar'
|
|
863
|
+
num_h = 0
|
|
864
|
+
else:
|
|
865
|
+
geometry = 'tetrahedral'
|
|
866
|
+
if coord == 1:
|
|
867
|
+
num_h = 2
|
|
868
|
+
return {'num_h': num_h, 'geometry': geometry, 'bond_length': bond_length}
|
|
869
|
+
|
|
870
|
+
if coord == 2:
|
|
871
|
+
bond_angle = self.geometry_stats['bond_angle_single']
|
|
872
|
+
# sp: linear geometry (nitrile C≡N, isocyanate N=C=O, carbodiimide)
|
|
873
|
+
# Threshold: bond_angle > 160° OR min_heavy < 1.22 (triple/cumulated bond)
|
|
874
|
+
if bond_angle > 160.0 or min_heavy < 1.22:
|
|
875
|
+
num_h = 0
|
|
876
|
+
geometry = 'linear'
|
|
877
|
+
# sp2: aromatic ring N (pyridine, pyrrole, imidazole, etc.)
|
|
878
|
+
# Guard: min_heavy < 1.42 Å checks the bonds TO THIS NITROGEN specifically.
|
|
879
|
+
# sp2 N bonds (pyridine, pyrrole, amide, aniline): 1.33–1.40 Å — all < 1.42.
|
|
880
|
+
# sp3 N bonds (tertiary amine, cage N): ≥ 1.44 Å — excluded by guard.
|
|
881
|
+
# We deliberately check bonds to N (not max_ring_bond_len) because some
|
|
882
|
+
# non-fully-aromatic rings contain a long C-C bond between two sp2 carbons
|
|
883
|
+
# (e.g. isatin, where C2-C3 ~1.52 Å) yet the N itself IS sp2 with short
|
|
884
|
+
# N-C bonds (~1.37-1.40 Å). max_ring_bond_len would incorrectly reject
|
|
885
|
+
# such rings, while min_heavy correctly accepts them.
|
|
886
|
+
elif in_ring and is_planar_ring and min_heavy < 1.42:
|
|
887
|
+
if 6 in ring_sizes: # Pyridine-like: lone pair in plane, no H
|
|
888
|
+
num_h = 0
|
|
889
|
+
geometry = 'planar_aromatic'
|
|
890
|
+
else: # Pyrrole-like (5-membered): lone pair in pi system, 1H
|
|
891
|
+
num_h = 1
|
|
892
|
+
geometry = 'planar_bisector'
|
|
893
|
+
# sp2: imine/amide N with short bond (C=N ~1.28, amide N-C ~1.34)
|
|
894
|
+
elif self._is_secondary_amide_like():
|
|
895
|
+
num_h = 1
|
|
896
|
+
geometry = 'trigonal_planar'
|
|
897
|
+
elif min_heavy < 1.38:
|
|
898
|
+
num_h = 0
|
|
899
|
+
geometry = 'trigonal_planar'
|
|
900
|
+
else:
|
|
901
|
+
# sp3: secondary amine (N-C ~1.47, N-N ~1.45)
|
|
902
|
+
num_h = 1
|
|
903
|
+
geometry = 'tetrahedral'
|
|
904
|
+
|
|
905
|
+
elif coord == 1:
|
|
906
|
+
# Terminal N: distinguish by bond length
|
|
907
|
+
# sp: C≡N nitrile (~1.16), N≡N (~1.10)
|
|
908
|
+
# sp2: C=N imine (~1.28), amide N-C(=O) (~1.34)
|
|
909
|
+
# sp3: C-N amine (~1.47)
|
|
910
|
+
if min_heavy < 1.22:
|
|
911
|
+
num_h = 0
|
|
912
|
+
geometry = 'linear'
|
|
913
|
+
elif min_heavy < 1.38:
|
|
914
|
+
# sp2: amide or imine terminus
|
|
915
|
+
num_h = 1
|
|
916
|
+
geometry = 'trigonal_planar'
|
|
917
|
+
else:
|
|
918
|
+
# sp3: primary amine -NH2
|
|
919
|
+
num_h = 2
|
|
920
|
+
geometry = 'tetrahedral'
|
|
921
|
+
|
|
922
|
+
elif coord == 3:
|
|
923
|
+
# Tertiary N: sp3 amine vs sp2 (aniline, amide, enamine)
|
|
924
|
+
# Key: sp2 N has at least one short bond due to resonance/conjugation
|
|
925
|
+
# sp2 N-C(aromatic/carbonyl): min_heavy ~1.34-1.40
|
|
926
|
+
# sp3 N-C(alkyl): min_heavy ~1.46-1.47
|
|
927
|
+
# sp3 N-N(hydrazine): min_heavy ~1.45
|
|
928
|
+
# Threshold 1.42 Å: covers aniline (1.40), amide (1.34), enamine (1.37)
|
|
929
|
+
# while excluding sp3 amine (1.46+) and hydrazine N-N (1.45)
|
|
930
|
+
# Same min_heavy guard as coord==2 for the same reason: the N-atom's own
|
|
931
|
+
# bond lengths reliably reflect its hybridization even when the ring
|
|
932
|
+
# contains long C-C bonds between sp2 carbons (as in isatin or maleimide).
|
|
933
|
+
if in_ring and is_planar_ring and min_heavy < 1.42:
|
|
934
|
+
# Aromatic ring N (e.g. N-substituted pyrrole, indole)
|
|
935
|
+
num_h = 0
|
|
936
|
+
geometry = 'trigonal_planar'
|
|
937
|
+
elif min_heavy < 1.42:
|
|
938
|
+
# sp2: aniline, amide, enamine — shortest bond shows conjugation
|
|
939
|
+
num_h = 0
|
|
940
|
+
geometry = 'trigonal_planar'
|
|
941
|
+
elif self._is_protonated_tertiary_amine_like():
|
|
942
|
+
num_h = 1
|
|
943
|
+
geometry = 'tetrahedral'
|
|
944
|
+
else:
|
|
945
|
+
# sp3: tertiary amine, hydrazine N
|
|
946
|
+
num_h = 0
|
|
947
|
+
geometry = 'tetrahedral'
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
'num_h': num_h,
|
|
951
|
+
'geometry': geometry,
|
|
952
|
+
'bond_length': bond_length
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
class GenericSite(HybridizedSite):
|
|
957
|
+
"""
|
|
958
|
+
Generic hybridized site implementation for elements other than C and N.
|
|
959
|
+
"""
|
|
960
|
+
|
|
961
|
+
def get_hydrogen_completion_strategy(self) -> Dict:
|
|
962
|
+
"""
|
|
963
|
+
Determine hydrogen_completion strategy for generic elements based on their environment.
|
|
964
|
+
|
|
965
|
+
For oxygen specifically, aromatic-ring membership (furan-like O) is detected
|
|
966
|
+
via the geometric pre-pass and short-circuits the heuristic to avoid adding
|
|
967
|
+
a spurious H to a furan-type O.
|
|
968
|
+
|
|
969
|
+
Isolated oxygen (coord=0) uses caller-supplied ion probes to distinguish
|
|
970
|
+
water-like, hydroxide-like, and oxonium-like H counts. Terminal O atoms on
|
|
971
|
+
hypercoordinate oxo centers such as perchlorate/nitrate are treated as 0H.
|
|
972
|
+
|
|
973
|
+
Returns
|
|
974
|
+
-------
|
|
975
|
+
dict
|
|
976
|
+
Contains 'num_h', 'geometry', and 'bond_length' keys
|
|
977
|
+
"""
|
|
978
|
+
import numpy as np
|
|
979
|
+
from ..constants.config import BOND_LENGTHS
|
|
980
|
+
|
|
981
|
+
coord = self.geometry_stats['coordination_number']
|
|
982
|
+
avg_len = self.geometry_stats['average_bond_length']
|
|
983
|
+
|
|
984
|
+
# Defaults
|
|
985
|
+
num_h = 0
|
|
986
|
+
geometry = 'tetrahedral'
|
|
987
|
+
bond_length = BOND_LENGTHS.get(f"{self.element}-H", 1.0)
|
|
988
|
+
|
|
989
|
+
atom_symbol = self.element
|
|
990
|
+
|
|
991
|
+
# Aromatic short-circuit for oxygen (furan-like: lone pair in π system, 0H)
|
|
992
|
+
if atom_symbol == 'O' and self.env.atom_aromatic_ring_sizes(self.atom_index):
|
|
993
|
+
return {'num_h': 0, 'geometry': 'trigonal_planar', 'bond_length': bond_length}
|
|
994
|
+
|
|
995
|
+
# Shortest heavy-atom bond length: primary hybridization indicator
|
|
996
|
+
# (avoids distortion from short X-H bonds, e.g. O-H ~0.97 Å)
|
|
997
|
+
graph = self.env.graph
|
|
998
|
+
positions = self.env.positions
|
|
999
|
+
center = positions[self.atom_index]
|
|
1000
|
+
heavy_neighbors = self.env._heavy_neighbors(self.atom_index)
|
|
1001
|
+
heavy_dists = [
|
|
1002
|
+
np.linalg.norm(positions[nb] - center)
|
|
1003
|
+
for nb in heavy_neighbors
|
|
1004
|
+
]
|
|
1005
|
+
min_heavy = min(heavy_dists) if heavy_dists else avg_len
|
|
1006
|
+
|
|
1007
|
+
in_ring = self.ring_info['in_ring']
|
|
1008
|
+
is_planar_ring = self.ring_info['is_ring_planar']
|
|
1009
|
+
ring_sizes = self.ring_info['ring_sizes']
|
|
1010
|
+
|
|
1011
|
+
# Oxygen rules
|
|
1012
|
+
# Reference bond lengths (Å):
|
|
1013
|
+
# C=O carbonyl: ~1.23 sp2
|
|
1014
|
+
# C-O ester/enol: ~1.34 sp2 (partial double bond)
|
|
1015
|
+
# C-O aromatic: ~1.37 sp2 (furan)
|
|
1016
|
+
# C-O ether/alc: ~1.43 sp3
|
|
1017
|
+
# 3-membered ring: strained → sp3 regardless of length
|
|
1018
|
+
if atom_symbol == 'O':
|
|
1019
|
+
if coord == 0:
|
|
1020
|
+
if (
|
|
1021
|
+
self.env.nearest_anion_distance(self.atom_index) < 3.20
|
|
1022
|
+
and self.env.nearest_cation_distance(self.atom_index) >= 2.60
|
|
1023
|
+
):
|
|
1024
|
+
num_h = 3
|
|
1025
|
+
geometry = 'trigonal_planar' # oxonium-like
|
|
1026
|
+
elif self.env.nearest_cation_distance(self.atom_index) < 2.60:
|
|
1027
|
+
num_h = 1
|
|
1028
|
+
geometry = 'bent' # hydroxide-like
|
|
1029
|
+
else:
|
|
1030
|
+
num_h = 2
|
|
1031
|
+
geometry = 'bent' # neutral water
|
|
1032
|
+
elif coord == 1:
|
|
1033
|
+
# Terminal O is ambiguous by length alone in noisy CIFs. Prefer
|
|
1034
|
+
# the neighbour's local environment over a single C/S-O cutoff.
|
|
1035
|
+
neighbor_idx = heavy_neighbors[0] if heavy_neighbors else None
|
|
1036
|
+
neighbor_symbol = (
|
|
1037
|
+
graph.nodes[neighbor_idx].get('symbol', '')
|
|
1038
|
+
if neighbor_idx is not None else ''
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
if neighbor_idx is not None and (
|
|
1042
|
+
self.env._is_hypercoordinate_oxo_center(neighbor_idx)
|
|
1043
|
+
or
|
|
1044
|
+
(neighbor_symbol == 'S' and self.env._is_sulfonate_like_S(neighbor_idx))
|
|
1045
|
+
or (neighbor_symbol == 'P' and self.env._is_phosphonate_like_P(neighbor_idx))
|
|
1046
|
+
):
|
|
1047
|
+
num_h = 0
|
|
1048
|
+
geometry = 'trigonal_planar' # sulfonate/phosphonate O
|
|
1049
|
+
elif neighbor_idx is not None and neighbor_symbol == 'C':
|
|
1050
|
+
if self.env._is_carboxylate_like_C(neighbor_idx):
|
|
1051
|
+
num_h = 0
|
|
1052
|
+
geometry = 'trigonal_planar' # carboxylate O
|
|
1053
|
+
elif min_heavy < 1.30:
|
|
1054
|
+
num_h = 0
|
|
1055
|
+
geometry = 'trigonal_planar' # clear carbonyl O
|
|
1056
|
+
else:
|
|
1057
|
+
num_h = 1
|
|
1058
|
+
geometry = 'bent' # alcohol/enol/carboxylic-acid O
|
|
1059
|
+
elif min_heavy < 1.30:
|
|
1060
|
+
num_h = 0
|
|
1061
|
+
geometry = 'trigonal_planar' # clear terminal double-bond O
|
|
1062
|
+
else:
|
|
1063
|
+
num_h = 1
|
|
1064
|
+
geometry = 'bent' # terminal hydroxyl-like O
|
|
1065
|
+
elif coord == 2:
|
|
1066
|
+
if in_ring and ring_sizes and min(ring_sizes) <= 3:
|
|
1067
|
+
# Oxirane: highly strained 3-membered ring → always sp3
|
|
1068
|
+
num_h = 0
|
|
1069
|
+
geometry = 'bent'
|
|
1070
|
+
elif in_ring and ring_sizes and min(ring_sizes) == 4:
|
|
1071
|
+
# Oxetane: 4-membered ring.
|
|
1072
|
+
# Usually sp3 (C-O-C ~1.44-1.46), but if one bond shows
|
|
1073
|
+
# genuine double-bond character (min_heavy < 1.37, e.g.
|
|
1074
|
+
# iminolactone O-C=N ~1.34, beta-lactone O-C=O ~1.35)
|
|
1075
|
+
# the O is sp2 despite being in a small ring.
|
|
1076
|
+
if min_heavy < 1.37:
|
|
1077
|
+
num_h = 0
|
|
1078
|
+
geometry = 'trigonal_planar' # sp2: iminolactone / beta-lactone
|
|
1079
|
+
else:
|
|
1080
|
+
num_h = 0
|
|
1081
|
+
geometry = 'bent' # sp3 oxetane ether
|
|
1082
|
+
elif in_ring and is_planar_ring and ring_sizes and min(ring_sizes) >= 5:
|
|
1083
|
+
# Fallback for O in a planar 5/6/7-membered ring that was NOT
|
|
1084
|
+
# caught by the aromatic pre-pass short-circuit above.
|
|
1085
|
+
# The pre-pass uses bond-length window [1.20, 1.45] Å, covering
|
|
1086
|
+
# standard furan-like O AND pseudo-aromatic rings with N=N.
|
|
1087
|
+
# This branch fires only for rare edge cases (e.g. one ring bond
|
|
1088
|
+
# marginally > 1.45 Å due to coordinate noise in experimental data).
|
|
1089
|
+
#
|
|
1090
|
+
# In this residual population we use O's own bond length (min_heavy)
|
|
1091
|
+
# as the discriminant. In real crystal structures:
|
|
1092
|
+
# Aromatic ring O (furan, benzofuran, chromene, …): C–O ~1.36 Å
|
|
1093
|
+
# sp3 ether O (THF, THP, dioxane, cage compounds): C–O ~1.43 Å
|
|
1094
|
+
# The ~0.07 Å gap gives robust separation for the experimental data
|
|
1095
|
+
# that MolCrysKit is designed for (CSD / CIF files).
|
|
1096
|
+
# Threshold 1.40 Å places the boundary in the middle of this gap.
|
|
1097
|
+
if min_heavy < 1.40:
|
|
1098
|
+
num_h = 0
|
|
1099
|
+
geometry = 'trigonal_planar' # furan-like sp2 (missed by pre-pass)
|
|
1100
|
+
else:
|
|
1101
|
+
num_h = 0
|
|
1102
|
+
geometry = 'bent' # sp3 ring ether
|
|
1103
|
+
elif min_heavy < 1.38:
|
|
1104
|
+
# sp2 O with genuine conjugation (not in a small ring):
|
|
1105
|
+
# vinyl ether O-C(sp2): ~1.36-1.37
|
|
1106
|
+
# ester bridging O-C=O: ~1.34-1.37
|
|
1107
|
+
num_h = 0
|
|
1108
|
+
geometry = 'trigonal_planar'
|
|
1109
|
+
else:
|
|
1110
|
+
# sp3: aliphatic ether (~1.43), alcohol O (~1.43)
|
|
1111
|
+
num_h = 0
|
|
1112
|
+
geometry = 'bent'
|
|
1113
|
+
|
|
1114
|
+
elif atom_symbol == 'S':
|
|
1115
|
+
if coord == 1:
|
|
1116
|
+
num_h = 1
|
|
1117
|
+
geometry = 'bent'
|
|
1118
|
+
elif coord == 2:
|
|
1119
|
+
in_ring = self.ring_info['in_ring']
|
|
1120
|
+
is_planar_ring = self.ring_info['is_ring_planar']
|
|
1121
|
+
if in_ring and is_planar_ring:
|
|
1122
|
+
# Thiophene-like aromatic S (sp2)
|
|
1123
|
+
num_h = 0
|
|
1124
|
+
geometry = 'trigonal_planar'
|
|
1125
|
+
else:
|
|
1126
|
+
num_h = 0
|
|
1127
|
+
geometry = 'bent'
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
'num_h': num_h,
|
|
1131
|
+
'geometry': geometry,
|
|
1132
|
+
'bond_length': bond_length
|
|
1133
|
+
}
|