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.
- molbuilder/__init__.py +8 -0
- molbuilder/__main__.py +6 -0
- molbuilder/atomic/__init__.py +4 -0
- molbuilder/atomic/bohr.py +235 -0
- molbuilder/atomic/quantum_atom.py +334 -0
- molbuilder/atomic/quantum_numbers.py +196 -0
- molbuilder/atomic/wavefunctions.py +297 -0
- molbuilder/bonding/__init__.py +4 -0
- molbuilder/bonding/covalent.py +442 -0
- molbuilder/bonding/lewis.py +347 -0
- molbuilder/bonding/vsepr.py +433 -0
- molbuilder/cli/__init__.py +1 -0
- molbuilder/cli/demos.py +516 -0
- molbuilder/cli/menu.py +127 -0
- molbuilder/cli/wizard.py +831 -0
- molbuilder/core/__init__.py +6 -0
- molbuilder/core/bond_data.py +170 -0
- molbuilder/core/constants.py +51 -0
- molbuilder/core/element_properties.py +183 -0
- molbuilder/core/elements.py +181 -0
- molbuilder/core/geometry.py +232 -0
- molbuilder/gui/__init__.py +2 -0
- molbuilder/gui/app.py +286 -0
- molbuilder/gui/canvas3d.py +115 -0
- molbuilder/gui/dialogs.py +117 -0
- molbuilder/gui/event_handler.py +118 -0
- molbuilder/gui/sidebar.py +105 -0
- molbuilder/gui/toolbar.py +71 -0
- molbuilder/io/__init__.py +1 -0
- molbuilder/io/json_io.py +146 -0
- molbuilder/io/mol_sdf.py +169 -0
- molbuilder/io/pdb.py +184 -0
- molbuilder/io/smiles_io.py +47 -0
- molbuilder/io/xyz.py +103 -0
- molbuilder/molecule/__init__.py +2 -0
- molbuilder/molecule/amino_acids.py +919 -0
- molbuilder/molecule/builders.py +257 -0
- molbuilder/molecule/conformations.py +70 -0
- molbuilder/molecule/functional_groups.py +484 -0
- molbuilder/molecule/graph.py +712 -0
- molbuilder/molecule/peptides.py +13 -0
- molbuilder/molecule/stereochemistry.py +6 -0
- molbuilder/process/__init__.py +3 -0
- molbuilder/process/conditions.py +260 -0
- molbuilder/process/costing.py +316 -0
- molbuilder/process/purification.py +285 -0
- molbuilder/process/reactor.py +297 -0
- molbuilder/process/safety.py +476 -0
- molbuilder/process/scale_up.py +427 -0
- molbuilder/process/solvent_systems.py +204 -0
- molbuilder/reactions/__init__.py +3 -0
- molbuilder/reactions/functional_group_detect.py +728 -0
- molbuilder/reactions/knowledge_base.py +1716 -0
- molbuilder/reactions/reaction_types.py +102 -0
- molbuilder/reactions/reagent_data.py +1248 -0
- molbuilder/reactions/retrosynthesis.py +1430 -0
- molbuilder/reactions/synthesis_route.py +377 -0
- molbuilder/reports/__init__.py +158 -0
- molbuilder/reports/cost_report.py +206 -0
- molbuilder/reports/molecule_report.py +279 -0
- molbuilder/reports/safety_report.py +296 -0
- molbuilder/reports/synthesis_report.py +283 -0
- molbuilder/reports/text_formatter.py +170 -0
- molbuilder/smiles/__init__.py +4 -0
- molbuilder/smiles/parser.py +487 -0
- molbuilder/smiles/tokenizer.py +291 -0
- molbuilder/smiles/writer.py +375 -0
- molbuilder/visualization/__init__.py +1 -0
- molbuilder/visualization/bohr_viz.py +166 -0
- molbuilder/visualization/molecule_viz.py +368 -0
- molbuilder/visualization/quantum_viz.py +434 -0
- molbuilder/visualization/theme.py +12 -0
- molbuilder-1.0.0.dist-info/METADATA +360 -0
- molbuilder-1.0.0.dist-info/RECORD +78 -0
- molbuilder-1.0.0.dist-info/WHEEL +5 -0
- molbuilder-1.0.0.dist-info/entry_points.txt +2 -0
- molbuilder-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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)
|