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,476 @@
1
+ """Safety assessment for synthesis routes.
2
+
3
+ Performs GHS hazard lookup for every reagent in every step, determines
4
+ PPE requirements, engineering controls, emergency procedures, and
5
+ produces a per-step :class:`SafetyAssessment`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, List
12
+
13
+ from molbuilder.reactions.reaction_types import ReactionCategory, ReactionTemplate
14
+ from molbuilder.reactions.reagent_data import REAGENT_DB, get_reagent, Reagent, normalize_reagent_name
15
+
16
+
17
+ # =====================================================================
18
+ # GHS reference tables
19
+ # =====================================================================
20
+
21
+ GHS_PICTOGRAMS: dict[str, str] = {
22
+ "GHS01": "Exploding bomb -- Explosives, self-reactive, organic peroxides",
23
+ "GHS02": "Flame -- Flammable gases/liquids/solids, pyrophoric, self-heating, "
24
+ "emits flammable gas on water contact",
25
+ "GHS03": "Flame over circle -- Oxidizers",
26
+ "GHS04": "Gas cylinder -- Compressed, liquefied, or dissolved gases",
27
+ "GHS05": "Corrosion -- Corrosive to metals, skin corrosion, serious eye damage",
28
+ "GHS06": "Skull and crossbones -- Acute toxicity (fatal or toxic)",
29
+ "GHS07": "Exclamation mark -- Irritant, narcotic, acute toxicity (harmful), "
30
+ "skin sensitizer",
31
+ "GHS08": "Health hazard -- Carcinogenicity, mutagenicity, reproductive toxicity, "
32
+ "respiratory sensitizer, organ toxicity, aspiration hazard",
33
+ "GHS09": "Environment -- Aquatic toxicity",
34
+ }
35
+
36
+ GHS_HAZARD_STATEMENTS: dict[str, str] = {
37
+ # Physical hazards
38
+ "H200": "Unstable explosive",
39
+ "H201": "Explosive; mass explosion hazard",
40
+ "H202": "Explosive; severe projection hazard",
41
+ "H203": "Explosive; fire, blast or projection hazard",
42
+ "H204": "Fire or projection hazard",
43
+ "H205": "May mass explode in fire",
44
+ "H220": "Extremely flammable gas",
45
+ "H221": "Flammable gas",
46
+ "H222": "Extremely flammable aerosol",
47
+ "H223": "Flammable aerosol",
48
+ "H224": "Extremely flammable liquid and vapour",
49
+ "H225": "Highly flammable liquid and vapour",
50
+ "H226": "Flammable liquid and vapour",
51
+ "H227": "Combustible liquid",
52
+ "H228": "Flammable solid",
53
+ "H240": "Heating may cause an explosion",
54
+ "H241": "Heating may cause a fire or explosion",
55
+ "H242": "Heating may cause a fire",
56
+ "H250": "Catches fire spontaneously if exposed to air",
57
+ "H251": "Self-heating; may catch fire",
58
+ "H252": "Self-heating in large quantities; may catch fire",
59
+ "H260": "In contact with water releases flammable gases which may ignite spontaneously",
60
+ "H261": "In contact with water releases flammable gas",
61
+ "H270": "May cause or intensify fire; oxidizer",
62
+ "H271": "May cause fire or explosion; strong oxidizer",
63
+ "H272": "May intensify fire; oxidizer",
64
+ "H280": "Contains gas under pressure; may explode if heated",
65
+ "H281": "Contains refrigerated gas; may cause cryogenic burns or injury",
66
+ "H290": "May be corrosive to metals",
67
+ # Health hazards
68
+ "H300": "Fatal if swallowed",
69
+ "H301": "Toxic if swallowed",
70
+ "H302": "Harmful if swallowed",
71
+ "H304": "May be fatal if swallowed and enters airways",
72
+ "H305": "May be harmful if swallowed and enters airways",
73
+ "H310": "Fatal in contact with skin",
74
+ "H311": "Toxic in contact with skin",
75
+ "H312": "Harmful in contact with skin",
76
+ "H314": "Causes severe skin burns and eye damage",
77
+ "H315": "Causes skin irritation",
78
+ "H317": "May cause an allergic skin reaction",
79
+ "H318": "Causes serious eye damage",
80
+ "H319": "Causes serious eye irritation",
81
+ "H330": "Fatal if inhaled",
82
+ "H331": "Toxic if inhaled",
83
+ "H332": "Harmful if inhaled",
84
+ "H334": "May cause allergy or asthma symptoms or breathing difficulties if inhaled",
85
+ "H335": "May cause respiratory irritation",
86
+ "H336": "May cause drowsiness or dizziness",
87
+ "H340": "May cause genetic defects",
88
+ "H341": "Suspected of causing genetic defects",
89
+ "H350": "May cause cancer",
90
+ "H351": "Suspected of causing cancer",
91
+ "H360": "May damage fertility or the unborn child",
92
+ "H361": "Suspected of damaging fertility or the unborn child",
93
+ "H362": "May cause harm to breast-fed children",
94
+ "H360F": "May damage fertility",
95
+ "H360D": "May damage the unborn child",
96
+ "H360Fd": "May damage fertility; suspected of damaging the unborn child",
97
+ "H360Df": "May damage the unborn child; suspected of damaging fertility",
98
+ "H370": "Causes damage to organs",
99
+ "H371": "May cause damage to organs",
100
+ "H372": "Causes damage to organs through prolonged or repeated exposure",
101
+ "H373": "May cause damage to organs through prolonged or repeated exposure",
102
+ # Environmental hazards
103
+ "H400": "Very toxic to aquatic life",
104
+ "H410": "Very toxic to aquatic life with long lasting effects",
105
+ "H411": "Toxic to aquatic life with long lasting effects",
106
+ "H412": "Harmful to aquatic life with long lasting effects",
107
+ "H413": "May cause long lasting harmful effects to aquatic life",
108
+ "H420": "Harms public health and the environment by destroying ozone in the upper atmosphere",
109
+ }
110
+
111
+
112
+ # =====================================================================
113
+ # Data classes
114
+ # =====================================================================
115
+
116
+ @dataclass
117
+ class HazardInfo:
118
+ """GHS hazard information for a single reagent."""
119
+
120
+ reagent_name: str
121
+ ghs_hazards: list[str]
122
+ ghs_pictograms: list[str]
123
+ hazard_descriptions: list[str]
124
+ pictogram_descriptions: list[str]
125
+
126
+
127
+ @dataclass
128
+ class SafetyAssessment:
129
+ """Complete safety assessment for one synthesis step."""
130
+
131
+ step_number: int
132
+ step_name: str
133
+ hazards: list[HazardInfo]
134
+ ppe_required: list[str]
135
+ engineering_controls: list[str]
136
+ emergency_procedures: list[str]
137
+ incompatible_materials: list[str]
138
+ waste_classification: str
139
+ risk_level: str # "low", "medium", "high"
140
+
141
+
142
+ # =====================================================================
143
+ # Internal helpers
144
+ # =====================================================================
145
+
146
+ def _build_hazard_info(reagent_name: str) -> HazardInfo:
147
+ """Look up a reagent and compile its hazard information."""
148
+ reagent = get_reagent(reagent_name)
149
+ if reagent is None:
150
+ return HazardInfo(
151
+ reagent_name=reagent_name,
152
+ ghs_hazards=[],
153
+ ghs_pictograms=[],
154
+ hazard_descriptions=["Hazard data not available in database"],
155
+ pictogram_descriptions=[],
156
+ )
157
+ haz_descs = [
158
+ GHS_HAZARD_STATEMENTS.get(h, f"Unknown hazard code {h}")
159
+ for h in reagent.ghs_hazards
160
+ ]
161
+ pic_descs = [
162
+ GHS_PICTOGRAMS.get(p, f"Unknown pictogram {p}")
163
+ for p in reagent.ghs_pictograms
164
+ ]
165
+ return HazardInfo(
166
+ reagent_name=reagent.name,
167
+ ghs_hazards=list(reagent.ghs_hazards),
168
+ ghs_pictograms=list(reagent.ghs_pictograms),
169
+ hazard_descriptions=haz_descs,
170
+ pictogram_descriptions=pic_descs,
171
+ )
172
+
173
+
174
+ def _determine_ppe(all_hazards: set[str], all_pictograms: set[str]) -> list[str]:
175
+ """Determine required PPE from the union of hazard codes."""
176
+ ppe: list[str] = []
177
+ # Always require baseline
178
+ ppe.append("Safety goggles (splash-proof)")
179
+ ppe.append("Lab coat")
180
+ ppe.append("Closed-toe shoes")
181
+
182
+ if "GHS05" in all_pictograms or "H314" in all_hazards:
183
+ ppe.append("Chemical-resistant gloves (e.g. butyl rubber or nitrile)")
184
+ ppe.append("Face shield")
185
+ ppe.append("Chemical-resistant apron")
186
+ else:
187
+ ppe.append("Nitrile gloves (double-gloving recommended)")
188
+
189
+ if any(h in all_hazards for h in ("H330", "H331", "H332", "H335")):
190
+ ppe.append("Respiratory protection (fume hood minimum; respirator if outside hood)")
191
+
192
+ if "GHS06" in all_pictograms or "H300" in all_hazards or "H310" in all_hazards:
193
+ ppe.append("Emergency eyewash and safety shower must be accessible within 10 seconds")
194
+
195
+ if "GHS02" in all_pictograms or "H250" in all_hazards or "H260" in all_hazards:
196
+ ppe.append("Fire-resistant lab coat or Nomex coveralls for pyrophoric work")
197
+
198
+ return ppe
199
+
200
+
201
+ def _determine_engineering_controls(
202
+ all_hazards: set[str],
203
+ all_pictograms: set[str],
204
+ template: ReactionTemplate,
205
+ ) -> list[str]:
206
+ """Determine engineering controls."""
207
+ controls: list[str] = []
208
+
209
+ # Fume hood is baseline for organic chemistry
210
+ controls.append("Perform all operations in a well-ventilated fume hood")
211
+
212
+ if "GHS02" in all_pictograms:
213
+ controls.append("Remove all ignition sources; use non-sparking tools")
214
+ controls.append("Ground and bond all containers to prevent static discharge")
215
+
216
+ if "H250" in all_hazards or "H260" in all_hazards:
217
+ controls.append("Schlenk line or glovebox required for air/moisture-sensitive reagents")
218
+
219
+ if "GHS03" in all_pictograms:
220
+ controls.append("Keep oxidizers separated from fuels and organic materials")
221
+ controls.append("Fire suppression system accessible")
222
+
223
+ if any(h in all_hazards for h in ("H340", "H350", "H360")):
224
+ controls.append("Designated area for CMR (carcinogenic/mutagenic/reprotoxic) substances")
225
+ controls.append("HEPA-filtered ventilation for solid handling")
226
+
227
+ if "GHS09" in all_pictograms:
228
+ controls.append("Secondary containment to prevent environmental release")
229
+ controls.append("Do not dispose of via drain; collect all waste")
230
+
231
+ mean_t = (template.temperature_range[0] + template.temperature_range[1]) / 2.0
232
+ if mean_t < -40:
233
+ controls.append("Cryogenic cooling equipment; ensure adequate ventilation for cryogen vapours")
234
+ if mean_t > 150:
235
+ controls.append("High-temperature operation; thermal insulation and burn protection required")
236
+
237
+ return controls
238
+
239
+
240
+ def _determine_emergency_procedures(
241
+ all_hazards: set[str],
242
+ all_pictograms: set[str],
243
+ ) -> list[str]:
244
+ """Emergency response procedures based on hazard profile."""
245
+ procedures: list[str] = []
246
+
247
+ procedures.append(
248
+ "In case of spill: evacuate area, ventilate, absorb with "
249
+ "inert material (vermiculite), dispose as hazardous waste."
250
+ )
251
+
252
+ if "GHS06" in all_pictograms or "H300" in all_hazards:
253
+ procedures.append(
254
+ "Ingestion: Do NOT induce vomiting. Call Poison Control / "
255
+ "emergency services immediately. Rinse mouth with water."
256
+ )
257
+
258
+ if "H310" in all_hazards or "H311" in all_hazards:
259
+ procedures.append(
260
+ "Skin contact: Remove contaminated clothing immediately. "
261
+ "Wash skin with copious water for at least 15 minutes. "
262
+ "Seek medical attention."
263
+ )
264
+
265
+ if "H330" in all_hazards:
266
+ procedures.append(
267
+ "Inhalation: Move victim to fresh air. If not breathing, "
268
+ "administer artificial respiration. Call emergency services."
269
+ )
270
+
271
+ if "H314" in all_hazards or "H318" in all_hazards:
272
+ procedures.append(
273
+ "Eye contact: Flush eyes with water for at least 15 minutes, "
274
+ "lifting upper and lower eyelids. Seek ophthalmological evaluation."
275
+ )
276
+
277
+ if "GHS02" in all_pictograms or "H250" in all_hazards:
278
+ procedures.append(
279
+ "Fire: Use dry chemical, CO2, or sand extinguisher. "
280
+ "Do NOT use water on pyrophoric / water-reactive materials. "
281
+ "Evacuate if fire cannot be controlled immediately."
282
+ )
283
+
284
+ if "H260" in all_hazards:
285
+ procedures.append(
286
+ "Water-reactive material: In case of spill, cover with dry "
287
+ "sand or vermiculite. Do NOT use water."
288
+ )
289
+
290
+ return procedures
291
+
292
+
293
+ def _determine_incompatibilities(template: ReactionTemplate) -> list[str]:
294
+ """List known incompatible material pairs in the step."""
295
+ incompatibilities: list[str] = []
296
+
297
+ reagent_keys = {normalize_reagent_name(r) for r in template.reagents}
298
+
299
+ # Oxidizer + organic / reducer
300
+ oxidizers = {"kmno4", "cro3", "h2o2", "naocl", "mcpba", "hno3", "naio4"}
301
+ reducers = {"nabh4", "lialh4", "dibal_h", "na_nh3", "red_al", "nah"}
302
+ if reagent_keys & oxidizers and reagent_keys & reducers:
303
+ incompatibilities.append("CRITICAL: Oxidizer and reducer present -- do NOT mix directly")
304
+
305
+ # Acid + cyanide
306
+ acids = {"hcl", "h2so4", "hno3", "tfa", "acoh", "bf3_oet2"}
307
+ cyanides = {"nacn"}
308
+ if reagent_keys & acids and reagent_keys & cyanides:
309
+ incompatibilities.append("CRITICAL: Acid + cyanide can liberate HCN gas (fatal)")
310
+
311
+ # Acid + azide
312
+ azides = {"nan3"}
313
+ if reagent_keys & acids and reagent_keys & azides:
314
+ incompatibilities.append("CRITICAL: Acid + azide can liberate HN3 (explosive/toxic)")
315
+
316
+ # Water-reactive + aqueous
317
+ water_reactive = {"lialh4", "nah", "n_buli", "memgbr", "etmgbr", "phmgbr",
318
+ "meli", "phli", "socl2", "accl", "ticl4", "pcl5"}
319
+ if reagent_keys & water_reactive:
320
+ incompatibilities.append(
321
+ "Water-reactive reagent(s) present: ensure strictly anhydrous conditions"
322
+ )
323
+
324
+ # Peroxides + metals
325
+ peroxides = {"h2o2", "mcpba", "tbhp", "dtbp", "benzoyl_peroxide"}
326
+ metals = {"ticl4", "alcl3", "zncl2", "cui", "fecl3", "fecl2"}
327
+ if reagent_keys & peroxides and reagent_keys & metals:
328
+ incompatibilities.append("Peroxide + metal salt: risk of uncontrolled decomposition")
329
+
330
+ # Hypochlorite + acids -> chlorine gas
331
+ hypochlorites = {"naocl", "bleach", "calcium_hypochlorite"}
332
+ if reagent_keys & hypochlorites and reagent_keys & acids:
333
+ incompatibilities.append("CRITICAL: Hypochlorite + acid liberates Cl2 gas (toxic)")
334
+
335
+ # Permanganate + concentrated organics -> fire/explosion
336
+ permanganates = {"kmno4", "namno4"}
337
+ flammable_organics = {"acetone", "diethyl_ether", "thf", "ethanol", "methanol",
338
+ "toluene", "hexane", "pentane", "dcm", "dmf", "dmso"}
339
+ if reagent_keys & permanganates and reagent_keys & flammable_organics:
340
+ incompatibilities.append("Permanganate + flammable organic: fire/explosion risk")
341
+
342
+ # Alkali metals + water -> violent reaction
343
+ alkali_metals = {"na", "k", "li", "cs", "nah", "kh"}
344
+ aqueous = {"h2o", "water", "naoh_aq", "hcl_aq"}
345
+ if reagent_keys & alkali_metals and reagent_keys & aqueous:
346
+ incompatibilities.append("CRITICAL: Alkali metal/hydride + water -> violent H2 evolution")
347
+
348
+ # Chlorine/halogens + ammonia -> toxic gases
349
+ halogens = {"cl2", "br2", "i2", "ncs"}
350
+ ammonia = {"nh3", "nh4oh", "nh4cl"}
351
+ if reagent_keys & halogens and reagent_keys & ammonia:
352
+ incompatibilities.append("CRITICAL: Halogen + ammonia -> toxic NCl3/NBr3")
353
+
354
+ # Strong oxidizers + flammable solvents
355
+ strong_oxidizers = {"kmno4", "cro3", "k2cr2o7", "hno3_conc", "h2o2_conc",
356
+ "naio4", "oxone", "dmp"}
357
+ if reagent_keys & strong_oxidizers and reagent_keys & flammable_organics:
358
+ incompatibilities.append("Strong oxidizer + flammable solvent: fire risk -- use compatible solvent")
359
+
360
+ # Nitrates + organics -> explosion risk
361
+ nitrates = {"nano3", "kno3", "agno3", "nh4no3"}
362
+ if reagent_keys & nitrates and reagent_keys & flammable_organics:
363
+ incompatibilities.append("Nitrate salt + organic solvent: explosion risk at elevated temperature")
364
+
365
+ # Concentrated acids + concentrated bases -> exothermic
366
+ bases = {"naoh", "koh", "lioh", "naoh_conc", "koh_conc", "nah", "naoh_aq"}
367
+ conc_acids = {"h2so4", "hno3", "hcl_conc", "h3po4", "hf"}
368
+ if reagent_keys & conc_acids and reagent_keys & bases:
369
+ incompatibilities.append("Concentrated acid + base: highly exothermic neutralization -- add slowly with cooling")
370
+
371
+ # Grignard/organolithium + protic solvents
372
+ organometallics = {"memgbr", "etmgbr", "phmgbr", "meli", "phli",
373
+ "n_buli", "t_buli", "s_buli", "znet2", "znme2"}
374
+ protic_solvents = {"meoh", "etoh", "h2o", "acoh", "ipoh"}
375
+ if reagent_keys & organometallics and reagent_keys & protic_solvents:
376
+ incompatibilities.append("Organometallic + protic solvent: immediate quench -- use ethereal solvents only")
377
+
378
+ # Perchloric acid + organics -> explosive perchlorates
379
+ perchlorics = {"hclo4", "perchloric_acid"}
380
+ if reagent_keys & perchlorics and reagent_keys & flammable_organics:
381
+ incompatibilities.append("CRITICAL: Perchloric acid + organics -> explosive perchlorate esters")
382
+
383
+ return incompatibilities
384
+
385
+
386
+ def _classify_waste(all_hazards: set[str]) -> str:
387
+ """Classify waste stream based on worst hazard codes."""
388
+ if any(h in all_hazards for h in ("H300", "H310", "H330", "H340", "H350", "H360")):
389
+ return "Hazardous waste -- Category 1 (acute/CMR): requires licensed disposal contractor"
390
+ if any(h in all_hazards for h in ("H301", "H311", "H314", "H331", "H400", "H410")):
391
+ return "Hazardous waste -- Category 2 (toxic/corrosive/ecotoxic): segregated collection"
392
+ if any(h in all_hazards for h in ("H225", "H224", "H220", "H228")):
393
+ return "Hazardous waste -- flammable: store in approved flammable-waste containers"
394
+ return "Non-hazardous chemical waste: collect in appropriate waste stream"
395
+
396
+
397
+ def _calculate_risk_level(all_hazards: set[str], all_pictograms: set[str]) -> str:
398
+ """Assign overall risk level for the step."""
399
+ # High risk: acutely fatal, CMR, pyrophoric, explosive
400
+ high_codes = {"H200", "H201", "H240", "H250", "H300", "H310", "H330",
401
+ "H340", "H350", "H360"}
402
+ if all_hazards & high_codes:
403
+ return "high"
404
+
405
+ # Medium risk: toxic, corrosive, flammable, sensitizer
406
+ medium_codes = {"H301", "H311", "H314", "H331", "H225", "H224",
407
+ "H260", "H334", "H317", "H370", "H372"}
408
+ if all_hazards & medium_codes:
409
+ return "medium"
410
+
411
+ return "low"
412
+
413
+
414
+ # =====================================================================
415
+ # Public API
416
+ # =====================================================================
417
+
418
+ def assess_safety(steps: list[Any]) -> list[SafetyAssessment]:
419
+ """Produce a :class:`SafetyAssessment` for every step in *steps*.
420
+
421
+ Parameters
422
+ ----------
423
+ steps : list
424
+ Each element must have a ``.template`` attribute
425
+ (:class:`ReactionTemplate`) and a ``.precursors`` attribute.
426
+ Duck typing is used.
427
+
428
+ Returns
429
+ -------
430
+ list[SafetyAssessment]
431
+ One assessment per step, in order.
432
+ """
433
+ if not steps:
434
+ return []
435
+ for i, step in enumerate(steps):
436
+ if not hasattr(step, 'template'):
437
+ raise TypeError(
438
+ f"Step {i} must have a 'template' attribute, "
439
+ f"got {type(step).__name__}"
440
+ )
441
+
442
+ assessments: list[SafetyAssessment] = []
443
+
444
+ for idx, step in enumerate(steps):
445
+ template: ReactionTemplate = step.template
446
+
447
+ # Collect hazard info for all reagents + catalysts
448
+ all_reagent_names = list(template.reagents) + list(template.catalysts)
449
+ hazard_infos: list[HazardInfo] = [
450
+ _build_hazard_info(rname) for rname in all_reagent_names
451
+ ]
452
+
453
+ # Aggregate hazard codes and pictograms across all reagents in this step
454
+ all_hazards: set[str] = set()
455
+ all_pictograms: set[str] = set()
456
+ for hi in hazard_infos:
457
+ all_hazards.update(hi.ghs_hazards)
458
+ all_pictograms.update(hi.ghs_pictograms)
459
+
460
+ assessments.append(SafetyAssessment(
461
+ step_number=idx + 1,
462
+ step_name=template.name,
463
+ hazards=hazard_infos,
464
+ ppe_required=_determine_ppe(all_hazards, all_pictograms),
465
+ engineering_controls=_determine_engineering_controls(
466
+ all_hazards, all_pictograms, template,
467
+ ),
468
+ emergency_procedures=_determine_emergency_procedures(
469
+ all_hazards, all_pictograms,
470
+ ),
471
+ incompatible_materials=_determine_incompatibilities(template),
472
+ waste_classification=_classify_waste(all_hazards),
473
+ risk_level=_calculate_risk_level(all_hazards, all_pictograms),
474
+ ))
475
+
476
+ return assessments