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,206 @@
1
+ """Cost estimation report generator.
2
+
3
+ Produces an ASCII cost breakdown report with summary, category
4
+ breakdown table, bar chart, percentage distribution, and notes.
5
+ All output is cp1252-safe.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from molbuilder.reports.text_formatter import (
13
+ section_header,
14
+ subsection_header,
15
+ ascii_table,
16
+ bullet_list,
17
+ key_value_block,
18
+ horizontal_bar,
19
+ format_currency,
20
+ format_percent,
21
+ word_wrap,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from molbuilder.reports import CostEstimateLike
26
+
27
+
28
+ # =====================================================================
29
+ # Helpers
30
+ # =====================================================================
31
+
32
+ _CATEGORY_LABELS: list[tuple[str, str]] = [
33
+ ("raw_materials_usd", "Raw Materials"),
34
+ ("labor_usd", "Labor"),
35
+ ("equipment_usd", "Equipment"),
36
+ ("energy_usd", "Energy"),
37
+ ("waste_disposal_usd", "Waste Disposal"),
38
+ ("overhead_usd", "Overhead"),
39
+ ]
40
+
41
+
42
+ def _safe_float(value, default: float = 0.0) -> float:
43
+ """Coerce *value* to float, returning *default* on failure."""
44
+ if value is None:
45
+ return default
46
+ try:
47
+ return float(value)
48
+ except (TypeError, ValueError):
49
+ return default
50
+
51
+
52
+ # =====================================================================
53
+ # Public API
54
+ # =====================================================================
55
+
56
+ def generate_cost_report(estimate: CostEstimateLike) -> str:
57
+ """Generate an ASCII cost breakdown report.
58
+
59
+ Uses duck typing -- *estimate* should expose:
60
+
61
+ * ``.total_usd`` -- float, total estimated cost
62
+ * ``.per_kg_usd`` -- float, cost per kilogram of product
63
+ * ``.scale_kg`` -- float, production scale in kg
64
+ * ``.breakdown`` -- object with attributes:
65
+ - ``.raw_materials_usd``
66
+ - ``.labor_usd``
67
+ - ``.equipment_usd``
68
+ - ``.energy_usd``
69
+ - ``.waste_disposal_usd``
70
+ - ``.overhead_usd``
71
+ * ``.notes`` -- list[str], assumptions and caveats
72
+ """
73
+ if not hasattr(estimate, 'total_usd'):
74
+ raise TypeError(
75
+ f"estimate must have a 'total_usd' attribute, "
76
+ f"got {type(estimate).__name__}"
77
+ )
78
+
79
+ lines: list[str] = []
80
+
81
+ # ------------------------------------------------------------------
82
+ # 1. Header
83
+ # ------------------------------------------------------------------
84
+ lines.append(section_header("Cost Estimation Report"))
85
+ lines.append("")
86
+
87
+ # ------------------------------------------------------------------
88
+ # 2. Summary
89
+ # ------------------------------------------------------------------
90
+ lines.append(subsection_header("Summary"))
91
+
92
+ total = _safe_float(getattr(estimate, "total_usd", None))
93
+ per_kg = _safe_float(getattr(estimate, "per_kg_usd", None))
94
+ scale = _safe_float(getattr(estimate, "scale_kg", None))
95
+
96
+ summary_pairs = [
97
+ ("Production Scale", f"{scale:.2f} kg"),
98
+ ("Total Cost", format_currency(total)),
99
+ ("Cost per kg", format_currency(per_kg)),
100
+ ]
101
+ lines.append(key_value_block(summary_pairs))
102
+ lines.append("")
103
+
104
+ # ------------------------------------------------------------------
105
+ # 3. Cost Breakdown -- table and bar chart
106
+ # ------------------------------------------------------------------
107
+ lines.append(subsection_header("Cost Breakdown"))
108
+
109
+ breakdown = getattr(estimate, "breakdown", None)
110
+ categories: list[tuple[str, float]] = []
111
+ for attr, label in _CATEGORY_LABELS:
112
+ val = _safe_float(getattr(breakdown, attr, None) if breakdown else None)
113
+ categories.append((label, val))
114
+
115
+ # Table
116
+ tbl_headers = ["Category", "Amount (USD)", "% of Total"]
117
+ tbl_rows: list[list[str]] = []
118
+ for label, amount in categories:
119
+ pct = (amount / total * 100.0) if total > 0 else 0.0
120
+ tbl_rows.append([
121
+ label,
122
+ format_currency(amount),
123
+ format_percent(pct),
124
+ ])
125
+ # Total row
126
+ tbl_rows.append([
127
+ "TOTAL",
128
+ format_currency(total),
129
+ format_percent(100.0) if total > 0 else format_percent(0.0),
130
+ ])
131
+
132
+ lines.append(ascii_table(
133
+ tbl_headers, tbl_rows,
134
+ alignments=["l", "r", "r"],
135
+ min_widths=[16, 14, 10],
136
+ ))
137
+ lines.append("")
138
+
139
+ # Bar chart
140
+ lines.append(subsection_header("Cost Distribution (Bar Chart)"))
141
+ max_amount = max((amt for _, amt in categories), default=0.0)
142
+ max_label_len = max((len(label) for label, _ in categories), default=0)
143
+
144
+ for label, amount in categories:
145
+ bar = horizontal_bar(amount, max_amount, width=35, char="#")
146
+ amount_str = format_currency(amount)
147
+ lines.append(
148
+ f" {label:<{max_label_len}} |{bar}| {amount_str}"
149
+ )
150
+ lines.append("")
151
+
152
+ # ------------------------------------------------------------------
153
+ # 4. Percentage Distribution
154
+ # ------------------------------------------------------------------
155
+ lines.append(subsection_header("Percentage Distribution"))
156
+
157
+ pct_headers = ["Category", "Percentage", "Visual"]
158
+ pct_rows: list[list[str]] = []
159
+ for label, amount in categories:
160
+ pct = (amount / total * 100.0) if total > 0 else 0.0
161
+ bar = horizontal_bar(pct, 100.0, width=20, char="=")
162
+ pct_rows.append([label, format_percent(pct), bar])
163
+
164
+ lines.append(ascii_table(
165
+ pct_headers, pct_rows,
166
+ alignments=["l", "r", "l"],
167
+ min_widths=[16, 10, 20],
168
+ ))
169
+ lines.append("")
170
+
171
+ # ------------------------------------------------------------------
172
+ # 5. Notes and Assumptions
173
+ # ------------------------------------------------------------------
174
+ lines.append(subsection_header("Notes and Assumptions"))
175
+
176
+ notes = getattr(estimate, "notes", None)
177
+ if notes and hasattr(notes, "__iter__") and not isinstance(notes, str):
178
+ note_list = [str(n) for n in notes if n]
179
+ elif notes:
180
+ note_list = [str(notes)]
181
+ else:
182
+ note_list = []
183
+
184
+ if note_list:
185
+ lines.append(bullet_list(note_list))
186
+ else:
187
+ lines.append(" No additional notes.")
188
+ lines.append("")
189
+
190
+ # Standard disclaimer
191
+ lines.append(subsection_header("Disclaimer"))
192
+ disclaimer = (
193
+ "This cost estimate is for planning purposes only. Actual costs "
194
+ "may vary based on supplier pricing, scale-up effects, local "
195
+ "labor rates, and regulatory requirements. All figures are "
196
+ "approximate and should be validated before procurement."
197
+ )
198
+ lines.append(word_wrap(disclaimer, width=66, indent=2))
199
+ lines.append("")
200
+
201
+ # Footer
202
+ lines.append("=" * 70)
203
+ lines.append(" End of Cost Estimation Report")
204
+ lines.append("=" * 70)
205
+
206
+ return "\n".join(lines)
@@ -0,0 +1,279 @@
1
+ """Molecule report generator.
2
+
3
+ Produces a comprehensive ASCII report about a molecule's composition,
4
+ bonding, functional groups, and connectivity. All output is cp1252-safe.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from molbuilder.reports.text_formatter import (
14
+ section_header,
15
+ subsection_header,
16
+ ascii_table,
17
+ bullet_list,
18
+ key_value_block,
19
+ format_percent,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from molbuilder.reports import MoleculeLike
24
+
25
+ # Attempt to import functional-group detection. If the reactions module
26
+ # is unavailable the report simply omits that section.
27
+ try:
28
+ from molbuilder.reactions.functional_group_detect import detect_functional_groups as _detect_fg
29
+ _HAS_FG_DETECT = True
30
+ except Exception:
31
+ _HAS_FG_DETECT = False
32
+
33
+
34
+ # Standard atomic weights for the most common organic elements (g/mol).
35
+ _ATOMIC_WEIGHTS: dict[str, float] = {
36
+ "H": 1.008,
37
+ "He": 4.003,
38
+ "Li": 6.941,
39
+ "Be": 9.012,
40
+ "B": 10.811,
41
+ "C": 12.011,
42
+ "N": 14.007,
43
+ "O": 15.999,
44
+ "F": 18.998,
45
+ "Ne": 20.180,
46
+ "Na": 22.990,
47
+ "Mg": 24.305,
48
+ "Al": 26.982,
49
+ "Si": 28.086,
50
+ "P": 30.974,
51
+ "S": 32.065,
52
+ "Cl": 35.453,
53
+ "Ar": 39.948,
54
+ "K": 39.098,
55
+ "Ca": 40.078,
56
+ "Ti": 47.867,
57
+ "Cr": 51.996,
58
+ "Mn": 54.938,
59
+ "Fe": 55.845,
60
+ "Co": 58.933,
61
+ "Ni": 58.693,
62
+ "Cu": 63.546,
63
+ "Zn": 65.380,
64
+ "Br": 79.904,
65
+ "I": 126.904,
66
+ "Pd": 106.42,
67
+ "Sn": 118.71,
68
+ "Pt": 195.08,
69
+ }
70
+
71
+
72
+ def _hill_formula(counts: dict[str, int]) -> str:
73
+ """Return molecular formula in Hill system order.
74
+
75
+ Hill system: C first, then H, then everything else alphabetically.
76
+ If no carbon, all elements alphabetically.
77
+ """
78
+ parts: list[str] = []
79
+ remaining = dict(counts)
80
+
81
+ if "C" in remaining:
82
+ n = remaining.pop("C")
83
+ parts.append("C" if n == 1 else f"C{n}")
84
+ if "H" in remaining:
85
+ n = remaining.pop("H")
86
+ parts.append("H" if n == 1 else f"H{n}")
87
+
88
+ for elem in sorted(remaining.keys()):
89
+ n = remaining[elem]
90
+ parts.append(elem if n == 1 else f"{elem}{n}")
91
+
92
+ return "".join(parts)
93
+
94
+
95
+ def _molecular_weight(counts: dict[str, int]) -> float:
96
+ """Compute molecular weight from element counts."""
97
+ total = 0.0
98
+ for elem, n in counts.items():
99
+ total += _ATOMIC_WEIGHTS.get(elem, 0.0) * n
100
+ return total
101
+
102
+
103
+ def _bond_order_label(order) -> str:
104
+ """Human-readable bond order label."""
105
+ try:
106
+ order_val = float(order)
107
+ except (TypeError, ValueError):
108
+ return "unknown"
109
+ if order_val == 1.0:
110
+ return "single"
111
+ if order_val == 1.5:
112
+ return "aromatic"
113
+ if order_val == 2.0:
114
+ return "double"
115
+ if order_val == 3.0:
116
+ return "triple"
117
+ return f"order {order_val}"
118
+
119
+
120
+ # =====================================================================
121
+ # Public API
122
+ # =====================================================================
123
+
124
+ def generate_molecule_report(mol: MoleculeLike) -> str:
125
+ """Generate a comprehensive ASCII report about a molecule.
126
+
127
+ Uses duck typing -- *mol* should expose:
128
+
129
+ * ``.name`` -- molecule name (str)
130
+ * ``.atoms`` -- list of atom objects with ``.symbol``
131
+ * ``.bonds`` -- list of bond objects with ``.atom_i``, ``.atom_j``, ``.order``
132
+ * ``.neighbors(idx)`` -- list of bonded-atom indices
133
+ * ``.get_bond(i, j)`` -- Bond object or None
134
+
135
+ Returns a single multi-line string.
136
+ """
137
+ if not hasattr(mol, 'atoms'):
138
+ raise TypeError(
139
+ f"mol must have an 'atoms' attribute, "
140
+ f"got {type(mol).__name__}"
141
+ )
142
+
143
+ lines: list[str] = []
144
+
145
+ # ------------------------------------------------------------------
146
+ # 1. Header
147
+ # ------------------------------------------------------------------
148
+ mol_name = getattr(mol, "name", "Unknown") or "Unknown"
149
+ lines.append(section_header(f"Molecule Report: {mol_name}"))
150
+ lines.append("")
151
+
152
+ atoms = getattr(mol, "atoms", [])
153
+ bonds = getattr(mol, "bonds", [])
154
+
155
+ # ------------------------------------------------------------------
156
+ # 2. Basic Properties
157
+ # ------------------------------------------------------------------
158
+ elem_counts: Counter[str] = Counter()
159
+ for atom in atoms:
160
+ elem_counts[atom.symbol] += 1
161
+
162
+ formula = _hill_formula(dict(elem_counts))
163
+ mw = _molecular_weight(dict(elem_counts))
164
+
165
+ lines.append(subsection_header("Basic Properties"))
166
+ props = [
167
+ ("Molecular Formula", formula),
168
+ ("Molecular Weight", f"{mw:.3f} g/mol"),
169
+ ("Total Atoms", str(len(atoms))),
170
+ ("Total Bonds", str(len(bonds))),
171
+ ]
172
+ lines.append(key_value_block(props))
173
+ lines.append("")
174
+
175
+ # ------------------------------------------------------------------
176
+ # 3. Atom Composition
177
+ # ------------------------------------------------------------------
178
+ lines.append(subsection_header("Atom Composition"))
179
+ comp_headers = ["Element", "Count", "Mass (g/mol)", "Mass %"]
180
+ comp_rows: list[list[str]] = []
181
+ for elem in sorted(elem_counts.keys()):
182
+ count = elem_counts[elem]
183
+ mass = _ATOMIC_WEIGHTS.get(elem, 0.0) * count
184
+ pct = (mass / mw * 100.0) if mw > 0 else 0.0
185
+ comp_rows.append([
186
+ elem,
187
+ str(count),
188
+ f"{mass:.3f}",
189
+ format_percent(pct),
190
+ ])
191
+ lines.append(ascii_table(
192
+ comp_headers, comp_rows,
193
+ alignments=["l", "r", "r", "r"],
194
+ min_widths=[8, 6, 12, 8],
195
+ ))
196
+ lines.append("")
197
+
198
+ # ------------------------------------------------------------------
199
+ # 4. Bond Summary
200
+ # ------------------------------------------------------------------
201
+ lines.append(subsection_header("Bond Summary"))
202
+ order_counts: Counter[str] = Counter()
203
+ for bond in bonds:
204
+ label = _bond_order_label(bond.order)
205
+ order_counts[label] += 1
206
+
207
+ bond_headers = ["Bond Type", "Count"]
208
+ bond_rows = [[btype, str(cnt)]
209
+ for btype, cnt in sorted(order_counts.items())]
210
+ lines.append(ascii_table(
211
+ bond_headers, bond_rows,
212
+ alignments=["l", "r"],
213
+ min_widths=[12, 6],
214
+ ))
215
+ lines.append("")
216
+
217
+ # ------------------------------------------------------------------
218
+ # 5. Functional Groups
219
+ # ------------------------------------------------------------------
220
+ if _HAS_FG_DETECT:
221
+ lines.append(subsection_header("Functional Groups"))
222
+ try:
223
+ groups = _detect_fg(mol)
224
+ if groups:
225
+ fg_names = [g.name for g in groups]
226
+ fg_counts: Counter[str] = Counter(fg_names)
227
+ fg_items = [
228
+ f"{name} (x{cnt})" if cnt > 1 else name
229
+ for name, cnt in sorted(fg_counts.items())
230
+ ]
231
+ lines.append(bullet_list(fg_items))
232
+ else:
233
+ lines.append(" No common functional groups detected.")
234
+ except Exception:
235
+ lines.append(" Functional group detection unavailable.")
236
+ lines.append("")
237
+
238
+ # ------------------------------------------------------------------
239
+ # 6. Connectivity
240
+ # ------------------------------------------------------------------
241
+ lines.append(subsection_header("Connectivity"))
242
+
243
+ # Degree distribution
244
+ degree_counts: Counter[int] = Counter()
245
+ for idx in range(len(atoms)):
246
+ try:
247
+ nbrs = mol.neighbors(idx)
248
+ degree_counts[len(nbrs)] += 1
249
+ except Exception:
250
+ pass
251
+
252
+ if degree_counts:
253
+ deg_headers = ["Degree", "Atom Count"]
254
+ deg_rows = [[str(d), str(c)]
255
+ for d, c in sorted(degree_counts.items())]
256
+ lines.append(ascii_table(
257
+ deg_headers, deg_rows,
258
+ alignments=["r", "r"],
259
+ min_widths=[8, 12],
260
+ ))
261
+ lines.append("")
262
+
263
+ # Ring detection heuristic (Euler formula: rings = bonds - atoms + 1
264
+ # for connected graph). This gives the cyclomatic number.
265
+ n_atoms = len(atoms)
266
+ n_bonds = len(bonds)
267
+ ring_count = n_bonds - n_atoms + 1
268
+ if ring_count > 0:
269
+ lines.append(f" Ring structures detected (cyclomatic number: {ring_count})")
270
+ else:
271
+ lines.append(" No ring structures detected (acyclic molecule)")
272
+ lines.append("")
273
+
274
+ # Footer
275
+ lines.append("=" * 70)
276
+ lines.append(" End of Molecule Report")
277
+ lines.append("=" * 70)
278
+
279
+ return "\n".join(lines)