molbuilder 1.0.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 (78) hide show
  1. molbuilder/__init__.py +8 -0
  2. molbuilder/__main__.py +6 -0
  3. molbuilder/atomic/__init__.py +4 -0
  4. molbuilder/atomic/bohr.py +235 -0
  5. molbuilder/atomic/quantum_atom.py +334 -0
  6. molbuilder/atomic/quantum_numbers.py +196 -0
  7. molbuilder/atomic/wavefunctions.py +297 -0
  8. molbuilder/bonding/__init__.py +4 -0
  9. molbuilder/bonding/covalent.py +442 -0
  10. molbuilder/bonding/lewis.py +347 -0
  11. molbuilder/bonding/vsepr.py +433 -0
  12. molbuilder/cli/__init__.py +1 -0
  13. molbuilder/cli/demos.py +516 -0
  14. molbuilder/cli/menu.py +127 -0
  15. molbuilder/cli/wizard.py +831 -0
  16. molbuilder/core/__init__.py +6 -0
  17. molbuilder/core/bond_data.py +170 -0
  18. molbuilder/core/constants.py +51 -0
  19. molbuilder/core/element_properties.py +183 -0
  20. molbuilder/core/elements.py +181 -0
  21. molbuilder/core/geometry.py +232 -0
  22. molbuilder/gui/__init__.py +2 -0
  23. molbuilder/gui/app.py +286 -0
  24. molbuilder/gui/canvas3d.py +115 -0
  25. molbuilder/gui/dialogs.py +117 -0
  26. molbuilder/gui/event_handler.py +118 -0
  27. molbuilder/gui/sidebar.py +105 -0
  28. molbuilder/gui/toolbar.py +71 -0
  29. molbuilder/io/__init__.py +1 -0
  30. molbuilder/io/json_io.py +146 -0
  31. molbuilder/io/mol_sdf.py +169 -0
  32. molbuilder/io/pdb.py +184 -0
  33. molbuilder/io/smiles_io.py +47 -0
  34. molbuilder/io/xyz.py +103 -0
  35. molbuilder/molecule/__init__.py +2 -0
  36. molbuilder/molecule/amino_acids.py +919 -0
  37. molbuilder/molecule/builders.py +257 -0
  38. molbuilder/molecule/conformations.py +70 -0
  39. molbuilder/molecule/functional_groups.py +484 -0
  40. molbuilder/molecule/graph.py +712 -0
  41. molbuilder/molecule/peptides.py +13 -0
  42. molbuilder/molecule/stereochemistry.py +6 -0
  43. molbuilder/process/__init__.py +3 -0
  44. molbuilder/process/conditions.py +260 -0
  45. molbuilder/process/costing.py +316 -0
  46. molbuilder/process/purification.py +285 -0
  47. molbuilder/process/reactor.py +297 -0
  48. molbuilder/process/safety.py +476 -0
  49. molbuilder/process/scale_up.py +427 -0
  50. molbuilder/process/solvent_systems.py +204 -0
  51. molbuilder/reactions/__init__.py +3 -0
  52. molbuilder/reactions/functional_group_detect.py +728 -0
  53. molbuilder/reactions/knowledge_base.py +1716 -0
  54. molbuilder/reactions/reaction_types.py +102 -0
  55. molbuilder/reactions/reagent_data.py +1248 -0
  56. molbuilder/reactions/retrosynthesis.py +1430 -0
  57. molbuilder/reactions/synthesis_route.py +377 -0
  58. molbuilder/reports/__init__.py +158 -0
  59. molbuilder/reports/cost_report.py +206 -0
  60. molbuilder/reports/molecule_report.py +279 -0
  61. molbuilder/reports/safety_report.py +296 -0
  62. molbuilder/reports/synthesis_report.py +283 -0
  63. molbuilder/reports/text_formatter.py +170 -0
  64. molbuilder/smiles/__init__.py +4 -0
  65. molbuilder/smiles/parser.py +487 -0
  66. molbuilder/smiles/tokenizer.py +291 -0
  67. molbuilder/smiles/writer.py +375 -0
  68. molbuilder/visualization/__init__.py +1 -0
  69. molbuilder/visualization/bohr_viz.py +166 -0
  70. molbuilder/visualization/molecule_viz.py +368 -0
  71. molbuilder/visualization/quantum_viz.py +434 -0
  72. molbuilder/visualization/theme.py +12 -0
  73. molbuilder-1.0.0.dist-info/METADATA +360 -0
  74. molbuilder-1.0.0.dist-info/RECORD +78 -0
  75. molbuilder-1.0.0.dist-info/WHEEL +5 -0
  76. molbuilder-1.0.0.dist-info/entry_points.txt +2 -0
  77. molbuilder-1.0.0.dist-info/licenses/LICENSE +21 -0
  78. molbuilder-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,712 @@
