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,831 @@
1
+ """Interactive step-by-step molecule building wizard.
2
+
3
+ Provides a guided CLI workflow for constructing molecules via five
4
+ different approaches: SMILES input, molecular formula (VSEPR), atom-by-
5
+ atom assembly, preset molecules, and peptide/amino-acid sequences.
6
+
7
+ All output is ASCII-safe (Windows cp1252 compatible).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ # ===================================================================
14
+ # Amino acid lookup tables
15
+ # ===================================================================
16
+
17
+ # One-letter code -> three-letter code
18
+ _ONE_TO_THREE = {
19
+ "G": "GLY", "A": "ALA", "V": "VAL", "L": "LEU", "I": "ILE",
20
+ "P": "PRO", "F": "PHE", "W": "TRP", "M": "MET", "S": "SER",
21
+ "T": "THR", "C": "CYS", "Y": "TYR", "N": "ASN", "Q": "GLN",
22
+ "D": "ASP", "E": "GLU", "K": "LYS", "R": "ARG", "H": "HIS",
23
+ }
24
+
25
+ # Full name -> three-letter code
26
+ _NAME_TO_THREE = {
27
+ "GLYCINE": "GLY", "ALANINE": "ALA", "VALINE": "VAL",
28
+ "LEUCINE": "LEU", "ISOLEUCINE": "ILE", "PROLINE": "PRO",
29
+ "PHENYLALANINE": "PHE", "TRYPTOPHAN": "TRP", "METHIONINE": "MET",
30
+ "SERINE": "SER", "THREONINE": "THR", "CYSTEINE": "CYS",
31
+ "TYROSINE": "TYR", "ASPARAGINE": "ASN", "GLUTAMINE": "GLN",
32
+ "ASPARTATE": "ASP", "GLUTAMATE": "GLU", "LYSINE": "LYS",
33
+ "ARGININE": "ARG", "HISTIDINE": "HIS",
34
+ }
35
+
36
+
37
+ # ===================================================================
38
+ # Helper utilities
39
+ # ===================================================================
40
+
41
+ def _prompt(message: str, default: str = "") -> str:
42
+ """Prompt the user for input. Returns stripped text."""
43
+ try:
44
+ val = input(message).strip()
45
+ except (EOFError, KeyboardInterrupt):
46
+ print()
47
+ return default
48
+ return val if val else default
49
+
50
+
51
+ def _prompt_int(message: str, lo: int, hi: int) -> int | None:
52
+ """Prompt for an integer in [lo, hi]. Returns None on bad input."""
53
+ raw = _prompt(message)
54
+ try:
55
+ val = int(raw)
56
+ if lo <= val <= hi:
57
+ return val
58
+ print(f" Please enter a number between {lo} and {hi}.")
59
+ except ValueError:
60
+ if raw:
61
+ print(f" Invalid number: {raw}")
62
+ return None
63
+
64
+
65
+ def _molecular_formula(mol) -> str:
66
+ """Compute a simple molecular formula string from a Molecule."""
67
+ from collections import Counter
68
+ counts = Counter(atom.symbol for atom in mol.atoms)
69
+ # Hill system: C first, H second, then alphabetical
70
+ parts = []
71
+ for sym in ("C", "H"):
72
+ if sym in counts:
73
+ parts.append(sym if counts[sym] == 1 else f"{sym}{counts[sym]}")
74
+ del counts[sym]
75
+ for sym in sorted(counts):
76
+ parts.append(sym if counts[sym] == 1 else f"{sym}{counts[sym]}")
77
+ return "".join(parts)
78
+
79
+
80
+ def _molecular_weight(mol) -> float:
81
+ """Compute the molecular weight in g/mol."""
82
+ from molbuilder.core.elements import SYMBOL_TO_Z, ELEMENTS
83
+ total = 0.0
84
+ for atom in mol.atoms:
85
+ z = SYMBOL_TO_Z.get(atom.symbol)
86
+ if z is not None:
87
+ total += ELEMENTS[z][2]
88
+ return total
89
+
90
+
91
+ def _validate_element(symbol: str) -> str | None:
92
+ """Validate and normalise an element symbol. Returns None if invalid."""
93
+ from molbuilder.core.elements import SYMBOL_TO_Z
94
+ sym = symbol.strip()
95
+ if not sym:
96
+ return None
97
+ if len(sym) == 1:
98
+ sym = sym.upper()
99
+ elif len(sym) == 2:
100
+ sym = sym[0].upper() + sym[1].lower()
101
+ else:
102
+ return None
103
+ if sym not in SYMBOL_TO_Z:
104
+ return None
105
+ return sym
106
+
107
+
108
+ # ===================================================================
109
+ # Flow 1: Build from SMILES
110
+ # ===================================================================
111
+
112
+ def _flow_smiles():
113
+ """Prompt for a SMILES string, parse it, and return the Molecule."""
114
+ print()
115
+ print(" Enter a SMILES string (e.g. CCO, c1ccccc1, CC(=O)O).")
116
+ smiles_str = _prompt(" SMILES: ")
117
+ if not smiles_str:
118
+ print(" No input provided.")
119
+ return None
120
+
121
+ try:
122
+ from molbuilder.smiles import parse
123
+ mol = parse(smiles_str)
124
+ mol.name = mol.name if mol.name else smiles_str
125
+ print()
126
+ print(f" Successfully parsed: {smiles_str}")
127
+ print(f" Atoms: {len(mol.atoms)} Bonds: {len(mol.bonds)}")
128
+ return mol
129
+ except Exception as exc:
130
+ print(f" Error parsing SMILES: {exc}")
131
+ return None
132
+
133
+
134
+ # ===================================================================
135
+ # Flow 2: Build from molecular formula (VSEPR)
136
+ # ===================================================================
137
+
138
+ def _flow_formula():
139
+ """Prompt for a molecular formula and build via VSEPR."""
140
+ print()
141
+ print(" Enter a molecular formula for a simple molecule")
142
+ print(" (e.g. H2O, CH4, NH3, BF3, SF6).")
143
+ formula = _prompt(" Formula: ")
144
+ if not formula:
145
+ print(" No input provided.")
146
+ return None
147
+
148
+ try:
149
+ from molbuilder.bonding.vsepr import VSEPRMolecule
150
+ vsepr = VSEPRMolecule(formula)
151
+ print()
152
+ print(vsepr.summary())
153
+
154
+ # Convert to a Molecule graph object for the analysis menu
155
+ coords = vsepr.coordinates
156
+ from molbuilder.molecule.graph import Molecule
157
+ mol = Molecule(formula)
158
+ for sym, pos in coords["atom_positions"]:
159
+ mol.add_atom(sym, pos)
160
+ for i, j, order in coords["bonds"]:
161
+ mol.add_bond(i, j, order=order)
162
+ mol.name = formula
163
+ return mol
164
+ except Exception as exc:
165
+ print(f" Error building molecule: {exc}")
166
+ return None
167
+
168
+
169
+ # ===================================================================
170
+ # Flow 3: Step-by-step atom-by-atom builder
171
+ # ===================================================================
172
+
173
+ def _flow_step_by_step():
174
+ """Interactively build a molecule atom by atom."""
175
+ from molbuilder.molecule.graph import Molecule
176
+
177
+ print()
178
+ print(" Step-by-step Molecule Builder")
179
+ print(" " + "-" * 40)
180
+ print()
181
+
182
+ # First atom
183
+ sym = _prompt(" Enter element symbol for the first atom (e.g. C): ")
184
+ sym = _validate_element(sym) if sym else None
185
+ if sym is None:
186
+ print(" Invalid element symbol.")
187
+ return None
188
+
189
+ mol = Molecule("custom molecule")
190
+ mol.add_atom(sym, [0.0, 0.0, 0.0])
191
+ print(f" Added {sym} as atom 0.")
192
+
193
+ while True:
194
+ print()
195
+ n_atoms = len(mol.atoms)
196
+ n_bonds = len(mol.bonds)
197
+ print(f" Current molecule: {n_atoms} atom(s), {n_bonds} bond(s)")
198
+
199
+ # Show atom list compactly
200
+ atom_list = ", ".join(
201
+ f"{a.symbol}[{a.index}]" for a in mol.atoms
202
+ )
203
+ if len(atom_list) > 60:
204
+ atom_list = atom_list[:57] + "..."
205
+ print(f" Atoms: {atom_list}")
206
+ print()
207
+ print(" Options:")
208
+ print(" [1] Add atom bonded to existing atom")
209
+ print(" [2] Add free (unconnected) atom")
210
+ print(" [3] Remove last atom")
211
+ print(" [4] Show current structure")
212
+ print(" [5] Done -- go to analysis")
213
+ print(" [0] Cancel -- back to wizard menu")
214
+ print()
215
+
216
+ choice = _prompt(" Choice: ")
217
+
218
+ if choice == "1":
219
+ _step_add_bonded(mol)
220
+ elif choice == "2":
221
+ _step_add_free(mol)
222
+ elif choice == "3":
223
+ _step_remove_last(mol)
224
+ elif choice == "4":
225
+ print()
226
+ print(mol.summary())
227
+ elif choice == "5":
228
+ if len(mol.atoms) == 0:
229
+ print(" Molecule is empty. Add at least one atom first.")
230
+ continue
231
+ return mol
232
+ elif choice == "0":
233
+ return None
234
+ else:
235
+ print(" Invalid choice. Please enter 0-5.")
236
+
237
+
238
+ def _step_add_bonded(mol):
239
+ """Add an atom bonded to an existing atom."""
240
+ if len(mol.atoms) == 0:
241
+ print(" No atoms yet. Add a free atom first.")
242
+ return
243
+
244
+ # Pick parent atom
245
+ n = len(mol.atoms)
246
+ if n == 1:
247
+ parent_idx = 0
248
+ print(f" Bonding to the only atom: {mol.atoms[0].symbol}[0]")
249
+ else:
250
+ print(f" Available atoms: 0 - {n - 1}")
251
+ parent_val = _prompt_int(f" Bond to which atom index? [0-{n-1}]: ", 0, n - 1)
252
+ if parent_val is None:
253
+ return
254
+ parent_idx = parent_val
255
+
256
+ # Element
257
+ sym = _prompt(" Element symbol for new atom (e.g. H, C, O): ")
258
+ sym = _validate_element(sym) if sym else None
259
+ if sym is None:
260
+ print(" Invalid element symbol.")
261
+ return
262
+
263
+ # Bond order
264
+ order_raw = _prompt(" Bond order (1=single, 2=double, 3=triple) [1]: ", "1")
265
+ try:
266
+ order = int(order_raw)
267
+ if order not in (1, 2, 3):
268
+ print(" Invalid bond order. Using single bond.")
269
+ order = 1
270
+ except ValueError:
271
+ order = 1
272
+
273
+ try:
274
+ idx = mol.add_atom_bonded(sym, parent_idx, bond_order=order)
275
+ print(f" Added {sym}[{idx}] bonded to "
276
+ f"{mol.atoms[parent_idx].symbol}[{parent_idx}] "
277
+ f"(order={order}).")
278
+ except Exception as exc:
279
+ print(f" Error: {exc}")
280
+
281
+
282
+ def _step_add_free(mol):
283
+ """Add a free (unconnected) atom."""
284
+ sym = _prompt(" Element symbol (e.g. C, N, O): ")
285
+ sym = _validate_element(sym) if sym else None
286
+ if sym is None:
287
+ print(" Invalid element symbol.")
288
+ return
289
+
290
+ # Place slightly offset from existing atoms
291
+ offset = len(mol.atoms) * 2.0
292
+ idx = mol.add_atom(sym, [offset, 0.0, 0.0])
293
+ print(f" Added free atom {sym}[{idx}].")
294
+
295
+
296
+ def _step_remove_last(mol):
297
+ """Remove the last atom (and any bonds to it)."""
298
+ if len(mol.atoms) == 0:
299
+ print(" No atoms to remove.")
300
+ return
301
+
302
+ last_idx = len(mol.atoms) - 1
303
+ last_sym = mol.atoms[last_idx].symbol
304
+
305
+ # Remove bonds involving the last atom
306
+ mol.bonds = [b for b in mol.bonds
307
+ if b.atom_i != last_idx and b.atom_j != last_idx]
308
+ # Update adjacency
309
+ for nbr in list(mol._adj.get(last_idx, [])):
310
+ mol._adj[nbr] = [x for x in mol._adj[nbr] if x != last_idx]
311
+ if last_idx in mol._adj:
312
+ del mol._adj[last_idx]
313
+ # Remove the atom
314
+ mol.atoms.pop()
315
+ print(f" Removed {last_sym}[{last_idx}].")
316
+
317
+
318
+ # ===================================================================
319
+ # Flow 4: Preset molecules
320
+ # ===================================================================
321
+
322
+ _PRESETS = [
323
+ ("Ethane (staggered)", "builder", "ethane"),
324
+ ("Butane (anti)", "builder", "butane"),
325
+ ("Cyclohexane (chair)", "builder", "cyclohexane"),
326
+ ("2-Butene (cis/Z)", "builder", "2-butene"),
327
+ ("Chiral molecule (CHFClBr)", "builder", "chiral"),
328
+ ("Ethanol", "smiles", "CCO"),
329
+ ("Acetic acid", "smiles", "CC(=O)O"),
330
+ ("Benzene", "smiles", "c1ccccc1"),
331
+ ("Aspirin", "smiles", "CC(=O)Oc1ccccc1C(=O)O"),
332
+ ("Caffeine", "smiles", "Cn1cnc2c1c(=O)n(c(=O)n2C)C"),
333
+ ]
334
+
335
+
336
+ def _flow_presets():
337
+ """Let the user pick a preset molecule."""
338
+ print()
339
+ print(" Available preset molecules:")
340
+ print()
341
+ for i, (name, _, _) in enumerate(_PRESETS, 1):
342
+ print(f" [{i:>2}] {name}")
343
+ print(f" [ 0] Cancel")
344
+ print()
345
+
346
+ choice = _prompt_int(" Select preset: ", 0, len(_PRESETS))
347
+ if choice is None or choice == 0:
348
+ return None
349
+
350
+ name, kind, key = _PRESETS[choice - 1]
351
+
352
+ try:
353
+ if kind == "builder":
354
+ mol = _build_preset(key)
355
+ else:
356
+ from molbuilder.smiles import parse
357
+ mol = parse(key)
358
+ mol.name = name
359
+ print(f" Built: {mol.name} ({len(mol.atoms)} atoms, {len(mol.bonds)} bonds)")
360
+ return mol
361
+ except Exception as exc:
362
+ print(f" Error building {name}: {exc}")
363
+ return None
364
+
365
+
366
+ def _build_preset(key: str):
367
+ """Build a preset molecule by key."""
368
+ from molbuilder.molecule.builders import (
369
+ build_ethane, build_butane, build_cyclohexane,
370
+ build_2_butene, build_chiral_molecule,
371
+ )
372
+ builders = {
373
+ "ethane": build_ethane,
374
+ "butane": build_butane,
375
+ "cyclohexane": build_cyclohexane,
376
+ "2-butene": build_2_butene,
377
+ "chiral": build_chiral_molecule,
378
+ }
379
+ return builders[key]()
380
+
381
+
382
+ # ===================================================================
383
+ # Flow 5: Peptide builder
384
+ # ===================================================================
385
+
386
+ def _parse_amino_acid_token(token: str):
387
+ """Parse a single amino acid token. Returns an AminoAcidType or None."""
388
+ from molbuilder.molecule.amino_acids import AminoAcidType
389
+
390
+ upper = token.upper()
391
+
392
+ # Three-letter code
393
+ try:
394
+ return AminoAcidType[upper]
395
+ except (KeyError, ValueError):
396
+ pass
397
+
398
+ # One-letter code
399
+ if len(upper) == 1 and upper in _ONE_TO_THREE:
400
+ return AminoAcidType[_ONE_TO_THREE[upper]]
401
+
402
+ # Full name
403
+ if upper in _NAME_TO_THREE:
404
+ return AminoAcidType[_NAME_TO_THREE[upper]]
405
+
406
+ return None
407
+
408
+
409
+ def _flow_peptide():
410
+ """Build a peptide from an amino acid sequence."""
411
+ print()
412
+ print(" Peptide Builder")
413
+ print(" " + "-" * 40)
414
+ print()
415
+ print(" Enter amino acid sequence using any of these formats:")
416
+ print(" - Three-letter codes: ALA GLY PHE")
417
+ print(" - One-letter codes: A G F")
418
+ print(" - Full names: Alanine Glycine Phenylalanine")
419
+ print(" - Or a mix: ALA G Phenylalanine")
420
+ print()
421
+
422
+ raw = _prompt(" Sequence: ")
423
+ if not raw:
424
+ print(" No input provided.")
425
+ return None
426
+
427
+ tokens = raw.split()
428
+ sequence = []
429
+ for tok in tokens:
430
+ aa = _parse_amino_acid_token(tok)
431
+ if aa is None:
432
+ print(f" Unrecognised amino acid: '{tok}'")
433
+ print(" Valid three-letter codes: " + ", ".join(
434
+ t.name for t in __import__(
435
+ "molbuilder.molecule.amino_acids",
436
+ fromlist=["AminoAcidType"]).AminoAcidType))
437
+ return None
438
+ sequence.append(aa)
439
+
440
+ if not sequence:
441
+ print(" Empty sequence.")
442
+ return None
443
+
444
+ names = [aa.name for aa in sequence]
445
+ print(f" Parsed sequence: {' - '.join(names)} ({len(sequence)} residues)")
446
+
447
+ # Optional secondary structure
448
+ print()
449
+ print(" Set secondary structure (optional):")
450
+ print(" [1] Alpha helix")
451
+ print(" [2] Beta sheet")
452
+ print(" [3] Extended")
453
+ print(" [0] None (default backbone angles)")
454
+ print()
455
+ ss_choice = _prompt(" Secondary structure [0]: ", "0")
456
+
457
+ phi_psi = None
458
+ ss_label = ""
459
+ if ss_choice == "1":
460
+ phi_psi = [(-57.0, -47.0)] * len(sequence)
461
+ ss_label = " (alpha helix)"
462
+ elif ss_choice == "2":
463
+ phi_psi = [(-135.0, 135.0)] * len(sequence)
464
+ ss_label = " (beta sheet)"
465
+ elif ss_choice == "3":
466
+ phi_psi = [(-180.0, 180.0)] * len(sequence)
467
+ ss_label = " (extended)"
468
+
469
+ try:
470
+ from molbuilder.molecule.amino_acids import build_peptide
471
+ mol = build_peptide(sequence, phi_psi=phi_psi)
472
+ mol.name = "-".join(names) + ss_label
473
+ print(f" Built peptide: {mol.name}")
474
+ print(f" Atoms: {len(mol.atoms)} Bonds: {len(mol.bonds)}")
475
+ return mol
476
+ except Exception as exc:
477
+ print(f" Error building peptide: {exc}")
478
+ return None
479
+
480
+
481
+ # ===================================================================
482
+ # Analysis menu
483
+ # ===================================================================
484
+
485
+ def _analysis_menu(mol):
486
+ """Post-build analysis options for a molecule."""
487
+ while True:
488
+ print()
489
+ print(" " + "=" * 56)
490
+ print(" Analysis Options")
491
+ print(" " + "=" * 56)
492
+ formula = _molecular_formula(mol)
493
+ print(f" Current: {mol.name} ({len(mol.atoms)} atoms, "
494
+ f"{len(mol.bonds)} bonds)")
495
+ print()
496
+ print(" [ 1] Show molecule summary")
497
+ print(" [ 2] Show molecular formula and weight")
498
+ print(" [ 3] Detect functional groups")
499
+ print(" [ 4] Bond analysis")
500
+ print(" [ 5] Generate SMILES")
501
+ print(" [ 6] Export to file (XYZ/MOL/PDB/JSON)")
502
+ print(" [ 7] Retrosynthetic analysis")
503
+ print(" [ 8] Process engineering analysis")
504
+ print(" [ 9] Visualize 3D (requires display)")
505
+ print(" [10] Build another molecule")
506
+ print(" [ 0] Back to main menu")
507
+ print()
508
+
509
+ choice = _prompt(" Select option: ")
510
+
511
+ if choice == "0":
512
+ return False # back to main menu
513
+ elif choice == "1":
514
+ _analysis_summary(mol)
515
+ elif choice == "2":
516
+ _analysis_formula_weight(mol)
517
+ elif choice == "3":
518
+ _analysis_functional_groups(mol)
519
+ elif choice == "4":
520
+ _analysis_bonds(mol)
521
+ elif choice == "5":
522
+ _analysis_smiles(mol)
523
+ elif choice == "6":
524
+ _analysis_export(mol)
525
+ elif choice == "7":
526
+ _analysis_retrosynthesis(mol)
527
+ elif choice == "8":
528
+ _analysis_process(mol)
529
+ elif choice == "9":
530
+ _analysis_visualize(mol)
531
+ elif choice == "10":
532
+ return True # build another
533
+ else:
534
+ print(" Invalid choice. Please enter 0-10.")
535
+
536
+
537
+ def _analysis_summary(mol):
538
+ """Print the full molecule summary."""
539
+ print()
540
+ print(mol.summary())
541
+
542
+
543
+ def _analysis_formula_weight(mol):
544
+ """Print molecular formula and weight."""
545
+ print()
546
+ formula = _molecular_formula(mol)
547
+ weight = _molecular_weight(mol)
548
+ print(f" Molecular formula: {formula}")
549
+ print(f" Molecular weight: {weight:.3f} g/mol")
550
+ print(f" Atom count: {len(mol.atoms)}")
551
+ print(f" Bond count: {len(mol.bonds)}")
552
+
553
+
554
+ def _analysis_functional_groups(mol):
555
+ """Detect and display functional groups."""
556
+ print()
557
+ try:
558
+ from molbuilder.reactions.functional_group_detect import detect_functional_groups
559
+ groups = detect_functional_groups(mol)
560
+ if groups:
561
+ print(f" Functional groups found ({len(groups)}):")
562
+ for fg in groups:
563
+ atoms_str = ", ".join(str(a) for a in fg.atoms)
564
+ print(f" - {fg.name:<20s} atoms: [{atoms_str}]")
565
+ else:
566
+ print(" No standard functional groups detected.")
567
+ except Exception as exc:
568
+ print(f" Error detecting functional groups: {exc}")
569
+
570
+
571
+ def _analysis_bonds(mol):
572
+ """Display bond analysis."""
573
+ print()
574
+ if not mol.bonds:
575
+ print(" No bonds in molecule.")
576
+ return
577
+
578
+ print(f" Bond Analysis ({len(mol.bonds)} bonds):")
579
+ print(f" {'Atoms':<16} {'Order':>6} {'Length (A)':>11} {'Rotatable':>10}")
580
+ print(f" {'-' * 46}")
581
+ for bond in mol.bonds:
582
+ sa = mol.atoms[bond.atom_i].symbol
583
+ sb = mol.atoms[bond.atom_j].symbol
584
+ sym = {1: "single", 2: "double", 3: "triple"}.get(bond.order, "?")
585
+ dist = mol.distance(bond.atom_i, bond.atom_j)
586
+ rot = "yes" if bond.rotatable else "no"
587
+ label = f"{sa}[{bond.atom_i}]-{sb}[{bond.atom_j}]"
588
+ print(f" {label:<16} {sym:>6} {dist:>11.3f} {rot:>10}")
589
+
590
+ # Summary counts
591
+ from collections import Counter
592
+ order_counts = Counter(b.order for b in mol.bonds)
593
+ print()
594
+ print(" Bond order summary:")
595
+ for order in sorted(order_counts):
596
+ label = {1: "Single", 2: "Double", 3: "Triple"}.get(order, f"Order {order}")
597
+ print(f" {label}: {order_counts[order]}")
598
+ rot_count = sum(1 for b in mol.bonds if b.rotatable)
599
+ print(f" Rotatable bonds: {rot_count}")
600
+
601
+
602
+ def _analysis_smiles(mol):
603
+ """Generate and display a SMILES string."""
604
+ print()
605
+ try:
606
+ from molbuilder.smiles import to_smiles
607
+ smi = to_smiles(mol)
608
+ print(f" SMILES: {smi}")
609
+ except Exception as exc:
610
+ print(f" Error generating SMILES: {exc}")
611
+
612
+
613
+ def _analysis_export(mol):
614
+ """Export molecule to a file in the chosen format."""
615
+ print()
616
+ print(" Export formats:")
617
+ print(" [1] XYZ")
618
+ print(" [2] MOL (V2000)")
619
+ print(" [3] PDB")
620
+ print(" [4] JSON")
621
+ print(" [0] Cancel")
622
+ print()
623
+
624
+ fmt_choice = _prompt(" Format: ")
625
+ if fmt_choice == "0" or not fmt_choice:
626
+ return
627
+
628
+ fmt_map = {
629
+ "1": ("xyz", "xyz"),
630
+ "2": ("mol", "mol"),
631
+ "3": ("pdb", "pdb"),
632
+ "4": ("json", "json"),
633
+ }
634
+ if fmt_choice not in fmt_map:
635
+ print(" Invalid format choice.")
636
+ return
637
+
638
+ fmt_name, ext = fmt_map[fmt_choice]
639
+
640
+ # Suggest a default filename
641
+ safe_name = mol.name.replace(" ", "_").replace("/", "-")
642
+ safe_name = "".join(c for c in safe_name if c.isalnum() or c in "_-.()")
643
+ if not safe_name:
644
+ safe_name = "molecule"
645
+ default_fn = f"{safe_name}.{ext}"
646
+
647
+ filepath = _prompt(f" Filename [{default_fn}]: ", default_fn)
648
+
649
+ try:
650
+ from molbuilder.io import write_xyz, write_mol, write_pdb, write_json
651
+ writers = {
652
+ "xyz": write_xyz,
653
+ "mol": write_mol,
654
+ "pdb": write_pdb,
655
+ "json": write_json,
656
+ }
657
+ writers[fmt_name](mol, filepath)
658
+ print(f" Exported to: {filepath}")
659
+ except Exception as exc:
660
+ print(f" Error exporting: {exc}")
661
+
662
+
663
+ def _analysis_retrosynthesis(mol):
664
+ """Run retrosynthetic analysis (if module available)."""
665
+ print()
666
+ try:
667
+ from molbuilder.reactions import (
668
+ detect_functional_groups, lookup_by_functional_group,
669
+ )
670
+ groups = detect_functional_groups(mol)
671
+ if not groups:
672
+ print(" No functional groups detected for retrosynthetic analysis.")
673
+ return
674
+
675
+ print(" Retrosynthetic Analysis")
676
+ print(" " + "-" * 40)
677
+ print(f" Detected functional groups: {len(groups)}")
678
+ for fg in groups:
679
+ print(f" - {fg.name}")
680
+
681
+ print()
682
+ print(" Suggested disconnections / reactions:")
683
+ found_any = False
684
+ for fg in groups:
685
+ templates = lookup_by_functional_group(fg.name)
686
+ for tmpl in templates:
687
+ print(f" [{fg.name}] {tmpl.name}")
688
+ if tmpl.description:
689
+ print(f" {tmpl.description}")
690
+ found_any = True
691
+
692
+ if not found_any:
693
+ print(" No reaction templates found for detected groups.")
694
+
695
+ except ImportError:
696
+ print(" Retrosynthetic analysis module is not yet available.")
697
+ print(" This feature requires molbuilder.reactions to be fully")
698
+ print(" implemented with retrosynthesis planning capabilities.")
699
+ except Exception as exc:
700
+ print(f" Error in retrosynthetic analysis: {exc}")
701
+
702
+
703
+ def _analysis_process(mol):
704
+ """Run process engineering analysis (if module available)."""
705
+ print()
706
+ try:
707
+ from molbuilder.process.reactor import ReactorType
708
+ from molbuilder.reactions import (
709
+ detect_functional_groups, lookup_by_functional_group,
710
+ )
711
+
712
+ groups = detect_functional_groups(mol)
713
+ print(" Process Engineering Analysis")
714
+ print(" " + "-" * 40)
715
+ formula = _molecular_formula(mol)
716
+ weight = _molecular_weight(mol)
717
+ print(f" Molecule: {mol.name}")
718
+ print(f" Formula: {formula}")
719
+ print(f" Mol weight: {weight:.3f} g/mol")
720
+ print(f" Atoms: {len(mol.atoms)}")
721
+ print(f" Bonds: {len(mol.bonds)}")
722
+ print()
723
+
724
+ if groups:
725
+ print(" Reactive functional groups:")
726
+ for fg in groups:
727
+ print(f" - {fg.name}")
728
+ print()
729
+ print(" Suggested reactor types for synthesis:")
730
+ for rt in ReactorType:
731
+ print(f" - {rt.name}")
732
+ else:
733
+ print(" No reactive functional groups detected.")
734
+ print(" Process analysis requires identifiable reaction sites.")
735
+
736
+ except ImportError:
737
+ print(" Process engineering module is not yet fully available.")
738
+ print(" This feature requires molbuilder.process to be fully")
739
+ print(" implemented with reactor selection and costing.")
740
+ except Exception as exc:
741
+ print(f" Error in process analysis: {exc}")
742
+
743
+
744
+ def _analysis_visualize(mol):
745
+ """Launch 3D visualisation (requires matplotlib and display)."""
746
+ print()
747
+ try:
748
+ from molbuilder.bonding.vsepr import VSEPRMolecule
749
+ from molbuilder.visualization.molecule_viz import visualize_molecule
750
+
751
+ # The visualizer expects a VSEPRMolecule, but our mol has
752
+ # to_coordinates_dict(). Build a thin wrapper.
753
+ coords = mol.to_coordinates_dict()
754
+
755
+ class _MolWrapper:
756
+ """Minimal wrapper to satisfy visualize_molecule signature."""
757
+ def __init__(self, name, coordinates):
758
+ self.formula = name
759
+ self.coordinates = coordinates
760
+ # Provide a minimal axe attribute
761
+ self.axe = type("AXE", (), {
762
+ "molecular_geometry": "custom",
763
+ "electron_geometry": "custom",
764
+ "hybridisation": "---",
765
+ "notation": "---",
766
+ "ideal_bond_angles": [],
767
+ })()
768
+ def computed_bond_angles(self):
769
+ return []
770
+ def summary(self):
771
+ return f" {self.formula}"
772
+
773
+ wrapper = _MolWrapper(mol.name, coords)
774
+ print(" Launching 3D visualisation...")
775
+ print(" (Close the figure window to return to the menu.)")
776
+ visualize_molecule(wrapper)
777
+ except ImportError as exc:
778
+ print(f" Visualisation requires matplotlib: {exc}")
779
+ print(" Install with: pip install matplotlib")
780
+ except Exception as exc:
781
+ print(f" Error during visualisation: {exc}")
782
+
783
+
784
+ # ===================================================================
785
+ # Main wizard entry point
786
+ # ===================================================================
787
+
788
+ def wizard_main():
789
+ """Top-level interactive molecule building wizard."""
790
+ while True:
791
+ print()
792
+ print(" " + "=" * 56)
793
+ print(" Molecule Builder Wizard")
794
+ print(" " + "=" * 56)
795
+ print()
796
+ print(" [1] Build from SMILES string")
797
+ print(" [2] Build from molecular formula (simple molecules)")
798
+ print(" [3] Build step-by-step (atom by atom)")
799
+ print(" [4] Choose from preset molecules")
800
+ print(" [5] Build peptide from amino acid sequence")
801
+ print(" [6] Back to main menu")
802
+ print()
803
+
804
+ choice = _prompt(" Select option: ")
805
+
806
+ mol = None
807
+
808
+ if choice == "1":
809
+ mol = _flow_smiles()
810
+ elif choice == "2":
811
+ mol = _flow_formula()
812
+ elif choice == "3":
813
+ mol = _flow_step_by_step()
814
+ elif choice == "4":
815
+ mol = _flow_presets()
816
+ elif choice == "5":
817
+ mol = _flow_peptide()
818
+ elif choice == "6":
819
+ return
820
+ else:
821
+ print(" Invalid choice. Please enter 1-6.")
822
+ continue
823
+
824
+ if mol is None:
825
+ continue
826
+
827
+ # Enter analysis menu
828
+ build_another = _analysis_menu(mol)
829
+ if not build_another:
830
+ return # back to main menu
831
+ # else: loop back to wizard menu