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.
Files changed (46) hide show
  1. molcrys_kit/__init__.py +26 -0
  2. molcrys_kit/analysis/__init__.py +44 -0
  3. molcrys_kit/analysis/charge.py +183 -0
  4. molcrys_kit/analysis/chemical_env.py +1133 -0
  5. molcrys_kit/analysis/disorder/__init__.py +9 -0
  6. molcrys_kit/analysis/disorder/diagnostics.py +209 -0
  7. molcrys_kit/analysis/disorder/edge_priority.py +85 -0
  8. molcrys_kit/analysis/disorder/graph.py +1630 -0
  9. molcrys_kit/analysis/disorder/info.py +3 -0
  10. molcrys_kit/analysis/disorder/process.py +76 -0
  11. molcrys_kit/analysis/disorder/solver.py +1607 -0
  12. molcrys_kit/analysis/formula_moiety.py +203 -0
  13. molcrys_kit/analysis/interactions.py +219 -0
  14. molcrys_kit/analysis/packing_shell.py +1061 -0
  15. molcrys_kit/analysis/shape.py +733 -0
  16. molcrys_kit/analysis/species.py +54 -0
  17. molcrys_kit/analysis/stoichiometry.py +156 -0
  18. molcrys_kit/constants/__init__.py +261 -0
  19. molcrys_kit/constants/atomic_masses.json +120 -0
  20. molcrys_kit/constants/atomic_radii.json +98 -0
  21. molcrys_kit/constants/config.py +234 -0
  22. molcrys_kit/io/__init__.py +11 -0
  23. molcrys_kit/io/cif.py +812 -0
  24. molcrys_kit/io/output.py +307 -0
  25. molcrys_kit/io/xyz.py +59 -0
  26. molcrys_kit/operations/__init__.py +69 -0
  27. molcrys_kit/operations/builders.py +76 -0
  28. molcrys_kit/operations/defects.py +320 -0
  29. molcrys_kit/operations/desolvation.py +114 -0
  30. molcrys_kit/operations/hydrogen_completion.py +847 -0
  31. molcrys_kit/operations/molecule_manipulation.py +603 -0
  32. molcrys_kit/operations/perturbation.py +122 -0
  33. molcrys_kit/operations/rotation.py +86 -0
  34. molcrys_kit/operations/surface.py +1235 -0
  35. molcrys_kit/structures/__init__.py +24 -0
  36. molcrys_kit/structures/atom.py +68 -0
  37. molcrys_kit/structures/crystal.py +419 -0
  38. molcrys_kit/structures/molecule.py +410 -0
  39. molcrys_kit/structures/polyhedra.py +688 -0
  40. molcrys_kit/utils/__init__.py +29 -0
  41. molcrys_kit/utils/geometry.py +928 -0
  42. molcrys_kit-0.3.0.dist-info/METADATA +286 -0
  43. molcrys_kit-0.3.0.dist-info/RECORD +46 -0
  44. molcrys_kit-0.3.0.dist-info/WHEEL +5 -0
  45. molcrys_kit-0.3.0.dist-info/licenses/LICENSE +21 -0
  46. 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
+ }