1
+ """
2
+ Molecular Conformations and Stereochemistry
3
+
4
+ Represents multi-centre molecules with explicit atom connectivity and 3D
5
+ coordinates built from internal coordinates (bond lengths, bond angles,
6
+ dihedral angles). Supports:
7
+
8
+ - Z-matrix style atom placement for multi-centre molecules
9
+ - Dihedral rotation about single bonds
10
+ - Named conformations (staggered, eclipsed, gauche, anti)
11
+ - Chair / boat cyclohexane
12
+ - E/Z geometric isomerism and R/S chirality
13
+ - Torsional strain energy estimation (Pitzer potential)
14
+ - Newman projection data extraction
15
+
16
+ Complements the existing VSEPR model (single-central-atom molecules) by
17
+ handling chains, branches, and rings such as ethane, butane, and
18
+ cyclohexane.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum, auto
26
+
27
+ import numpy as np
28
+
29
+ from molbuilder.core.elements import SYMBOL_TO_Z
30
+ from molbuilder.core.element_properties import cpk_color
31
+ from molbuilder.core.bond_data import bond_length, SP3_ANGLE, SP2_ANGLE, SP_ANGLE, TORSION_BARRIERS
32
+ from molbuilder.core.geometry import normalize, rotation_matrix, place_atom_zmatrix
33
+
34
+
35
+ # ===================================================================
36
+ # Enumerations
37
+ # ===================================================================
38
+
39
+ class Hybridization(Enum):
40
+ """Hybridisation state of a bonding centre."""
41
+ SP3 = auto()
42
+ SP2 = auto()
43
+ SP = auto()
44
+
45
+
46
+ class ConformationType(Enum):
47
+ """Named conformation types for rotational isomers."""
48
+ ECLIPSED = auto()
49
+ STAGGERED = auto()
50
+ GAUCHE = auto()
51
+ ANTI = auto()
52
+ CUSTOM = auto()
53
+
54
+
55
+ class RingConformation(Enum):
56
+ """Named ring conformations."""
57
+ CHAIR = auto()
58
+ BOAT = auto()
59
+ TWIST_BOAT = auto()
60
+ HALF_CHAIR = auto()
61
+ FLAT = auto()
62
+
63
+
64
+ class Stereodescriptor(Enum):
65
+ """Stereochemical configuration descriptors."""
66
+ R = auto()
67
+ S = auto()
68
+ E = auto()
69
+ Z = auto()
70
+ NONE = auto()
71
+
72
+
73
+ # ===================================================================
74
+ # Core data classes
75
+ # ===================================================================
76
+
77
+ @dataclass
78
+ class Atom:
79
+ """An atom with a 3D position in a multi-centre molecule."""
80
+ symbol: str
81
+ position: np.ndarray
82
+ index: int
83
+ hybridization: Hybridization | None = None
84
+ chirality: str | None = None
85
+ isotope: int | None = None
86
+ formal_charge: int = 0
87
+
88
+ def __repr__(self):
89
+ x, y, z = self.position
90
+ return f"Atom({self.symbol}[{self.index}] @ ({x:.3f}, {y:.3f}, {z:.3f}))"
91
+
92
+
93
+ @dataclass
94
+ class Bond:
95
+ """A bond between two atoms in a multi-centre molecule."""
96
+ atom_i: int
97
+ atom_j: int
98
+ order: int = 1
99
+ rotatable: bool = False
100
+
101
+ def __repr__(self):
102
+ sym = {1: "-", 2: "=", 3: "#"}.get(self.order, "?")
103
+ rot = " (rot)" if self.rotatable else ""
104
+ return f"Bond({self.atom_i}{sym}{self.atom_j}{rot})"
105
+
106
+
107
+ @dataclass
108
+ class TorsionAngle:
109
+ """A dihedral / torsion angle defined by four atom indices."""
110
+ atom_i: int
111
+ atom_j: int
112
+ atom_k: int
113
+ atom_l: int
114
+ angle_deg: float = 0.0
115
+
116
+ def __repr__(self):
117
+ return (f"Torsion({self.atom_i}-{self.atom_j}-"
118
+ f"{self.atom_k}-{self.atom_l}: {self.angle_deg:.1f} deg)")
119
+
120
+
121
+ @dataclass
122
+ class NewmanProjection:
123
+ """Data for a Newman projection along a bond.
124
+
125
+ Substituent angles are measured clockwise from 12-o'clock.
126
+ """
127
+ front_atom: int
128
+ back_atom: int
129
+ front_substituents: list[tuple[int, str, float]] = field(default_factory=list)
130
+ back_substituents: list[tuple[int, str, float]] = field(default_factory=list)
131
+ dihedral_deg: float = 0.0
132
+
133
+ def summary(self) -> str:
134
+ lines = [
135
+ f" Newman Projection along bond "
136
+ f"{self.front_atom}-{self.back_atom}",
137
+ f" Dihedral: {self.dihedral_deg:.1f} deg",
138
+ f" Front substituents (atom {self.front_atom}):",
139
+ ]
140
+ for idx, sym, ang in self.front_substituents:
141
+ lines.append(f" {sym}[{idx}] at {ang:.1f} deg")
142
+ lines.append(f" Back substituents (atom {self.back_atom}):")
143
+ for idx, sym, ang in self.back_substituents:
144
+ lines.append(f" {sym}[{idx}] at {ang:.1f} deg")
145
+ return "\n".join(lines)
146
+
147
+
148
+ @dataclass
149
+ class StrainEnergy:
150
+ """Torsional strain energy breakdown."""
151
+ total_kj_per_mol: float
152
+ contributions: list[tuple[TorsionAngle, float]] = field(default_factory=list)
153
+
154
+ def summary(self) -> str:
155
+ lines = [
156
+ f" Torsional Strain: {self.total_kj_per_mol:.2f} kJ/mol",
157
+ ]
158
+ for torsion, e in self.contributions:
159
+ lines.append(f" {torsion}: {e:.2f} kJ/mol")
160
+ return "\n".join(lines)
161
+
162
+
163
+ # ===================================================================
164
+ # Molecule -- multi-centre molecular graph with 3D coordinates
165
+ # ===================================================================
166
+
167
+ class Molecule:
168
+ """A multi-centre molecule with explicit connectivity and 3D geometry.
169
+
170
+ Unlike VSEPRMolecule (single-central-atom systems), this class can
171
+ represent chains, branches, and rings such as ethane, butane, and
172
+ cyclohexane.
173
+
174
+ Atoms are placed incrementally using internal coordinates (z-matrix
175
+ style): bond length, bond angle, and dihedral angle.
176
+
177
+ Parameters
178
+ ----------
179
+ name : str
180
+ Human-readable name for the molecule.
181
+ """
182
+
183
+ def __init__(self, name: str = ""):
184
+ self.name = name
185
+ self.atoms: list[Atom] = []
186
+ self.bonds: list[Bond] = []
187
+ self._adj: dict[int, list[int]] = {}
188
+
189
+ # ---- building ----
190
+
191
+ def add_atom(self, symbol: str, position,
192
+ hybridization: Hybridization | None = None,
193
+ chirality: str | None = None,
194
+ isotope: int | None = None,
195
+ formal_charge: int = 0) -> int:
196
+ """Add an atom at an explicit 3D position. Returns new index."""
197
+ idx = len(self.atoms)
198
+ self.atoms.append(Atom(
199
+ symbol=symbol,
200
+ position=np.array(position, dtype=float),
201
+ index=idx,
202
+ hybridization=hybridization,
203
+ chirality=chirality,
204
+ isotope=isotope,
205
+ formal_charge=formal_charge,
206
+ ))
207
+ self._adj[idx] = []
208
+ return idx
209
+
210
+ def add_bond(self, i: int, j: int, order: int = 1,
211
+ rotatable: bool | None = None) -> Bond:
212
+ """Add a bond between atoms *i* and *j*. Returns the Bond."""
213
+ if rotatable is None:
214
+ rotatable = (order == 1)
215
+ bond = Bond(atom_i=i, atom_j=j, order=order, rotatable=rotatable)
216
+ self.bonds.append(bond)
217
+ self._adj[i].append(j)
218
+ self._adj[j].append(i)
219
+ return bond
220
+
221
+ def add_atom_bonded(self, symbol: str, bonded_to: int,
222
+ bond_order: int = 1,
223
+ angle_ref: int | None = None,
224
+ dihedral_ref: int | None = None,
225
+ bond_length_val: float | None = None,
226
+ bond_angle_deg: float | None = None,
227
+ dihedral_deg: float = 0.0,
228
+ hybridization: Hybridization | None = None,
229
+ rotatable: bool | None = None,
230
+ **kwargs) -> int:
231
+ """Add an atom bonded to an existing atom via internal coordinates.
232
+
233
+ For the first atom (index 0): placed at origin.
234
+ For the second atom (index 1): placed along +z.
235
+ For the third: placed in the xz-plane using a synthetic dihedral
236
+ reference. For all subsequent atoms: full z-matrix placement.
237
+
238
+ Returns the index of the new atom.
239
+ """
240
+ # Support both 'bond_length' and 'bond_length_val' parameter names
241
+ bl = bond_length_val if bond_length_val is not None else kwargs.get('bond_length', None)
242
+ if bl is None:
243
+ parent_sym = self.atoms[bonded_to].symbol
244
+ bl = bond_length(parent_sym, symbol, bond_order)
245
+
246
+ n = len(self.atoms)
247
+
248
+ # --- first atom ------------------------------------------------
249
+ if n == 0:
250
+ return self.add_atom(symbol, [0.0, 0.0, 0.0], hybridization)
251
+
252
+ # --- second atom -----------------------------------------------
253
+ if n == 1:
254
+ pos = (self.atoms[bonded_to].position
255
+ + np.array([0.0, 0.0, bl]))
256
+ idx = self.add_atom(symbol, pos, hybridization)
257
+ self.add_bond(bonded_to, idx, bond_order, rotatable)
258
+ return idx
259
+
260
+ # --- default bond angle from parent hybridisation --------------
261
+ if bond_angle_deg is None:
262
+ parent_hyb = self.atoms[bonded_to].hybridization
263
+ if parent_hyb == Hybridization.SP2:
264
+ bond_angle_deg = SP2_ANGLE
265
+ elif parent_hyb == Hybridization.SP:
266
+ bond_angle_deg = SP_ANGLE
267
+ else:
268
+ bond_angle_deg = SP3_ANGLE
269
+
270
+ # --- auto-select angle reference -------------------------------
271
+ if angle_ref is None:
272
+ nbrs = self._adj.get(bonded_to, [])
273
+ if nbrs:
274
+ angle_ref = nbrs[0]
275
+ else:
276
+ angle_ref = 0 if bonded_to != 0 else 1
277
+
278
+ # --- third atom (synthetic dihedral ref) -----------------------
279
+ if n == 2:
280
+ pos_j = self.atoms[bonded_to].position
281
+ pos_i = self.atoms[angle_ref].position
282
+ synthetic_k = pos_i + np.array([0.0, 1.0, 0.0])
283
+ pos = place_atom_zmatrix(pos_j, pos_i, synthetic_k,
284
+ bl, bond_angle_deg,
285
+ dihedral_deg)
286
+ idx = self.add_atom(symbol, pos, hybridization)
287
+ self.add_bond(bonded_to, idx, bond_order, rotatable)
288
+ return idx
289
+
290
+ # --- general case: full z-matrix -------------------------------
291
+ if dihedral_ref is None:
292
+ ar_nbrs = self._adj.get(angle_ref, [])
293
+ candidates = [x for x in ar_nbrs if x != bonded_to]
294
+ if candidates:
295
+ dihedral_ref = candidates[0]
296
+ else:
297
+ for a in self.atoms:
298
+ if a.index not in (bonded_to, angle_ref):
299
+ dihedral_ref = a.index
300
+ break
301
+
302
+ pos = place_atom_zmatrix(
303
+ self.atoms[bonded_to].position,
304
+ self.atoms[angle_ref].position,
305
+ self.atoms[dihedral_ref].position,
306
+ bl, bond_angle_deg, dihedral_deg,
307
+ )
308
+ idx = self.add_atom(symbol, pos, hybridization)
309
+ self.add_bond(bonded_to, idx, bond_order, rotatable)
310
+ return idx
311
+
312
+ def close_ring(self, i: int, j: int, order: int = 1):
313
+ """Bond two existing atoms to close a ring (non-rotatable)."""
314
+ self.add_bond(i, j, order=order, rotatable=False)
315
+
316
+ # ---- geometry queries ----
317
+
318
+ def distance(self, i: int, j: int) -> float:
319
+ """Distance between atoms *i* and *j* in Angstroms."""
320
+ return float(np.linalg.norm(
321
+ self.atoms[i].position - self.atoms[j].position))
322
+
323
+ def bond_angle(self, i: int, j: int, k: int) -> float:
324
+ """Bond angle i-j-k in degrees."""
325
+ vi = self.atoms[i].position - self.atoms[j].position
326
+ vk = self.atoms[k].position - self.atoms[j].position
327
+ cos_a = np.clip(
328
+ np.dot(vi, vk) / (np.linalg.norm(vi) * np.linalg.norm(vk)),
329
+ -1.0, 1.0)
330
+ return math.degrees(math.acos(cos_a))
331
+
332
+ def dihedral_angle(self, i: int, j: int, k: int, l: int) -> float:
333
+ """Signed dihedral angle i-j-k-l in degrees (-180 to 180)."""
334
+ b1 = self.atoms[j].position - self.atoms[i].position
335
+ b2 = self.atoms[k].position - self.atoms[j].position
336
+ b3 = self.atoms[l].position - self.atoms[k].position
337
+
338
+ n1 = np.cross(b1, b2)
339
+ n2 = np.cross(b2, b3)
340
+ n1_n = np.linalg.norm(n1)
341
+ n2_n = np.linalg.norm(n2)
342
+ if n1_n < 1e-12 or n2_n < 1e-12:
343
+ return 0.0
344
+ n1 /= n1_n
345
+ n2 /= n2_n
346
+ b2_hat = b2 / np.linalg.norm(b2)
347
+ x = float(np.dot(n1, n2))
348
+ y = float(np.dot(np.cross(n1, b2_hat), n2))
349
+ return math.degrees(math.atan2(y, x))
350
+
351
+ def neighbors(self, idx: int) -> list[int]:
352
+ """Indices of atoms bonded to atom *idx*."""
353
+ return list(self._adj.get(idx, []))
354
+
355
+ def get_bond(self, i: int, j: int) -> Bond | None:
356
+ """Return the Bond between *i* and *j*, or None."""
357
+ for b in self.bonds:
358
+ if {b.atom_i, b.atom_j} == {i, j}:
359
+ return b
360
+ return None
361
+
362
+ def is_in_ring(self, i: int, j: int) -> bool:
363
+ """True if removing the i-j edge leaves i and j connected."""
364
+ visited: set[int] = set()
365
+ stack = [i]
366
+ while stack:
367
+ cur = stack.pop()
368
+ if cur == j:
369
+ return True
370
+ if cur in visited:
371
+ continue
372
+ visited.add(cur)
373
+ for nb in self._adj.get(cur, []):
374
+ if cur == i and nb == j:
375
+ continue
376
+ if cur == j and nb == i:
377
+ continue
378
+ if nb not in visited:
379
+ stack.append(nb)
380
+ return False
381
+
382
+ # ---- dihedral manipulation ----
383
+
384
+ def rotate_dihedral(self, j: int, k: int, angle_deg: float):
385
+ """Rotate all atoms on the k-side of bond j-k by *angle_deg*.
386
+
387
+ Atoms on the j-side remain fixed. Raises ValueError if the
388
+ bond is part of a ring.
389
+ """
390
+ if self.is_in_ring(j, k):
391
+ raise ValueError(
392
+ f"Cannot rotate ring bond {j}-{k}.")
393
+
394
+ # BFS from k, not crossing back to j
395
+ k_side: set[int] = set()
396
+ stack = [k]
397
+ while stack:
398
+ cur = stack.pop()
399
+ if cur in k_side:
400
+ continue
401
+ k_side.add(cur)
402
+ for nb in self._adj.get(cur, []):
403
+ if cur == k and nb == j:
404
+ continue
405
+ if nb not in k_side:
406
+ stack.append(nb)
407
+
408
+ axis = self.atoms[k].position - self.atoms[j].position
409
+ pivot = self.atoms[j].position
410
+ R = rotation_matrix(axis, math.radians(angle_deg))
411
+
412
+ for idx in k_side:
413
+ rel = self.atoms[idx].position - pivot
414
+ self.atoms[idx].position = pivot + R @ rel
415
+
416
+ def set_dihedral(self, i: int, j: int, k: int, l: int,
417
+ target_deg: float):
418
+ """Set the dihedral i-j-k-l to *target_deg* by rotating the
419
+ k-side of bond j-k."""
420
+ current = self.dihedral_angle(i, j, k, l)
421
+ self.rotate_dihedral(j, k, target_deg - current)
422
+
423
+ # ---- torsional energy ----
424
+
425
+ def torsional_energy(self, j: int, k: int) -> StrainEnergy:
426
+ """Estimate torsional strain about bond j-k (Pitzer potential).
427
+
428
+ Sums over every (i on j) x (l on k) substituent pair.
429
+ """
430
+ j_subs = [n for n in self.neighbors(j) if n != k]
431
+ k_subs = [n for n in self.neighbors(k) if n != j]
432
+
433
+ total = 0.0
434
+ contributions: list[tuple[TorsionAngle, float]] = []
435
+
436
+ for i_idx in j_subs:
437
+ for l_idx in k_subs:
438
+ phi = self.dihedral_angle(i_idx, j, k, l_idx)
439
+ phi_rad = math.radians(phi)
440
+
441
+ key = self._torsion_key(i_idx, j, k, l_idx)
442
+ params = TORSION_BARRIERS.get(key, TORSION_BARRIERS["default"])
443
+ V1, V2, V3 = params["V1"], params["V2"], params["V3"]
444
+
445
+ energy = (
446
+ (V1 / 2.0) * (1.0 + math.cos(phi_rad))
447
+ + (V2 / 2.0) * (1.0 - math.cos(2.0 * phi_rad))
448
+ + (V3 / 2.0) * (1.0 + math.cos(3.0 * phi_rad))
449
+ )
450
+ torsion = TorsionAngle(i_idx, j, k, l_idx, phi)
451
+ contributions.append((torsion, energy))
452
+ total += energy
453
+
454
+ return StrainEnergy(total_kj_per_mol=total,
455
+ contributions=contributions)
456
+
457
+ def _torsion_key(self, i: int, j: int, k: int, l: int) -> str:
458
+ sym_i = self.atoms[i].symbol
459
+ sym_l = self.atoms[l].symbol
460
+
461
+ def _hyb_str(idx):
462
+ h = self.atoms[idx].hybridization
463
+ if h == Hybridization.SP3:
464
+ return "sp3"
465
+ if h == Hybridization.SP2:
466
+ return "sp2"
467
+ return "sp"
468
+
469
+ a, b = sym_i, sym_l
470
+ ha, hb = _hyb_str(j), _hyb_str(k)
471
+ if a > b:
472
+ a, b = b, a
473
+ ha, hb = hb, ha
474
+ return f"{a}_{ha}_{hb}_{b}"
475
+
476
+ # ---- Newman projection ----
477
+
478
+ def newman_projection(self, j: int, k: int) -> NewmanProjection:
479
+ """Extract Newman projection data looking from j toward k.
480
+
481
+ j is the front atom; k is the back atom. Substituent angles
482
+ are measured clockwise from 12-o'clock in the projection plane.
483
+ """
484
+ pos_j = self.atoms[j].position
485
+ pos_k = self.atoms[k].position
486
+ view = normalize(pos_k - pos_j)
487
+
488
+ # Orthonormal basis for the projection plane
489
+ up = np.array([0.0, 1.0, 0.0])
490
+ if abs(np.dot(view, up)) > 0.9:
491
+ up = np.array([1.0, 0.0, 0.0])
492
+ right = normalize(np.cross(view, up))
493
+ proj_up = normalize(np.cross(right, view))
494
+
495
+ def _proj_angle(sub_pos, center_pos):
496
+ v = sub_pos - center_pos
497
+ v_proj = v - np.dot(v, view) * view
498
+ x = float(np.dot(v_proj, right))
499
+ y = float(np.dot(v_proj, proj_up))
500
+ ang = math.degrees(math.atan2(x, y))
501
+ return ang % 360.0
502
+
503
+ front = []
504
+ for n in self.neighbors(j):
505
+ if n == k:
506
+ continue
507
+ ang = _proj_angle(self.atoms[n].position, pos_j)
508
+ front.append((n, self.atoms[n].symbol, ang))
509
+ front.sort(key=lambda t: t[2])
510
+
511
+ back = []
512
+ for n in self.neighbors(k):
513
+ if n == j:
514
+ continue
515
+ ang = _proj_angle(self.atoms[n].position, pos_k)
516
+ back.append((n, self.atoms[n].symbol, ang))
517
+ back.sort(key=lambda t: t[2])
518
+
519
+ dih = 0.0
520
+ if front and back:
521
+ dih = self.dihedral_angle(front[0][0], j, k, back[0][0])
522
+
523
+ return NewmanProjection(
524
+ front_atom=j, back_atom=k,
525
+ front_substituents=front,
526
+ back_substituents=back,
527
+ dihedral_deg=dih,
528
+ )
529
+
530
+ # ---- stereochemistry ----
531
+
532
+ def cip_priority(self, center: int, nbrs: list[int],
533
+ depth: int = 4) -> list[int]:
534
+ """Rank substituents by CIP (Cahn-Ingold-Prelog) priority.
535
+
536
+ Returns neighbour indices from highest to lowest priority.
537
+ Uses BFS expansion with phantom-atom duplication for multiple
538
+ bonds.
539
+ """
540
+ def _expand(start, exclude, d):
541
+ levels = []
542
+ layer = [start]
543
+ visited = {exclude, start}
544
+ for _ in range(d):
545
+ z_list: list[int] = []
546
+ nxt: list[int] = []
547
+ for node in layer:
548
+ z_list.append(SYMBOL_TO_Z.get(self.atoms[node].symbol, 0))
549
+ for nb in self.neighbors(node):
550
+ if nb not in visited:
551
+ visited.add(nb)
552
+ nxt.append(nb)
553
+ bond = self.get_bond(node, nb)
554
+ if bond and bond.order > 1:
555
+ for _ in range(bond.order - 1):
556
+ z_list.append(
557
+ SYMBOL_TO_Z.get(
558
+ self.atoms[nb].symbol, 0))
559
+ z_list.sort(reverse=True)
560
+ levels.append(tuple(z_list))
561
+ layer = nxt
562
+ if not layer:
563
+ break
564
+ return levels
565
+
566
+ pmap = {nb: _expand(nb, center, depth) for nb in nbrs}
567
+ return sorted(nbrs, key=lambda nb: pmap[nb], reverse=True)
568
+
569
+ def assign_rs(self, center: int) -> Stereodescriptor:
570
+ """Assign R or S to a tetrahedral stereocenter.
571
+
572
+ Returns NONE if the centre does not have exactly four different
573
+ substituents.
574
+ """
575
+ nbrs = self.neighbors(center)
576
+ if len(nbrs) != 4:
577
+ return Stereodescriptor.NONE
578
+
579
+ ranked = self.cip_priority(center, nbrs)
580
+ # ranked[0]=highest ... ranked[3]=lowest
581
+
582
+ c = self.atoms[center].position
583
+ p1 = self.atoms[ranked[0]].position - c
584
+ p2 = self.atoms[ranked[1]].position - c
585
+ p3 = self.atoms[ranked[2]].position - c
586
+ p4 = self.atoms[ranked[3]].position - c
587
+
588
+ normal = np.cross(p1 - p2, p3 - p2)
589
+ if np.dot(normal, p4) > 0:
590
+ return Stereodescriptor.R
591
+ return Stereodescriptor.S
592
+
593
+ def assign_ez(self, j: int, k: int) -> Stereodescriptor:
594
+ """Assign E or Z about a double bond j=k.
595
+
596
+ Uses the dihedral angle between the highest-priority
597
+ substituents on each side. If they are on the same side
598
+ (|dihedral| < 90) the configuration is Z (zusammen); if on
599
+ opposite sides (|dihedral| > 90) it is E (entgegen).
600
+
601
+ Returns NONE if either side has fewer than two substituents.
602
+ """
603
+ j_subs = [n for n in self.neighbors(j) if n != k]
604
+ k_subs = [n for n in self.neighbors(k) if n != j]
605
+
606
+ if len(j_subs) < 2 or len(k_subs) < 2:
607
+ return Stereodescriptor.NONE
608
+
609
+ j_ranked = self.cip_priority(j, j_subs)
610
+ k_ranked = self.cip_priority(k, k_subs)
611
+
612
+ high_j = j_ranked[0]
613
+ high_k = k_ranked[0]
614
+
615
+ dih = self.dihedral_angle(high_j, j, k, high_k)
616
+
617
+ if abs(dih) < 90.0:
618
+ return Stereodescriptor.Z
619
+ return Stereodescriptor.E
620
+
621
+ # ---- steric clash detection ----
622
+
623
+ def check_steric_clashes(
624
+ self, min_distance: float = 0.5) -> list[tuple[int, int]]:
625
+ """Return pairs of non-bonded atoms closer than *min_distance* Angstroms.
626
+
627
+ Useful after building molecules to catch placement errors or
628
+ unreasonable conformations.
629
+
630
+ Parameters
631
+ ----------
632
+ min_distance : float
633
+ Threshold distance in Angstroms. Pairs of non-bonded atoms
634
+ closer than this are reported.
635
+
636
+ Returns
637
+ -------
638
+ list[tuple[int, int]]
639
+ List of (atom_i, atom_j) pairs that clash.
640
+ """
641
+ bonded_pairs: set[frozenset[int]] = set()
642
+ for b in self.bonds:
643
+ bonded_pairs.add(frozenset((b.atom_i, b.atom_j)))
644
+
645
+ clashes: list[tuple[int, int]] = []
646
+ n = len(self.atoms)
647
+ for i in range(n):
648
+ for j in range(i + 1, n):
649
+ if frozenset((i, j)) in bonded_pairs:
650
+ continue
651
+ d = float(np.linalg.norm(
652
+ self.atoms[i].position - self.atoms[j].position))
653
+ if d < min_distance:
654
+ clashes.append((i, j))
655
+ return clashes
656
+
657
+ # ---- visualisation compatibility ----
658
+
659
+ def to_coordinates_dict(self) -> dict:
660
+ """Return coordinates in the format used by molecule_visualization.
661
+
662
+ Produces the same dict structure as
663
+ vsepr_model.generate_3d_coordinates().
664
+ """
665
+ atom_positions = [
666
+ (a.symbol, a.position.copy()) for a in self.atoms
667
+ ]
668
+ bonds = [(b.atom_i, b.atom_j, b.order) for b in self.bonds]
669
+ return {
670
+ "atom_positions": atom_positions,
671
+ "bonds": bonds,
672
+ "lone_pair_positions": [],
673
+ "central_index": 0,
674
+ }
675
+
676
+ # ---- display ----
677
+
678
+ def __repr__(self):
679
+ return (f"Molecule({self.name!r}, "
680
+ f"{len(self.atoms)} atoms, {len(self.bonds)} bonds)")
681
+
682
+ def summary(self) -> str:
683
+ lines = [
684
+ f"{'=' * 60}",
685
+ f" Molecule: {self.name}",
686
+ f" Atoms: {len(self.atoms)} Bonds: {len(self.bonds)}",
687
+ f"{'=' * 60}",
688
+ f" Coordinates (Angstroms):",
689
+ f" {'Idx':<5} {'Sym':<4} {'Hyb':<5}"
690
+ f" {'x':>8} {'y':>8} {'z':>8}",
691
+ f" {'-' * 48}",
692
+ ]
693
+ for atom in self.atoms:
694
+ hyb = atom.hybridization.name if atom.hybridization else "---"
695
+ x, y, z = atom.position
696
+ lines.append(
697
+ f" {atom.index:<5} {atom.symbol:<4} {hyb:<5}"
698
+ f" {x:>8.4f} {y:>8.4f} {z:>8.4f}")
699
+ lines.append(f" {'-' * 48}")
700
+ lines.append(" Bonds:")
701
+ for bond in self.bonds:
702
+ d = self.distance(bond.atom_i, bond.atom_j)
703
+ rot = "rotatable" if bond.rotatable else "fixed"
704
+ sa = self.atoms[bond.atom_i].symbol
705
+ sb = self.atoms[bond.atom_j].symbol
706
+ sym = {1: "-", 2: "=", 3: "#"}.get(bond.order, "?")
707
+ lines.append(
708
+ f" {sa}[{bond.atom_i}]{sym}"
709
+ f"{sb}[{bond.atom_j}]"
710
+ f" {d:.3f} A ({rot})")
711
+ lines.append(f"{'=' * 60}")
712
+ return "\n".join(lines)