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,433 @@
1
+ """
2
+ VSEPR (Valence Shell Electron Pair Repulsion) Theory
3
+
4
+ Predicts molecular geometry from electron pair repulsion around a
5
+ central atom. Given a molecular formula, this module:
6
+
7
+ 1. Builds a Lewis structure (bonding pairs + lone pairs)
8
+ 2. Classifies as AXnEm (A=central, X=bonds, E=lone pairs)
9
+ 3. Predicts electron geometry, molecular geometry, hybridisation
10
+ 4. Generates 3D atomic coordinates for visualisation
11
+ """
12
+
13
+ import math
14
+ import numpy as np
15
+ from dataclasses import dataclass, field
16
+
17
+ from molbuilder.core.element_properties import estimated_bond_length_pm
18
+ from molbuilder.bonding.lewis import LewisStructure
19
+
20
+
21
+ # ===================================================================
22
+ # Geometry reference tables
23
+ # ===================================================================
24
+
25
+ ELECTRON_GEOMETRY = {
26
+ 1: "terminal",
27
+ 2: "linear",
28
+ 3: "trigonal planar",
29
+ 4: "tetrahedral",
30
+ 5: "trigonal bipyramidal",
31
+ 6: "octahedral",
32
+ 7: "pentagonal bipyramidal",
33
+ }
34
+
35
+ # (steric_number, lone_pairs) -> (molecular_geometry, ideal_bond_angles)
36
+ MOLECULAR_GEOMETRY = {
37
+ (1, 0): ("terminal", [0.0]),
38
+ (2, 0): ("linear", [180.0]),
39
+ (2, 1): ("linear", [180.0]),
40
+ (3, 0): ("trigonal planar", [120.0]),
41
+ (3, 1): ("bent", [117.0]),
42
+ (4, 0): ("tetrahedral", [109.5]),
43
+ (4, 1): ("trigonal pyramidal", [107.0]),
44
+ (4, 2): ("bent", [104.5]),
45
+ (5, 0): ("trigonal bipyramidal", [90.0, 120.0]),
46
+ (5, 1): ("seesaw", [90.0, 117.0]),
47
+ (5, 2): ("T-shaped", [90.0]),
48
+ (5, 3): ("linear", [180.0]),
49
+ (6, 0): ("octahedral", [90.0]),
50
+ (6, 1): ("square pyramidal", [90.0]),
51
+ (6, 2): ("square planar", [90.0]),
52
+ (6, 3): ("T-shaped", [90.0]),
53
+ (6, 4): ("linear", [180.0]),
54
+ (7, 0): ("pentagonal bipyramidal", [72.0, 90.0]),
55
+ (7, 1): ("pentagonal pyramidal", [72.0, 90.0]),
56
+ (7, 2): ("pentagonal planar", [72.0]),
57
+ }
58
+
59
+ HYBRIDIZATION = {
60
+ 1: "s",
61
+ 2: "sp",
62
+ 3: "sp2",
63
+ 4: "sp3",
64
+ 5: "sp3d",
65
+ 6: "sp3d2",
66
+ 7: "sp3d3",
67
+ }
68
+
69
+
70
+ # ===================================================================
71
+ # AXE classification
72
+ # ===================================================================
73
+
74
+ @dataclass
75
+ class AXEClassification:
76
+ """AXnEm classification of a molecule's central atom."""
77
+ central_symbol: str
78
+ bonding_groups: int # X
79
+ lone_pairs: int # E
80
+ steric_number: int # X + E
81
+ axe_notation: str # e.g. "AX2E2"
82
+ electron_geometry: str
83
+ molecular_geometry: str
84
+ ideal_bond_angles: list
85
+ hybridization: str
86
+
87
+
88
+ # ===================================================================
89
+ # Ideal direction vectors for each electron geometry
90
+ # ===================================================================
91
+
92
+ def _linear_directions():
93
+ return [
94
+ np.array([0.0, 0.0, 1.0]),
95
+ np.array([0.0, 0.0, -1.0]),
96
+ ]
97
+
98
+
99
+ def _trigonal_planar_directions():
100
+ return [
101
+ np.array([1.0, 0.0, 0.0]),
102
+ np.array([-0.5, math.sqrt(3)/2, 0.0]),
103
+ np.array([-0.5, -math.sqrt(3)/2, 0.0]),
104
+ ]
105
+
106
+
107
+ def _tetrahedral_directions():
108
+ return [
109
+ np.array([ 1.0, 1.0, 1.0]) / math.sqrt(3),
110
+ np.array([ 1.0, -1.0, -1.0]) / math.sqrt(3),
111
+ np.array([-1.0, 1.0, -1.0]) / math.sqrt(3),
112
+ np.array([-1.0, -1.0, 1.0]) / math.sqrt(3),
113
+ ]
114
+
115
+
116
+ def _trigonal_bipyramidal_directions():
117
+ """3 equatorial (xy-plane, 120 deg) + 2 axial (+z, -z)."""
118
+ eq = _trigonal_planar_directions()
119
+ ax = [np.array([0.0, 0.0, 1.0]), np.array([0.0, 0.0, -1.0])]
120
+ # Return equatorial first, then axial (lone pairs prefer equatorial)
121
+ return eq + ax
122
+
123
+
124
+ def _octahedral_directions():
125
+ return [
126
+ np.array([ 1.0, 0.0, 0.0]),
127
+ np.array([-1.0, 0.0, 0.0]),
128
+ np.array([ 0.0, 1.0, 0.0]),
129
+ np.array([ 0.0, -1.0, 0.0]),
130
+ np.array([ 0.0, 0.0, 1.0]),
131
+ np.array([ 0.0, 0.0, -1.0]),
132
+ ]
133
+
134
+
135
+ def _pentagonal_bipyramidal_directions():
136
+ """5 equatorial (xy-plane, 72 deg) + 2 axial."""
137
+ eq = []
138
+ for i in range(5):
139
+ angle = 2 * math.pi * i / 5
140
+ eq.append(np.array([math.cos(angle), math.sin(angle), 0.0]))
141
+ ax = [np.array([0.0, 0.0, 1.0]), np.array([0.0, 0.0, -1.0])]
142
+ return eq + ax
143
+
144
+
145
+ _DIRECTION_GENERATORS = {
146
+ 2: _linear_directions,
147
+ 3: _trigonal_planar_directions,
148
+ 4: _tetrahedral_directions,
149
+ 5: _trigonal_bipyramidal_directions,
150
+ 6: _octahedral_directions,
151
+ 7: _pentagonal_bipyramidal_directions,
152
+ }
153
+
154
+
155
+ def _assign_positions(steric_number: int, num_lone_pairs: int):
156
+ """Assign direction vectors to bonding groups and lone pairs.
157
+
158
+ Lone pairs are placed at positions that minimise 90-degree repulsions:
159
+ SN=3: lone pair at any equatorial position
160
+ SN=4: lone pair at any tetrahedral vertex
161
+ SN=5: lone pairs at equatorial positions first
162
+ SN=6: 1 LP at any position; 2 LPs at trans (opposite) positions
163
+
164
+ Returns
165
+ -------
166
+ bonding_dirs : list of np.array -- unit vectors for bonded atoms
167
+ lone_pair_dirs : list of np.array -- unit vectors for lone pairs
168
+ """
169
+ gen = _DIRECTION_GENERATORS.get(steric_number)
170
+ if gen is None:
171
+ # Single atom or unknown: just put along +z
172
+ return [np.array([0.0, 0.0, 1.0])] * (steric_number - num_lone_pairs), \
173
+ [np.array([0.0, 0.0, -1.0])] * num_lone_pairs
174
+
175
+ all_dirs = gen()
176
+
177
+ if steric_number == 5:
178
+ # Equatorial positions are indices 0,1,2; axial are 3,4
179
+ # Lone pairs prefer equatorial
180
+ lp_indices = list(range(min(num_lone_pairs, 3)))
181
+ if num_lone_pairs > 3:
182
+ lp_indices.extend([3, 4][:num_lone_pairs - 3])
183
+ elif steric_number == 6:
184
+ # For 1 LP: remove index 0 (arbitrary, all equivalent)
185
+ # For 2 LPs: remove trans pair (indices 0,1 are +x/-x)
186
+ if num_lone_pairs == 1:
187
+ lp_indices = [0]
188
+ elif num_lone_pairs == 2:
189
+ lp_indices = [0, 1] # trans pair
190
+ elif num_lone_pairs == 3:
191
+ lp_indices = [0, 1, 2] # fac arrangement
192
+ elif num_lone_pairs == 4:
193
+ lp_indices = [0, 1, 2, 3]
194
+ else:
195
+ lp_indices = list(range(num_lone_pairs))
196
+ else:
197
+ # For SN=2,3,4: lone pairs take the last positions
198
+ lp_indices = list(range(steric_number - num_lone_pairs, steric_number))
199
+
200
+ bonding_dirs = [all_dirs[i] for i in range(len(all_dirs)) if i not in lp_indices]
201
+ lone_pair_dirs = [all_dirs[i] for i in lp_indices]
202
+
203
+ return bonding_dirs, lone_pair_dirs
204
+
205
+
206
+ # ===================================================================
207
+ # 3D coordinate generation
208
+ # ===================================================================
209
+
210
+ def generate_3d_coordinates(lewis: LewisStructure) -> dict:
211
+ """Generate 3D atomic coordinates from a Lewis structure.
212
+
213
+ Central atom is placed at the origin. Terminal atoms are placed
214
+ along ideal geometry direction vectors, scaled by estimated bond
215
+ lengths.
216
+
217
+ Returns
218
+ -------
219
+ dict with keys:
220
+ 'atom_positions' : list of (symbol, np.array([x,y,z])) in Angstroms
221
+ 'bonds' : list of (idx_a, idx_b, bond_order)
222
+ 'lone_pair_positions': list of (atom_idx, np.array direction)
223
+ 'central_index' : int
224
+ """
225
+ sn = lewis.steric_number()
226
+ lp_count = lewis.lone_pairs_on_central()
227
+ bp_count = lewis.bonding_pairs_on_central()
228
+
229
+ # Handle single-atom or diatomic edge case
230
+ if sn < 2 and len(lewis.atoms) == 1:
231
+ return {
232
+ 'atom_positions': [(lewis.atoms[0], np.array([0.0, 0.0, 0.0]))],
233
+ 'bonds': [],
234
+ 'lone_pair_positions': [],
235
+ 'central_index': 0,
236
+ }
237
+
238
+ # Get direction vectors
239
+ if sn >= 2:
240
+ bonding_dirs, lp_dirs = _assign_positions(sn, lp_count)
241
+ else:
242
+ # Diatomic with SN=1
243
+ bonding_dirs = [np.array([0.0, 0.0, 1.0])]
244
+ lp_dirs = []
245
+
246
+ # Build atom positions
247
+ atom_positions = [(None, None)] * len(lewis.atoms)
248
+ atom_positions[lewis.central_index] = (
249
+ lewis.central_symbol,
250
+ np.array([0.0, 0.0, 0.0]),
251
+ )
252
+
253
+ # Assign each bonded terminal to a direction
254
+ bonds_out = []
255
+ bond_dir_idx = 0
256
+ for bond in lewis.bonds:
257
+ ti = bond.atom_b if bond.atom_a == lewis.central_index else bond.atom_a
258
+ sym = lewis.atoms[ti]
259
+
260
+ bl = estimated_bond_length_pm(lewis.central_symbol, sym, bond.order) / 100.0
261
+
262
+ if bond_dir_idx < len(bonding_dirs):
263
+ direction = bonding_dirs[bond_dir_idx]
264
+ else:
265
+ direction = np.array([0.0, 0.0, 1.0])
266
+ bond_dir_idx += 1
267
+
268
+ pos = direction * bl
269
+ atom_positions[ti] = (sym, pos)
270
+ bonds_out.append((lewis.central_index, ti, bond.order))
271
+
272
+ # Lone pair direction info (for visualisation)
273
+ lp_positions = []
274
+ for i, lp_dir in enumerate(lp_dirs):
275
+ lp_positions.append((lewis.central_index, lp_dir))
276
+
277
+ return {
278
+ 'atom_positions': atom_positions,
279
+ 'bonds': bonds_out,
280
+ 'lone_pair_positions': lp_positions,
281
+ 'central_index': lewis.central_index,
282
+ }
283
+
284
+
285
+ # ===================================================================
286
+ # VSEPRMolecule
287
+ # ===================================================================
288
+
289
+ class VSEPRMolecule:
290
+ """Complete VSEPR analysis of a molecule.
291
+
292
+ Parameters
293
+ ----------
294
+ formula : str
295
+ Molecular formula (e.g., 'H2O', 'CH4').
296
+ charge : int
297
+ Net charge (default 0).
298
+ """
299
+
300
+ def __init__(self, formula: str, charge: int = 0):
301
+ self.formula = formula
302
+ self.charge = charge
303
+ self.lewis = LewisStructure(formula, charge)
304
+ self.axe = self._classify()
305
+ self.coordinates = generate_3d_coordinates(self.lewis)
306
+
307
+ def _classify(self) -> AXEClassification:
308
+ """Perform AXnEm classification."""
309
+ X = self.lewis.bonding_pairs_on_central()
310
+ E = self.lewis.lone_pairs_on_central()
311
+ sn = X + E
312
+
313
+ notation = f"AX{X}" + (f"E{E}" if E > 0 else "")
314
+ e_geom = ELECTRON_GEOMETRY.get(sn, "unknown")
315
+
316
+ entry = MOLECULAR_GEOMETRY.get((sn, E))
317
+ if entry:
318
+ m_geom, angles = entry
319
+ else:
320
+ m_geom, angles = "unknown", []
321
+
322
+ hybrid = HYBRIDIZATION.get(sn, "unknown")
323
+
324
+ return AXEClassification(
325
+ central_symbol=self.lewis.central_symbol,
326
+ bonding_groups=X,
327
+ lone_pairs=E,
328
+ steric_number=sn,
329
+ axe_notation=notation,
330
+ electron_geometry=e_geom,
331
+ molecular_geometry=m_geom,
332
+ ideal_bond_angles=angles,
333
+ hybridization=hybrid,
334
+ )
335
+
336
+ def computed_bond_angles(self) -> list[float]:
337
+ """Compute actual bond angles from 3D coordinates (degrees)."""
338
+ coords = self.coordinates
339
+ central_pos = coords['atom_positions'][coords['central_index']][1]
340
+ terminal_positions = []
341
+ for idx_a, idx_b, order in coords['bonds']:
342
+ ti = idx_b if idx_a == coords['central_index'] else idx_a
343
+ terminal_positions.append(coords['atom_positions'][ti][1])
344
+
345
+ angles = []
346
+ for i in range(len(terminal_positions)):
347
+ for j in range(i + 1, len(terminal_positions)):
348
+ va = terminal_positions[i] - central_pos
349
+ vb = terminal_positions[j] - central_pos
350
+ na, nb = np.linalg.norm(va), np.linalg.norm(vb)
351
+ if na < 1e-10 or nb < 1e-10:
352
+ continue
353
+ cos_angle = np.clip(np.dot(va, vb) / (na * nb), -1.0, 1.0)
354
+ angles.append(math.degrees(math.acos(cos_angle)))
355
+ return sorted(angles)
356
+
357
+ # ------ display ------
358
+
359
+ def __repr__(self):
360
+ return (f"VSEPRMolecule({self.formula}, "
361
+ f"{self.axe.axe_notation}, "
362
+ f"{self.axe.molecular_geometry})")
363
+
364
+ def summary(self) -> str:
365
+ """Return a detailed ASCII summary."""
366
+ axe = self.axe
367
+ coords = self.coordinates
368
+
369
+ angles_str = ", ".join(f"{a:.1f}" for a in axe.ideal_bond_angles) if axe.ideal_bond_angles else "N/A"
370
+ computed = self.computed_bond_angles()
371
+ computed_str = ", ".join(f"{a:.1f}" for a in computed) if computed else "N/A"
372
+
373
+ lines = [
374
+ f"{'='*60}",
375
+ f" VSEPR Analysis: {self.formula}",
376
+ f" Charge: {self.charge:+d}",
377
+ f"{'='*60}",
378
+ f" Lewis Structure:",
379
+ f" Total valence electrons: {self.lewis.total_valence_electrons}",
380
+ f" Central atom: {self.lewis.central_symbol}",
381
+ ]
382
+
383
+ # Bonds summary
384
+ bond_strs = []
385
+ for bond in self.lewis.bonds:
386
+ sym_a = self.lewis.atoms[bond.atom_a]
387
+ sym_b = self.lewis.atoms[bond.atom_b]
388
+ order_sym = {1: "-", 2: "=", 3: "#"}.get(bond.order, "?")
389
+ bond_strs.append(f"{sym_a}{order_sym}{sym_b}")
390
+ lines.append(f" Bonds: {', '.join(bond_strs)}")
391
+
392
+ lp_central = self.lewis.lone_pairs_on_central()
393
+ lines.append(f" Lone pairs on {self.lewis.central_symbol}: {lp_central}")
394
+
395
+ lines.extend([
396
+ f"{'='*60}",
397
+ f" VSEPR Classification:",
398
+ f" AXE notation: {axe.axe_notation}",
399
+ f" Steric number: {axe.steric_number}",
400
+ f" Electron geometry: {axe.electron_geometry}",
401
+ f" Molecular geometry: {axe.molecular_geometry}",
402
+ f" Hybridization: {axe.hybridization}",
403
+ f" Ideal bond angles: {angles_str} deg",
404
+ f" Computed angles: {computed_str} deg",
405
+ ])
406
+
407
+ lines.append(f"{'='*60}")
408
+ lines.append(" 3D Coordinates (Angstroms):")
409
+ for i, (sym, pos) in enumerate(coords['atom_positions']):
410
+ if sym is not None and pos is not None:
411
+ tag = " <-- central" if i == coords['central_index'] else ""
412
+ lines.append(
413
+ f" {sym:<4} {pos[0]:>8.4f} {pos[1]:>8.4f} {pos[2]:>8.4f}{tag}"
414
+ )
415
+ lines.append(f"{'='*60}")
416
+ return "\n".join(lines)
417
+
418
+
419
+ # ===================================================================
420
+ # Convenience constructors
421
+ # ===================================================================
422
+
423
+ def from_formula(formula: str, charge: int = 0) -> VSEPRMolecule:
424
+ """Create a VSEPRMolecule from a molecular formula string."""
425
+ return VSEPRMolecule(formula, charge)
426
+
427
+
428
+ def from_atoms(symbols: list[str], charge: int = 0) -> VSEPRMolecule:
429
+ """Create a VSEPRMolecule from a list of element symbols."""
430
+ from collections import Counter
431
+ counts = Counter(symbols)
432
+ formula = "".join(f"{sym}{cnt if cnt > 1 else ''}" for sym, cnt in counts.items())
433
+ return VSEPRMolecule(formula, charge)
@@ -0,0 +1 @@
1
+ """Command-line interface: menus, demos, interactive wizard."""