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,117 @@
1
+ """Dialog windows: SMILES input, file open/save, export settings."""
2
+
3
+ import tkinter as tk
4
+ from tkinter import ttk, filedialog, messagebox
5
+
6
+
7
+ class SmilesDialog:
8
+ """Dialog for entering a SMILES string."""
9
+
10
+ def __init__(self, parent):
11
+ self.result = None
12
+ self.dialog = tk.Toplevel(parent)
13
+ self.dialog.title("Enter SMILES")
14
+ self.dialog.geometry("400x150")
15
+ self.dialog.transient(parent)
16
+ self.dialog.grab_set()
17
+
18
+ ttk.Label(self.dialog, text="SMILES string:").pack(padx=10, pady=(10, 0), anchor="w")
19
+ self.entry = ttk.Entry(self.dialog, width=50)
20
+ self.entry.pack(padx=10, pady=5, fill="x")
21
+ self.entry.focus_set()
22
+ self.entry.bind("<Return>", lambda e: self._ok())
23
+
24
+ ttk.Label(self.dialog, text="Name (optional):").pack(padx=10, anchor="w")
25
+ self.name_entry = ttk.Entry(self.dialog, width=50)
26
+ self.name_entry.pack(padx=10, pady=5, fill="x")
27
+
28
+ bf = ttk.Frame(self.dialog)
29
+ bf.pack(pady=10)
30
+ ttk.Button(bf, text="OK", command=self._ok).pack(side="left", padx=5)
31
+ ttk.Button(bf, text="Cancel", command=self._cancel).pack(side="left", padx=5)
32
+
33
+ parent.wait_window(self.dialog)
34
+
35
+ def _ok(self):
36
+ smi = self.entry.get().strip()
37
+ name = self.name_entry.get().strip()
38
+ if smi:
39
+ self.result = (smi, name)
40
+ self.dialog.destroy()
41
+
42
+ def _cancel(self):
43
+ self.dialog.destroy()
44
+
45
+
46
+ class FormulaDialog:
47
+ """Dialog for entering a molecular formula (for simple VSEPR molecules)."""
48
+
49
+ def __init__(self, parent):
50
+ self.result = None
51
+ self.dialog = tk.Toplevel(parent)
52
+ self.dialog.title("Enter Formula")
53
+ self.dialog.geometry("350x120")
54
+ self.dialog.transient(parent)
55
+ self.dialog.grab_set()
56
+
57
+ ttk.Label(self.dialog, text="Molecular formula (e.g. H2O, CH4):").pack(padx=10, pady=(10, 0), anchor="w")
58
+ self.entry = ttk.Entry(self.dialog, width=30)
59
+ self.entry.pack(padx=10, pady=5)
60
+ self.entry.focus_set()
61
+ self.entry.bind("<Return>", lambda e: self._ok())
62
+
63
+ ttk.Label(self.dialog, text="Charge:").pack(padx=10, anchor="w")
64
+ self.charge = ttk.Entry(self.dialog, width=5)
65
+ self.charge.insert(0, "0")
66
+ self.charge.pack(padx=10, pady=2, anchor="w")
67
+
68
+ bf = ttk.Frame(self.dialog)
69
+ bf.pack(pady=5)
70
+ ttk.Button(bf, text="OK", command=self._ok).pack(side="left", padx=5)
71
+ ttk.Button(bf, text="Cancel", command=self._cancel).pack(side="left", padx=5)
72
+
73
+ parent.wait_window(self.dialog)
74
+
75
+ def _ok(self):
76
+ formula = self.entry.get().strip()
77
+ try:
78
+ charge = int(self.charge.get().strip())
79
+ except ValueError:
80
+ charge = 0
81
+ if formula:
82
+ self.result = (formula, charge)
83
+ self.dialog.destroy()
84
+
85
+ def _cancel(self):
86
+ self.dialog.destroy()
87
+
88
+
89
+ def file_open_dialog(parent, filetypes=None):
90
+ """Show file open dialog, return filepath or None."""
91
+ if filetypes is None:
92
+ filetypes = [
93
+ ("All supported", "*.xyz *.mol *.sdf *.pdb *.json *.smi"),
94
+ ("XYZ files", "*.xyz"),
95
+ ("MOL files", "*.mol"),
96
+ ("SDF files", "*.sdf"),
97
+ ("PDB files", "*.pdb"),
98
+ ("JSON files", "*.json"),
99
+ ("SMILES files", "*.smi"),
100
+ ("All files", "*.*"),
101
+ ]
102
+ return filedialog.askopenfilename(parent=parent, filetypes=filetypes)
103
+
104
+
105
+ def file_save_dialog(parent, default_ext=".xyz", filetypes=None):
106
+ """Show file save dialog, return filepath or None."""
107
+ if filetypes is None:
108
+ filetypes = [
109
+ ("XYZ files", "*.xyz"),
110
+ ("MOL files", "*.mol"),
111
+ ("SDF files", "*.sdf"),
112
+ ("PDB files", "*.pdb"),
113
+ ("JSON files", "*.json"),
114
+ ("SMILES files", "*.smi"),
115
+ ]
116
+ return filedialog.asksaveasfilename(parent=parent, defaultextension=default_ext,
117
+ filetypes=filetypes)
@@ -0,0 +1,118 @@
1
+ """Event handler: atom placement, selection, coordinate computation."""
2
+
3
+ from __future__ import annotations
4
+ import numpy as np
5
+ from molbuilder.molecule.graph import Molecule, Hybridization
6
+ from molbuilder.core.bond_data import bond_length, SP3_ANGLE
7
+ from molbuilder.core.geometry import normalize, available_tetrahedral_dirs
8
+
9
+
10
+ class MolEventHandler:
11
+ """Manages molecule editing state and operations."""
12
+
13
+ def __init__(self):
14
+ self.mol = Molecule("untitled")
15
+ self.selected_atoms: list[int] = [] # selected atom indices
16
+ self.current_element = "C"
17
+ self.current_bond_order = 1
18
+
19
+ def add_atom_free(self, element: str = None) -> int:
20
+ """Add an atom at a computed position.
21
+
22
+ If no atoms exist, place at origin.
23
+ If atoms are selected, place bonded to the first selected atom.
24
+ Otherwise place bonded to the last atom.
25
+ """
26
+ sym = element or self.current_element
27
+
28
+ if len(self.mol.atoms) == 0:
29
+ idx = self.mol.add_atom(sym, [0.0, 0.0, 0.0], Hybridization.SP3)
30
+ return idx
31
+
32
+ # Determine attachment point
33
+ if self.selected_atoms:
34
+ parent = self.selected_atoms[0]
35
+ else:
36
+ parent = len(self.mol.atoms) - 1
37
+
38
+ idx = self.mol.add_atom_bonded(
39
+ sym, parent,
40
+ bond_order=self.current_bond_order,
41
+ hybridization=Hybridization.SP3,
42
+ )
43
+ return idx
44
+
45
+ def add_bond_between_selected(self) -> bool:
46
+ """Add a bond between two selected atoms."""
47
+ if len(self.selected_atoms) < 2:
48
+ return False
49
+ i, j = self.selected_atoms[0], self.selected_atoms[1]
50
+ existing = self.mol.get_bond(i, j)
51
+ if existing:
52
+ return False
53
+ self.mol.add_bond(i, j, order=self.current_bond_order)
54
+ return True
55
+
56
+ def delete_selected(self):
57
+ """Delete selected atoms (rebuild molecule without them)."""
58
+ if not self.selected_atoms:
59
+ return
60
+ keep = [i for i in range(len(self.mol.atoms)) if i not in self.selected_atoms]
61
+ if not keep:
62
+ self.mol = Molecule(self.mol.name)
63
+ self.selected_atoms.clear()
64
+ return
65
+
66
+ remap = {}
67
+ new_mol = Molecule(self.mol.name)
68
+ for new_i, old_i in enumerate(keep):
69
+ a = self.mol.atoms[old_i]
70
+ new_mol.add_atom(a.symbol, a.position.copy(), a.hybridization)
71
+ remap[old_i] = new_i
72
+
73
+ for bond in self.mol.bonds:
74
+ if bond.atom_i in remap and bond.atom_j in remap:
75
+ new_mol.add_bond(remap[bond.atom_i], remap[bond.atom_j],
76
+ bond.order, bond.rotatable)
77
+
78
+ self.mol = new_mol
79
+ self.selected_atoms.clear()
80
+
81
+ def add_hydrogens(self):
82
+ """Add missing hydrogens to all heavy atoms (simple valence fill)."""
83
+ from molbuilder.core.geometry import add_sp3_hydrogens
84
+
85
+ target_valence = {"C": 4, "N": 3, "O": 2, "S": 2, "P": 3, "B": 3}
86
+ for i, atom in enumerate(list(self.mol.atoms)):
87
+ if atom.symbol == "H":
88
+ continue
89
+ target = target_valence.get(atom.symbol)
90
+ if target is None:
91
+ continue
92
+ current = sum(b.order for b in self.mol.bonds
93
+ if b.atom_i == i or b.atom_j == i)
94
+ needed = target - current
95
+ if needed > 0 and atom.symbol == "C":
96
+ add_sp3_hydrogens(self.mol, i, needed)
97
+ elif needed > 0:
98
+ # Generic hydrogen addition
99
+ from molbuilder.core.bond_data import bond_length as bl
100
+ pos = atom.position + np.array([0.0, 0.0, bl(atom.symbol, "H", 1)])
101
+ for _ in range(needed):
102
+ h_idx = self.mol.add_atom_bonded("H", i, bond_order=1, rotatable=False)
103
+
104
+ def clear(self):
105
+ """Remove all atoms."""
106
+ self.mol = Molecule(self.mol.name)
107
+ self.selected_atoms.clear()
108
+
109
+ def toggle_selection(self, atom_idx: int):
110
+ """Toggle selection of an atom."""
111
+ if atom_idx in self.selected_atoms:
112
+ self.selected_atoms.remove(atom_idx)
113
+ else:
114
+ self.selected_atoms.append(atom_idx)
115
+
116
+ def clear_selection(self):
117
+ """Clear atom selection."""
118
+ self.selected_atoms.clear()
@@ -0,0 +1,105 @@
1
+ """Sidebar: molecule properties, analysis options."""
2
+
3
+ import tkinter as tk
4
+ from tkinter import ttk
5
+
6
+
7
+ class MolSidebar(ttk.Frame):
8
+ """Side panel showing molecule properties and analysis controls."""
9
+
10
+ def __init__(self, parent, on_analyze=None):
11
+ super().__init__(parent, width=280)
12
+ self.pack_propagate(False)
13
+ self._on_analyze = on_analyze
14
+ self._build()
15
+
16
+ def _build(self):
17
+ # Info panel
18
+ info = ttk.LabelFrame(self, text="Molecule Info")
19
+ info.pack(fill="x", padx=5, pady=5)
20
+
21
+ self.name_var = tk.StringVar(value="(none)")
22
+ self.formula_var = tk.StringVar(value="")
23
+ self.atoms_var = tk.StringVar(value="0 atoms")
24
+ self.bonds_var = tk.StringVar(value="0 bonds")
25
+ self.mw_var = tk.StringVar(value="MW: --")
26
+
27
+ for var in [self.name_var, self.formula_var, self.atoms_var,
28
+ self.bonds_var, self.mw_var]:
29
+ ttk.Label(info, textvariable=var).pack(anchor="w", padx=5)
30
+
31
+ # Analysis buttons
32
+ af = ttk.LabelFrame(self, text="Analysis")
33
+ af.pack(fill="x", padx=5, pady=5)
34
+ analyses = [
35
+ "Functional Groups",
36
+ "Bond Analysis",
37
+ "Stereochemistry",
38
+ "SMILES",
39
+ "Retrosynthesis",
40
+ "Process Engineering",
41
+ ]
42
+ for name in analyses:
43
+ b = ttk.Button(af, text=name,
44
+ command=lambda n=name: self._analyze(n))
45
+ b.pack(fill="x", padx=5, pady=1)
46
+
47
+ # Export buttons
48
+ ef = ttk.LabelFrame(self, text="Export")
49
+ ef.pack(fill="x", padx=5, pady=5)
50
+ for fmt in ["XYZ", "MOL/SDF", "PDB", "JSON", "SMILES"]:
51
+ b = ttk.Button(ef, text=f"Save as {fmt}",
52
+ command=lambda f=fmt: self._analyze(f"export_{f}"))
53
+ b.pack(fill="x", padx=5, pady=1)
54
+
55
+ # Results text area
56
+ rf = ttk.LabelFrame(self, text="Results")
57
+ rf.pack(fill="both", expand=True, padx=5, pady=5)
58
+ self.results_text = tk.Text(rf, wrap="word", bg="#111122",
59
+ fg="#ccddee", font=("Consolas", 9),
60
+ height=12)
61
+ scroll = ttk.Scrollbar(rf, orient="vertical",
62
+ command=self.results_text.yview)
63
+ self.results_text.configure(yscrollcommand=scroll.set)
64
+ scroll.pack(side="right", fill="y")
65
+ self.results_text.pack(fill="both", expand=True)
66
+
67
+ def update_info(self, mol):
68
+ """Update displayed molecule info."""
69
+ if mol is None:
70
+ self.name_var.set("(none)")
71
+ self.formula_var.set("")
72
+ self.atoms_var.set("0 atoms")
73
+ self.bonds_var.set("0 bonds")
74
+ self.mw_var.set("MW: --")
75
+ return
76
+
77
+ self.name_var.set(mol.name or "(unnamed)")
78
+
79
+ # Compute formula
80
+ from collections import Counter
81
+ counts = Counter(a.symbol for a in mol.atoms)
82
+ formula = ""
83
+ for sym in ["C", "H", "N", "O", "S", "P"]:
84
+ if sym in counts:
85
+ formula += sym + (str(counts[sym]) if counts[sym] > 1 else "")
86
+ del counts[sym]
87
+ for sym in sorted(counts):
88
+ formula += sym + (str(counts[sym]) if counts[sym] > 1 else "")
89
+ self.formula_var.set(formula)
90
+
91
+ self.atoms_var.set(f"{len(mol.atoms)} atoms")
92
+ self.bonds_var.set(f"{len(mol.bonds)} bonds")
93
+
94
+ from molbuilder.core.elements import atomic_weight
95
+ mw = sum(atomic_weight(a.symbol) for a in mol.atoms)
96
+ self.mw_var.set(f"MW: {mw:.2f} g/mol")
97
+
98
+ def show_results(self, text: str):
99
+ """Display analysis results."""
100
+ self.results_text.delete("1.0", "end")
101
+ self.results_text.insert("1.0", text)
102
+
103
+ def _analyze(self, name):
104
+ if self._on_analyze:
105
+ self._on_analyze(name)
@@ -0,0 +1,71 @@
1
+ """Toolbar: element palette, bond tools, mode buttons."""
2
+
3
+ import tkinter as tk
4
+ from tkinter import ttk
5
+
6
+
7
+ class MolToolbar(ttk.Frame):
8
+ """Toolbar with element selector, bond tools, and action buttons."""
9
+
10
+ COMMON_ELEMENTS = ["H", "C", "N", "O", "F", "P", "S", "Cl", "Br", "I"]
11
+ BOND_TYPES = [("Single", 1), ("Double", 2), ("Triple", 3)]
12
+
13
+ def __init__(self, parent, on_element_selected=None, on_bond_selected=None,
14
+ on_action=None):
15
+ super().__init__(parent)
16
+ self._on_element = on_element_selected
17
+ self._on_bond = on_bond_selected
18
+ self._on_action = on_action
19
+ self.selected_element = tk.StringVar(value="C")
20
+ self.selected_bond = tk.IntVar(value=1)
21
+ self._build()
22
+
23
+ def _build(self):
24
+ # Element palette
25
+ ef = ttk.LabelFrame(self, text="Element")
26
+ ef.pack(side="left", padx=5, pady=2)
27
+ for sym in self.COMMON_ELEMENTS:
28
+ b = ttk.Radiobutton(ef, text=sym, value=sym,
29
+ variable=self.selected_element,
30
+ command=self._elem_changed)
31
+ b.pack(side="left", padx=1)
32
+
33
+ # Custom element entry
34
+ self._custom = ttk.Entry(ef, width=4)
35
+ self._custom.pack(side="left", padx=2)
36
+ self._custom.bind("<Return>", self._custom_elem)
37
+
38
+ # Bond type
39
+ bf = ttk.LabelFrame(self, text="Bond")
40
+ bf.pack(side="left", padx=5, pady=2)
41
+ for label, val in self.BOND_TYPES:
42
+ b = ttk.Radiobutton(bf, text=label, value=val,
43
+ variable=self.selected_bond,
44
+ command=self._bond_changed)
45
+ b.pack(side="left", padx=1)
46
+
47
+ # Action buttons
48
+ af = ttk.LabelFrame(self, text="Actions")
49
+ af.pack(side="left", padx=5, pady=2)
50
+ for label in ["Add Atom", "Add Bond", "Delete", "Add H", "Clear"]:
51
+ b = ttk.Button(af, text=label,
52
+ command=lambda l=label: self._action(l))
53
+ b.pack(side="left", padx=2)
54
+
55
+ def _elem_changed(self):
56
+ if self._on_element:
57
+ self._on_element(self.selected_element.get())
58
+
59
+ def _custom_elem(self, event):
60
+ sym = self._custom.get().strip().capitalize()
61
+ if sym:
62
+ self.selected_element.set(sym)
63
+ self._elem_changed()
64
+
65
+ def _bond_changed(self):
66
+ if self._on_bond:
67
+ self._on_bond(self.selected_bond.get())
68
+
69
+ def _action(self, action):
70
+ if self._on_action:
71
+ self._on_action(action)
@@ -0,0 +1 @@
1
+ """File I/O: XYZ, JSON, MOL/SDF, PDB, SMILES."""
@@ -0,0 +1,146 @@
1
+ """JSON serialisation / deserialisation for Molecule objects.
2
+
3
+ Schema::
4
+
5
+ {
6
+ "name": "ethanol",
7
+ "atoms": [
8
+ {"index": 0, "symbol": "C", "position": [0.0, 0.0, 0.0],
9
+ "hybridization": "SP3"},
10
+ ...
11
+ ],
12
+ "bonds": [
13
+ {"atom_i": 0, "atom_j": 1, "order": 1, "rotatable": true},
14
+ ...
15
+ ],
16
+ "properties": {
17
+ "formula": "C2H6O",
18
+ "atom_count": 9,
19
+ "bond_count": 8
20
+ }
21
+ }
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from collections import Counter
28
+
29
+ from molbuilder.molecule.graph import Molecule, Hybridization
30
+
31
+
32
+ # ── Helpers ───────────────────────────────────────────────────────────
33
+
34
+ # Map for serialising Hybridization to a string and back.
35
+ _HYB_TO_STR = {
36
+ Hybridization.SP3: "SP3",
37
+ Hybridization.SP2: "SP2",
38
+ Hybridization.SP: "SP",
39
+ }
40
+ _STR_TO_HYB = {v: k for k, v in _HYB_TO_STR.items()}
41
+
42
+
43
+ def _molecular_formula(mol: Molecule) -> str:
44
+ """Return a Hill-system molecular formula (C first, H second, then
45
+ alphabetical)."""
46
+ counts: Counter[str] = Counter()
47
+ for atom in mol.atoms:
48
+ counts[atom.symbol] += 1
49
+
50
+ parts: list[str] = []
51
+ for sym in ("C", "H"):
52
+ if sym in counts:
53
+ n = counts.pop(sym)
54
+ parts.append(sym if n == 1 else f"{sym}{n}")
55
+ for sym in sorted(counts):
56
+ n = counts[sym]
57
+ parts.append(sym if n == 1 else f"{sym}{n}")
58
+ return "".join(parts)
59
+
60
+
61
+ # ── Molecule -> dict -> JSON ──────────────────────────────────────────
62
+
63
+ def _mol_to_dict(mol: Molecule) -> dict:
64
+ """Convert a Molecule to a JSON-serialisable dictionary."""
65
+ atoms = []
66
+ for atom in mol.atoms:
67
+ hyb_str = _HYB_TO_STR.get(atom.hybridization) if atom.hybridization else None
68
+ atoms.append({
69
+ "index": atom.index,
70
+ "symbol": atom.symbol,
71
+ "position": atom.position.tolist(),
72
+ "hybridization": hyb_str,
73
+ })
74
+
75
+ bonds = []
76
+ for bond in mol.bonds:
77
+ bonds.append({
78
+ "atom_i": bond.atom_i,
79
+ "atom_j": bond.atom_j,
80
+ "order": bond.order,
81
+ "rotatable": bond.rotatable,
82
+ })
83
+
84
+ return {
85
+ "name": mol.name,
86
+ "atoms": atoms,
87
+ "bonds": bonds,
88
+ "properties": {
89
+ "formula": _molecular_formula(mol),
90
+ "atom_count": len(mol.atoms),
91
+ "bond_count": len(mol.bonds),
92
+ },
93
+ }
94
+
95
+
96
+ def _dict_to_mol(data: dict) -> Molecule:
97
+ """Reconstruct a Molecule from a JSON-derived dictionary."""
98
+ mol = Molecule(name=data.get("name", ""))
99
+
100
+ for atom_data in data["atoms"]:
101
+ hyb = None
102
+ hyb_str = atom_data.get("hybridization")
103
+ if hyb_str is not None:
104
+ hyb = _STR_TO_HYB.get(hyb_str)
105
+ mol.add_atom(
106
+ symbol=atom_data["symbol"],
107
+ position=atom_data["position"],
108
+ hybridization=hyb,
109
+ )
110
+
111
+ for bond_data in data["bonds"]:
112
+ mol.add_bond(
113
+ i=bond_data["atom_i"],
114
+ j=bond_data["atom_j"],
115
+ order=bond_data.get("order", 1),
116
+ rotatable=bond_data.get("rotatable", True),
117
+ )
118
+
119
+ return mol
120
+
121
+
122
+ # ── String serialisation ─────────────────────────────────────────────
123
+
124
+ def to_json_string(mol: Molecule, indent: int = 2) -> str:
125
+ """Serialise a Molecule to a JSON string."""
126
+ return json.dumps(_mol_to_dict(mol), indent=indent)
127
+
128
+
129
+ def from_json_string(content: str) -> Molecule:
130
+ """Deserialise a Molecule from a JSON string."""
131
+ data = json.loads(content)
132
+ return _dict_to_mol(data)
133
+
134
+
135
+ # ── File I/O ──────────────────────────────────────────────────────────
136
+
137
+ def write_json(mol: Molecule, filepath: str) -> None:
138
+ """Write a Molecule to a JSON file."""
139
+ with open(filepath, "w") as f:
140
+ f.write(to_json_string(mol))
141
+
142
+ def read_json(filepath: str) -> Molecule:
143
+ """Read a Molecule from a JSON file."""
144
+ with open(filepath, "r") as f:
145
+ content = f.read()
146
+ return from_json_string(content)