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,296 @@
1
+ """Safety assessment report generator.
2
+
3
+ Produces a detailed ASCII safety report covering hazards, PPE
4
+ requirements, engineering controls, waste handling, and emergency
5
+ procedures for every step of a synthesis route.
6
+ All output is cp1252-safe.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+ from typing import TYPE_CHECKING
13
+
14
+ from molbuilder.reports.text_formatter import (
15
+ section_header,
16
+ subsection_header,
17
+ ascii_table,
18
+ bullet_list,
19
+ key_value_block,
20
+ word_wrap,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from molbuilder.reports import SafetyAssessmentLike
25
+
26
+
27
+ # =====================================================================
28
+ # Helpers
29
+ # =====================================================================
30
+
31
+ # Emergency contact numbers -- override these for non-US locales
32
+ EMERGENCY_NUMBER = "911"
33
+ POISON_CONTROL_NUMBER = "1-800-222-1222"
34
+
35
+ _RISK_ORDER = {
36
+ "low": 0,
37
+ "moderate": 1,
38
+ "high": 2,
39
+ "very high": 3,
40
+ "extreme": 4,
41
+ }
42
+
43
+
44
+ def _risk_rank(level: str) -> int:
45
+ """Numeric rank for a risk-level string (higher = worse)."""
46
+ return _RISK_ORDER.get(str(level).strip().lower(), -1)
47
+
48
+
49
+ def _safe_list(obj, attr: str) -> list:
50
+ """Return getattr(obj, attr) as a list, or [] on failure."""
51
+ val = getattr(obj, attr, None)
52
+ if val is None:
53
+ return []
54
+ if isinstance(val, (list, tuple)):
55
+ return list(val)
56
+ return [val]
57
+
58
+
59
+ def _safe_str(value, default: str = "N/A") -> str:
60
+ if value is None:
61
+ return default
62
+ s = str(value)
63
+ return s if s else default
64
+
65
+
66
+ # =====================================================================
67
+ # Public API
68
+ # =====================================================================
69
+
70
+ def generate_safety_report(
71
+ assessments: Iterable[SafetyAssessmentLike] | None,
72
+ ) -> str:
73
+ """Generate an ASCII safety report.
74
+
75
+ Uses duck typing -- *assessments* should be a list (or iterable)
76
+ where each item exposes:
77
+
78
+ * ``.step_number`` -- int
79
+ * ``.step_name`` -- str
80
+ * ``.hazards`` -- list of hazard objects, each with:
81
+ - ``.reagent_name`` -- str
82
+ - ``.ghs_hazards`` -- list[str] (e.g. ``["H225", "H301"]``)
83
+ - ``.hazard_descriptions`` -- list[str]
84
+ - ``.pictogram_descriptions`` -- list[str]
85
+ * ``.ppe_required`` -- list[str]
86
+ * ``.engineering_controls`` -- list[str]
87
+ * ``.emergency_procedures`` -- list[str]
88
+ * ``.incompatible_materials`` -- list[str]
89
+ * ``.waste_classification`` -- str
90
+ * ``.risk_level`` -- str (e.g. ``"low"``, ``"high"``)
91
+ """
92
+ if assessments is not None:
93
+ for i, a in enumerate(assessments):
94
+ if not hasattr(a, 'step_number'):
95
+ raise TypeError(
96
+ f"Assessment {i} must have a 'step_number' attribute, "
97
+ f"got {type(a).__name__}"
98
+ )
99
+
100
+ lines: list[str] = []
101
+
102
+ # ------------------------------------------------------------------
103
+ # 1. Header
104
+ # ------------------------------------------------------------------
105
+ lines.append(section_header("Safety Assessment Report"))
106
+ lines.append("")
107
+
108
+ assessment_list = list(assessments) if assessments else []
109
+ if not assessment_list:
110
+ lines.append(" No safety assessments provided.")
111
+ lines.append("")
112
+ lines.append("=" * 70)
113
+ return "\n".join(lines)
114
+
115
+ # ------------------------------------------------------------------
116
+ # 2. Overall Risk Summary
117
+ # ------------------------------------------------------------------
118
+ lines.append(subsection_header("Overall Risk Summary"))
119
+
120
+ risk_levels: list[str] = []
121
+ for a in assessment_list:
122
+ rl = _safe_str(getattr(a, "risk_level", None), "unknown")
123
+ risk_levels.append(rl)
124
+
125
+ # Determine highest risk
126
+ highest_risk = "unknown"
127
+ highest_rank = -1
128
+ for rl in risk_levels:
129
+ rank = _risk_rank(rl)
130
+ if rank > highest_rank:
131
+ highest_rank = rank
132
+ highest_risk = rl
133
+
134
+ overview = [
135
+ ("Total Steps Assessed", str(len(assessment_list))),
136
+ ("Highest Risk Level", highest_risk.upper()),
137
+ ]
138
+ lines.append(key_value_block(overview))
139
+ lines.append("")
140
+
141
+ # Per-step risk overview table
142
+ risk_headers = ["Step", "Name", "Risk Level"]
143
+ risk_rows: list[list[str]] = []
144
+ for a in assessment_list:
145
+ sn = _safe_str(getattr(a, "step_number", None), "?")
146
+ nm = _safe_str(getattr(a, "step_name", None), "Unnamed")
147
+ rl = _safe_str(getattr(a, "risk_level", None), "unknown")
148
+ risk_rows.append([str(sn), nm, rl.upper()])
149
+ lines.append(ascii_table(
150
+ risk_headers, risk_rows,
151
+ alignments=["r", "l", "c"],
152
+ min_widths=[5, 30, 14],
153
+ ))
154
+ lines.append("")
155
+
156
+ # ------------------------------------------------------------------
157
+ # 3. Consolidated PPE Requirements
158
+ # ------------------------------------------------------------------
159
+ lines.append(subsection_header("PPE Requirements (All Steps)"))
160
+ all_ppe: set[str] = set()
161
+ for a in assessment_list:
162
+ for item in _safe_list(a, "ppe_required"):
163
+ all_ppe.add(str(item))
164
+
165
+ if all_ppe:
166
+ lines.append(bullet_list(sorted(all_ppe)))
167
+ else:
168
+ lines.append(" No specific PPE requirements listed.")
169
+ lines.append("")
170
+
171
+ # ------------------------------------------------------------------
172
+ # 4. Per-Step Hazard Details
173
+ # ------------------------------------------------------------------
174
+ lines.append(section_header("Per-Step Hazard Details"))
175
+ lines.append("")
176
+
177
+ for a in assessment_list:
178
+ step_num = _safe_str(getattr(a, "step_number", None), "?")
179
+ step_name = _safe_str(getattr(a, "step_name", None), "Unnamed")
180
+ risk_level = _safe_str(getattr(a, "risk_level", None), "unknown")
181
+
182
+ lines.append(subsection_header(
183
+ f"Step {step_num}: {step_name} [Risk: {risk_level.upper()}]"
184
+ ))
185
+
186
+ # Reagent hazards
187
+ hazards = _safe_list(a, "hazards")
188
+ if hazards:
189
+ for h in hazards:
190
+ reagent_name = _safe_str(getattr(h, "reagent_name", None),
191
+ "Unknown reagent")
192
+ lines.append(f" Reagent: {reagent_name}")
193
+
194
+ ghs = _safe_list(h, "ghs_hazards")
195
+ if ghs:
196
+ lines.append(f" GHS Codes: {', '.join(str(c) for c in ghs)}")
197
+
198
+ descs = _safe_list(h, "hazard_descriptions")
199
+ if descs:
200
+ lines.append(" Hazard Descriptions:")
201
+ lines.append(bullet_list(
202
+ [str(d) for d in descs], indent=6))
203
+
204
+ pictos = _safe_list(h, "pictogram_descriptions")
205
+ if pictos:
206
+ lines.append(" Pictograms:")
207
+ lines.append(bullet_list(
208
+ [str(p) for p in pictos], indent=6))
209
+ lines.append("")
210
+ else:
211
+ lines.append(" No reagent hazards listed.")
212
+ lines.append("")
213
+
214
+ # Engineering controls
215
+ controls = _safe_list(a, "engineering_controls")
216
+ if controls:
217
+ lines.append(" Engineering Controls:")
218
+ lines.append(bullet_list([str(c) for c in controls], indent=4))
219
+ lines.append("")
220
+
221
+ # Incompatible materials
222
+ incompat = _safe_list(a, "incompatible_materials")
223
+ if incompat:
224
+ lines.append(" Incompatible Materials:")
225
+ lines.append(bullet_list([str(m) for m in incompat], indent=4))
226
+ lines.append("")
227
+
228
+ # ------------------------------------------------------------------
229
+ # 5. Waste Handling
230
+ # ------------------------------------------------------------------
231
+ lines.append(section_header("Waste Handling"))
232
+ lines.append("")
233
+
234
+ waste_headers = ["Step", "Name", "Waste Classification"]
235
+ waste_rows: list[list[str]] = []
236
+ for a in assessment_list:
237
+ sn = _safe_str(getattr(a, "step_number", None), "?")
238
+ nm = _safe_str(getattr(a, "step_name", None), "Unnamed")
239
+ wc = _safe_str(getattr(a, "waste_classification", None), "Unclassified")
240
+ waste_rows.append([str(sn), nm, wc])
241
+
242
+ lines.append(ascii_table(
243
+ waste_headers, waste_rows,
244
+ alignments=["r", "l", "l"],
245
+ min_widths=[5, 25, 25],
246
+ ))
247
+ lines.append("")
248
+
249
+ lines.append(" General Waste Disposal Guidance:")
250
+ disposal_notes = [
251
+ "Segregate waste by classification (halogenated, aqueous, "
252
+ "heavy-metal, etc.)",
253
+ "Label all waste containers with contents, hazard class, "
254
+ "and date",
255
+ "Never mix incompatible waste streams",
256
+ "Contact EHS for disposal of unknown or high-hazard waste",
257
+ ]
258
+ lines.append(bullet_list(disposal_notes, indent=4))
259
+ lines.append("")
260
+
261
+ # ------------------------------------------------------------------
262
+ # 6. Emergency Procedures
263
+ # ------------------------------------------------------------------
264
+ lines.append(section_header("Emergency Procedures"))
265
+ lines.append("")
266
+
267
+ all_emergency: list[str] = []
268
+ for a in assessment_list:
269
+ step_num = _safe_str(getattr(a, "step_number", None), "?")
270
+ step_name = _safe_str(getattr(a, "step_name", None), "Unnamed")
271
+ procs = _safe_list(a, "emergency_procedures")
272
+ if procs:
273
+ lines.append(subsection_header(f"Step {step_num}: {step_name}"))
274
+ lines.append(bullet_list([str(p) for p in procs], indent=4))
275
+ lines.append("")
276
+ all_emergency.extend(str(p) for p in procs)
277
+
278
+ if not all_emergency:
279
+ lines.append(" No step-specific emergency procedures listed.")
280
+ lines.append("")
281
+
282
+ lines.append(subsection_header("General Emergency Contacts"))
283
+ contacts = [
284
+ ("Emergency Services", EMERGENCY_NUMBER),
285
+ ("Poison Control", POISON_CONTROL_NUMBER),
286
+ ("Campus/Site EHS", "See posted numbers in laboratory"),
287
+ ]
288
+ lines.append(key_value_block(contacts, indent=4))
289
+ lines.append("")
290
+
291
+ # Footer
292
+ lines.append("=" * 70)
293
+ lines.append(" End of Safety Assessment Report")
294
+ lines.append("=" * 70)
295
+
296
+ return "\n".join(lines)
@@ -0,0 +1,283 @@
1
+ """Synthesis route report generator.
2
+
3
+ Produces a detailed ASCII report of a multi-step synthesis route
4
+ including reagents, conditions, yields, and starting materials.
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
+ word_wrap,
19
+ format_percent,
20
+ format_currency,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from molbuilder.reports import SynthesisRouteLike
25
+
26
+
27
+ # =====================================================================
28
+ # Helpers
29
+ # =====================================================================
30
+
31
+ def _safe_str(value, default: str = "N/A") -> str:
32
+ """Convert *value* to a string, returning *default* if None or empty."""
33
+ if value is None:
34
+ return default
35
+ s = str(value)
36
+ return s if s else default
37
+
38
+
39
+ def _yield_str(yld) -> str:
40
+ """Format a yield value (float 0-100 or None) as a percentage."""
41
+ if yld is None:
42
+ return "N/A"
43
+ try:
44
+ return format_percent(float(yld))
45
+ except (TypeError, ValueError):
46
+ return _safe_str(yld)
47
+
48
+
49
+ def _temp_str(template) -> str:
50
+ """Extract a human-readable temperature string from a template."""
51
+ try:
52
+ lo, hi = template.temperature_range
53
+ if lo == hi:
54
+ return f"{lo:.0f} C"
55
+ return f"{lo:.0f} - {hi:.0f} C"
56
+ except Exception:
57
+ return "N/A"
58
+
59
+
60
+ # =====================================================================
61
+ # Public API
62
+ # =====================================================================
63
+
64
+ def generate_synthesis_report(route: SynthesisRouteLike) -> str:
65
+ """Generate an ASCII synthesis route report.
66
+
67
+ Uses duck typing -- *route* should expose:
68
+
69
+ * ``.target_smiles`` -- SMILES of the target molecule
70
+ * ``.target_name`` -- human-readable target name
71
+ * ``.steps`` -- list of step objects (see below)
72
+ * ``.overall_yield`` -- float (percent)
73
+ * ``.starting_materials`` -- list of material objects with ``.name``, ``.smiles``
74
+ * ``.total_steps`` -- int
75
+ * ``.longest_linear_sequence`` -- int
76
+
77
+ Each **step** should expose:
78
+
79
+ * ``.step_number`` -- int
80
+ * ``.template`` -- ReactionTemplate (has ``.name``, ``.category``,
81
+ ``.reagents``, ``.solvents``, ``.catalysts``, ``.temperature_range``,
82
+ ``.typical_yield``, ``.mechanism``, ``.safety_notes``)
83
+ * ``.precursors`` -- list with ``.name``, ``.smiles``
84
+ * ``.product_smiles`` -- str
85
+ * ``.product_name`` -- str
86
+ * ``.conditions`` -- str or dict
87
+ * ``.expected_yield`` -- float (percent)
88
+ * ``.notes`` -- str
89
+ """
90
+ if not hasattr(route, 'steps'):
91
+ raise TypeError(
92
+ f"route must have a 'steps' attribute, "
93
+ f"got {type(route).__name__}"
94
+ )
95
+
96
+ lines: list[str] = []
97
+
98
+ # ------------------------------------------------------------------
99
+ # 1. Header
100
+ # ------------------------------------------------------------------
101
+ target_name = _safe_str(getattr(route, "target_name", None), "Target")
102
+ lines.append(section_header(f"Synthesis Route: {target_name}"))
103
+ lines.append("")
104
+
105
+ # ------------------------------------------------------------------
106
+ # 2. Route Overview
107
+ # ------------------------------------------------------------------
108
+ lines.append(subsection_header("Route Overview"))
109
+
110
+ target_smiles = _safe_str(getattr(route, "target_smiles", None))
111
+ total_steps = getattr(route, "total_steps", None)
112
+ lls = getattr(route, "longest_linear_sequence", None)
113
+ overall_yield = getattr(route, "overall_yield", None)
114
+
115
+ overview = [
116
+ ("Target", target_name),
117
+ ("Target SMILES", target_smiles),
118
+ ("Total Steps", _safe_str(total_steps)),
119
+ ("Longest Linear Sequence", _safe_str(lls)),
120
+ ("Overall Yield", _yield_str(overall_yield)),
121
+ ]
122
+ lines.append(key_value_block(overview))
123
+ lines.append("")
124
+
125
+ # Starting materials list
126
+ starting_materials = getattr(route, "starting_materials", []) or []
127
+ if starting_materials:
128
+ lines.append(subsection_header("Starting Materials"))
129
+ sm_headers = ["#", "Name", "SMILES"]
130
+ sm_rows: list[list[str]] = []
131
+ for i, sm in enumerate(starting_materials, 1):
132
+ sm_name = _safe_str(getattr(sm, "name", None), f"Material {i}")
133
+ sm_smiles = _safe_str(getattr(sm, "smiles", None))
134
+ sm_rows.append([str(i), sm_name, sm_smiles])
135
+ lines.append(ascii_table(
136
+ sm_headers, sm_rows,
137
+ alignments=["r", "l", "l"],
138
+ min_widths=[3, 25, 25],
139
+ ))
140
+ lines.append("")
141
+
142
+ # ------------------------------------------------------------------
143
+ # 3. Step-by-step Details
144
+ # ------------------------------------------------------------------
145
+ steps = getattr(route, "steps", []) or []
146
+ if steps:
147
+ lines.append(section_header("Step-by-Step Details"))
148
+ lines.append("")
149
+
150
+ for step in steps:
151
+ step_num = getattr(step, "step_number", "?")
152
+ product_name = _safe_str(getattr(step, "product_name", None))
153
+ product_smiles = _safe_str(getattr(step, "product_smiles", None))
154
+
155
+ lines.append(subsection_header(f"Step {step_num}: {product_name}"))
156
+
157
+ # Template info
158
+ template = getattr(step, "template", None)
159
+ if template is not None:
160
+ rxn_name = _safe_str(getattr(template, "name", None), "Unknown reaction")
161
+ named_rxn = getattr(template, "named_reaction", None)
162
+ category = getattr(template, "category", None)
163
+ cat_str = category.name if category is not None else "N/A"
164
+
165
+ step_info = [
166
+ ("Reaction", rxn_name),
167
+ ]
168
+ if named_rxn:
169
+ step_info.append(("Named Reaction", str(named_rxn)))
170
+ step_info.append(("Category", cat_str))
171
+ step_info.append(("Product SMILES", product_smiles))
172
+ lines.append(key_value_block(step_info))
173
+ lines.append("")
174
+
175
+ # Reagents
176
+ reagents = getattr(template, "reagents", []) or []
177
+ if reagents:
178
+ lines.append(" Reagents:")
179
+ lines.append(bullet_list(reagents, indent=4))
180
+ lines.append("")
181
+
182
+ # Solvents
183
+ solvents = getattr(template, "solvents", []) or []
184
+ if solvents:
185
+ lines.append(" Solvents:")
186
+ lines.append(bullet_list(solvents, indent=4))
187
+ lines.append("")
188
+
189
+ # Catalysts
190
+ catalysts = getattr(template, "catalysts", []) or []
191
+ if catalysts:
192
+ lines.append(" Catalysts:")
193
+ lines.append(bullet_list(catalysts, indent=4))
194
+ lines.append("")
195
+
196
+ # Temperature
197
+ lines.append(f" Temperature: {_temp_str(template)}")
198
+
199
+ # Typical yield range from template
200
+ try:
201
+ lo, hi = template.typical_yield
202
+ lines.append(f" Typical Yield Range: {lo:.0f}% - {hi:.0f}%")
203
+ except Exception:
204
+ pass
205
+ else:
206
+ lines.append(key_value_block([
207
+ ("Product SMILES", product_smiles),
208
+ ]))
209
+ lines.append("")
210
+
211
+ # Expected yield for this specific step
212
+ expected_yield = getattr(step, "expected_yield", None)
213
+ lines.append(f" Expected Yield: {_yield_str(expected_yield)}")
214
+
215
+ # Precursors
216
+ precursors = getattr(step, "precursors", []) or []
217
+ if precursors:
218
+ lines.append("")
219
+ lines.append(" Precursors:")
220
+ for p in precursors:
221
+ p_name = _safe_str(getattr(p, "name", None), "Unknown")
222
+ p_smiles = _safe_str(getattr(p, "smiles", None))
223
+ lines.append(f" - {p_name} ({p_smiles})")
224
+
225
+ # Conditions (may be a string or dict)
226
+ conditions = getattr(step, "conditions", None)
227
+ if conditions:
228
+ lines.append("")
229
+ if isinstance(conditions, dict):
230
+ cond_pairs = [(str(k), str(v)) for k, v in conditions.items()]
231
+ lines.append(" Conditions:")
232
+ lines.append(key_value_block(cond_pairs, indent=4))
233
+ else:
234
+ lines.append(word_wrap(f" Conditions: {conditions}", indent=2))
235
+
236
+ # Mechanism description from template
237
+ if template is not None:
238
+ mechanism = getattr(template, "mechanism", "")
239
+ if mechanism:
240
+ lines.append("")
241
+ lines.append(" Mechanism:")
242
+ lines.append(word_wrap(mechanism, indent=4))
243
+
244
+ safety = getattr(template, "safety_notes", "")
245
+ if safety:
246
+ lines.append("")
247
+ lines.append(" Safety Notes:")
248
+ lines.append(word_wrap(safety, indent=4))
249
+
250
+ # Step notes
251
+ notes = getattr(step, "notes", "")
252
+ if notes:
253
+ lines.append("")
254
+ lines.append(" Notes:")
255
+ lines.append(word_wrap(str(notes), indent=4))
256
+
257
+ lines.append("")
258
+
259
+ # ------------------------------------------------------------------
260
+ # 4. Starting Materials Summary Table
261
+ # ------------------------------------------------------------------
262
+ if starting_materials:
263
+ lines.append(section_header("Starting Materials Summary"))
264
+ lines.append("")
265
+ sum_headers = ["Material", "SMILES"]
266
+ sum_rows: list[list[str]] = []
267
+ for sm in starting_materials:
268
+ sm_name = _safe_str(getattr(sm, "name", None), "Unknown")
269
+ sm_smiles = _safe_str(getattr(sm, "smiles", None))
270
+ sum_rows.append([sm_name, sm_smiles])
271
+ lines.append(ascii_table(
272
+ sum_headers, sum_rows,
273
+ alignments=["l", "l"],
274
+ min_widths=[30, 30],
275
+ ))
276
+ lines.append("")
277
+
278
+ # Footer
279
+ lines.append("=" * 70)
280
+ lines.append(" End of Synthesis Report")
281
+ lines.append("=" * 70)
282
+
283
+ return "\n".join(lines)