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,13 @@
1
+ """Peptide construction: bond formation, phi/psi, secondary structure.
2
+
3
+ The implementation lives in amino_acids.py. This module provides
4
+ convenient direct imports.
5
+ """
6
+ from molbuilder.molecule.amino_acids import (
7
+ form_peptide_bond,
8
+ build_peptide,
9
+ set_phi_psi,
10
+ apply_secondary_structure,
11
+ SecondaryStructure,
12
+ BackboneIndices,
13
+ )
@@ -0,0 +1,6 @@
1
+ """Stereochemistry: R/S and E/Z assignment.
2
+
3
+ These methods live directly on the Molecule class in graph.py.
4
+ This module re-exports them for convenient access.
5
+ """
6
+ from molbuilder.molecule.graph import Stereodescriptor
@@ -0,0 +1,3 @@
1
+ """Process engineering: reactor selection, costing, safety, scale-up."""
2
+
3
+ DEFAULT_SOLVENT_L_PER_KG = 7.0 # Liters of solvent per kg product
@@ -0,0 +1,260 @@
1
+ """Reaction condition optimisation with scale-dependent adjustments.
2
+
3
+ Provides :func:`optimize_conditions` which returns a fully populated
4
+ :class:`ReactionConditions` dataclass tuned for the target production scale.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ from molbuilder.reactions.reaction_types import ReactionCategory, ReactionTemplate
13
+ from molbuilder.reactions.reagent_data import normalize_reagent_name
14
+
15
+
16
+ # =====================================================================
17
+ # Data class
18
+ # =====================================================================
19
+
20
+ @dataclass
21
+ class ReactionConditions:
22
+ """Optimised reaction conditions for a given template and scale."""
23
+
24
+ temperature_C: float
25
+ pressure_atm: float
26
+ solvent: str
27
+ concentration_M: float
28
+ addition_rate: str # "all at once", "dropwise over X min", etc.
29
+ reaction_time_hours: float
30
+ atmosphere: str # "air", "N2", "Ar"
31
+ workup_procedure: str
32
+ notes: str
33
+
34
+
35
+ # =====================================================================
36
+ # Internal helpers
37
+ # =====================================================================
38
+
39
+ # Categories that typically need inert atmosphere
40
+ _INERT_CATEGORIES = {
41
+ ReactionCategory.COUPLING,
42
+ ReactionCategory.REDUCTION,
43
+ ReactionCategory.RADICAL,
44
+ }
45
+
46
+ # Categories requiring slow / controlled addition
47
+ _CONTROLLED_ADDITION_CATEGORIES = {
48
+ ReactionCategory.ADDITION,
49
+ ReactionCategory.CARBONYL,
50
+ ReactionCategory.POLYMERIZATION,
51
+ }
52
+
53
+ # Default concentration (mol/L) by category
54
+ _DEFAULT_CONCENTRATIONS: dict[ReactionCategory, float] = {
55
+ ReactionCategory.SUBSTITUTION: 0.5,
56
+ ReactionCategory.ELIMINATION: 0.3,
57
+ ReactionCategory.ADDITION: 0.5,
58
+ ReactionCategory.OXIDATION: 0.2,
59
+ ReactionCategory.REDUCTION: 0.3,
60
+ ReactionCategory.COUPLING: 0.1,
61
+ ReactionCategory.CARBONYL: 0.5,
62
+ ReactionCategory.PROTECTION: 0.5,
63
+ ReactionCategory.DEPROTECTION: 0.3,
64
+ ReactionCategory.REARRANGEMENT: 0.2,
65
+ ReactionCategory.RADICAL: 0.2,
66
+ ReactionCategory.PERICYCLIC: 1.0,
67
+ ReactionCategory.POLYMERIZATION: 2.0,
68
+ ReactionCategory.MISC: 0.3,
69
+ }
70
+
71
+
72
+ def _select_atmosphere(template: ReactionTemplate) -> str:
73
+ """Choose atmosphere based on reaction category and reagent sensitivity."""
74
+ if template.category in _INERT_CATEGORIES:
75
+ return "N2"
76
+ # Check for air-sensitive reagents
77
+ sensitive_keywords = {"lialh4", "nabh4", "n_buli", "dibal", "grignard",
78
+ "memgbr", "etmgbr", "phmgbr", "lda", "lhmds",
79
+ "nahmds", "khmds", "nah", "red_al"}
80
+ reagent_keys = {normalize_reagent_name(r) for r in template.reagents}
81
+ if reagent_keys & sensitive_keywords:
82
+ return "Ar"
83
+ return "air"
84
+
85
+
86
+ def _addition_rate(template: ReactionTemplate, scale_kg: float) -> str:
87
+ """Determine reagent addition rate.
88
+
89
+ Larger scales require slower, controlled addition for thermal management.
90
+ """
91
+ needs_control = (
92
+ template.category in _CONTROLLED_ADDITION_CATEGORIES
93
+ or scale_kg > 10.0
94
+ )
95
+ if not needs_control:
96
+ if scale_kg < 0.1:
97
+ return "all at once"
98
+ return "portion-wise over 10 min"
99
+
100
+ # Scale-dependent drip rate
101
+ if scale_kg < 1.0:
102
+ return "dropwise over 15-30 min"
103
+ if scale_kg < 10.0:
104
+ return "dropwise over 30-60 min via addition funnel"
105
+ if scale_kg < 100.0:
106
+ return "metered addition over 1-2 h via peristaltic pump"
107
+ return "metered addition over 2-4 h via mass-flow-controlled pump"
108
+
109
+
110
+ def _estimate_reaction_time(template: ReactionTemplate, scale_kg: float) -> float:
111
+ """Estimate reaction time in hours.
112
+
113
+ At larger scale, heat/mass transfer limitations extend reaction time.
114
+ """
115
+ # Base time from yield midpoint heuristic: higher yield takes longer
116
+ _, yield_hi = template.typical_yield
117
+ base_hours = 0.5 + (yield_hi / 100.0) * 2.0 # higher yield -> longer time
118
+
119
+ # Temperature effect: cryogenic reactions are faster but need hold time
120
+ mean_t = (template.temperature_range[0] + template.temperature_range[1]) / 2.0
121
+ if mean_t < -40:
122
+ base_hours *= 0.5 # fast at cryo, but add hold time
123
+ base_hours += 0.5
124
+ elif mean_t > 120:
125
+ base_hours *= 0.7 # faster at high temp
126
+
127
+ # Scale factor: roughly +20% per decade of scale
128
+ if scale_kg > 1.0:
129
+ import math
130
+ decades = math.log10(scale_kg)
131
+ base_hours *= (1.0 + 0.2 * decades)
132
+
133
+ return round(max(0.5, base_hours), 1)
134
+
135
+
136
+ def _workup_procedure(template: ReactionTemplate, scale_kg: float) -> str:
137
+ """Generate a workup procedure string."""
138
+ cat = template.category
139
+ parts: list[str] = []
140
+
141
+ if cat in {ReactionCategory.OXIDATION, ReactionCategory.REDUCTION}:
142
+ parts.append("Quench reaction carefully (ice bath).")
143
+ parts.append("Dilute with water or saturated NH4Cl.")
144
+ parts.append("Extract 3x with ethyl acetate or DCM.")
145
+ parts.append("Wash combined organics with brine, dry over Na2SO4.")
146
+ elif cat == ReactionCategory.COUPLING:
147
+ parts.append("Filter through Celite to remove catalyst residues.")
148
+ parts.append("Wash filtrate with water and brine.")
149
+ parts.append("Dry organic layer over MgSO4, concentrate in vacuo.")
150
+ elif cat in {ReactionCategory.SUBSTITUTION, ReactionCategory.ELIMINATION}:
151
+ parts.append("Pour into ice water to quench.")
152
+ parts.append("Extract with DCM or ethyl acetate (3x).")
153
+ parts.append("Wash with saturated NaHCO3, then brine.")
154
+ parts.append("Dry over Na2SO4, filter, concentrate.")
155
+ elif cat == ReactionCategory.CARBONYL:
156
+ parts.append("Quench with saturated NH4Cl solution.")
157
+ parts.append("Extract with ethyl acetate (3x).")
158
+ parts.append("Wash organic layer with brine, dry over MgSO4.")
159
+ parts.append("Filter and concentrate under reduced pressure.")
160
+ elif cat in {ReactionCategory.PROTECTION, ReactionCategory.DEPROTECTION}:
161
+ parts.append("Dilute with ethyl acetate.")
162
+ parts.append("Wash with 1M HCl, saturated NaHCO3, then brine.")
163
+ parts.append("Dry over Na2SO4, concentrate.")
164
+ elif cat == ReactionCategory.POLYMERIZATION:
165
+ parts.append("Precipitate polymer into cold anti-solvent (methanol or hexanes).")
166
+ parts.append("Filter, wash precipitate, dry under vacuum.")
167
+ else:
168
+ parts.append("Standard aqueous workup: dilute, extract, wash, dry, concentrate.")
169
+
170
+ if scale_kg > 50:
171
+ parts.append("At this scale, use mixer-settler for extraction.")
172
+
173
+ return " ".join(parts)
174
+
175
+
176
+ def _select_solvent(template: ReactionTemplate) -> str:
177
+ """Pick the first listed solvent from the template, or a sensible default."""
178
+ if template.solvents:
179
+ return template.solvents[0]
180
+ defaults = {
181
+ ReactionCategory.COUPLING: "THF",
182
+ ReactionCategory.REDUCTION: "MeOH",
183
+ ReactionCategory.OXIDATION: "DCM",
184
+ ReactionCategory.CARBONYL: "THF",
185
+ ReactionCategory.SUBSTITUTION: "DMF",
186
+ ReactionCategory.ELIMINATION: "THF",
187
+ ReactionCategory.PROTECTION: "DCM",
188
+ ReactionCategory.DEPROTECTION: "DCM",
189
+ ReactionCategory.POLYMERIZATION: "toluene",
190
+ }
191
+ return defaults.get(template.category, "THF")
192
+
193
+
194
+ # =====================================================================
195
+ # Public API
196
+ # =====================================================================
197
+
198
+ def optimize_conditions(
199
+ template: ReactionTemplate,
200
+ scale_kg: float,
201
+ ) -> ReactionConditions:
202
+ """Return optimised :class:`ReactionConditions` for *template* at *scale_kg*.
203
+
204
+ Scale-dependent adjustments include:
205
+ * Slower addition at larger scale for thermal control
206
+ * Lower concentration at large scale to aid mixing
207
+ * Longer reaction time accounting for heat/mass transfer
208
+ * Appropriate workup procedures
209
+ """
210
+ if not hasattr(template, 'temperature_range'):
211
+ raise TypeError(
212
+ f"template must have a 'temperature_range' attribute, "
213
+ f"got {type(template).__name__}"
214
+ )
215
+
216
+ mean_t = (template.temperature_range[0] + template.temperature_range[1]) / 2.0
217
+
218
+ # Concentration adjustment: dilute slightly at large scale for mixing
219
+ base_conc = _DEFAULT_CONCENTRATIONS.get(template.category, 0.3)
220
+ if scale_kg > 100.0:
221
+ concentration = base_conc * 0.7
222
+ elif scale_kg > 10.0:
223
+ concentration = base_conc * 0.85
224
+ else:
225
+ concentration = base_conc
226
+
227
+ # Pressure: normally atmospheric unless high-temp or hydrogenation
228
+ pressure = 1.0
229
+ if mean_t > 150.0:
230
+ pressure = 3.0
231
+ if template.category == ReactionCategory.REDUCTION:
232
+ # Check for hydrogenation reagents
233
+ h2_reagents = {"h2_pd_c", "pd_c_10", "lindlar"}
234
+ reagent_keys = {normalize_reagent_name(r) for r in template.reagents}
235
+ if reagent_keys & h2_reagents:
236
+ pressure = 4.0 # typical balloon to autoclave
237
+
238
+ notes_parts: list[str] = []
239
+ if scale_kg > 100:
240
+ notes_parts.append(
241
+ "Large-scale operation: verify heat removal capacity before start."
242
+ )
243
+ if mean_t < -40:
244
+ notes_parts.append(
245
+ "Cryogenic conditions: use dry ice/acetone or liquid N2 cooling."
246
+ )
247
+ if template.safety_notes:
248
+ notes_parts.append(f"Safety: {template.safety_notes}")
249
+
250
+ return ReactionConditions(
251
+ temperature_C=round(mean_t, 1),
252
+ pressure_atm=pressure,
253
+ solvent=_select_solvent(template),
254
+ concentration_M=round(concentration, 2),
255
+ addition_rate=_addition_rate(template, scale_kg),
256
+ reaction_time_hours=_estimate_reaction_time(template, scale_kg),
257
+ atmosphere=_select_atmosphere(template),
258
+ workup_procedure=_workup_procedure(template, scale_kg),
259
+ notes=" ".join(notes_parts) if notes_parts else "No special notes.",
260
+ )
@@ -0,0 +1,316 @@
1
+ """Process costing for multi-step synthesis routes.
2
+
3
+ Estimates total manufacturing cost by summing raw-material, labour,
4
+ equipment, energy, waste-disposal, and overhead contributions across
5
+ all synthesis steps. Reagent pricing comes from ``REAGENT_DB``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, List
13
+
14
+ from molbuilder.reactions.reaction_types import ReactionCategory, ReactionTemplate
15
+ from molbuilder.reactions.reagent_data import REAGENT_DB, SOLVENT_DB, get_reagent, get_solvent, normalize_reagent_name
16
+ from molbuilder.process import DEFAULT_SOLVENT_L_PER_KG
17
+
18
+
19
+ # =====================================================================
20
+ # Data classes
21
+ # =====================================================================
22
+
23
+ @dataclass
24
+ class CostBreakdown:
25
+ """Itemised cost breakdown in USD."""
26
+
27
+ raw_materials_usd: float
28
+ labor_usd: float
29
+ equipment_usd: float
30
+ energy_usd: float
31
+ waste_disposal_usd: float
32
+ overhead_usd: float
33
+
34
+
35
+ @dataclass
36
+ class CostEstimate:
37
+ """Complete cost estimate for a synthesis route."""
38
+
39
+ total_usd: float
40
+ per_kg_usd: float
41
+ breakdown: CostBreakdown
42
+ scale_kg: float
43
+ notes: list[str]
44
+
45
+
46
+ # =====================================================================
47
+ # Configuration constants (realistic 2024 values)
48
+ # =====================================================================
49
+
50
+ # Labour rate USD/h (loaded, including benefits)
51
+ _LABOR_RATE_USD_H = 75.0
52
+
53
+ # Hours of labour per synthesis step (depends on complexity)
54
+ _BASE_LABOR_HOURS_PER_STEP = 3.0
55
+
56
+ # Energy cost per kWh
57
+ _ENERGY_COST_KWH = 0.10
58
+
59
+ # Waste disposal cost per kg
60
+ _WASTE_DISPOSAL_PER_KG = 2.50
61
+
62
+ # Overhead multiplier on direct costs (insurance, QC, facility)
63
+ _OVERHEAD_FRACTION = 0.25
64
+
65
+ # Equipment depreciation rate (fraction of capital cost per batch)
66
+ _EQUIPMENT_DEPRECIATION_PER_BATCH = 0.002
67
+
68
+ # Default reagent equivalents if not otherwise specified
69
+ _DEFAULT_REAGENT_EQUIV = 1.2
70
+
71
+ # Typical solvent volume: litres per kg of product (shared constant)
72
+ _SOLVENT_L_PER_KG = DEFAULT_SOLVENT_L_PER_KG
73
+
74
+
75
+ # =====================================================================
76
+ # Internal helpers
77
+ # =====================================================================
78
+
79
+ def _normalise_key(name: str) -> str:
80
+ """Normalise a reagent/solvent name to a REAGENT_DB lookup key."""
81
+ return normalize_reagent_name(name)
82
+
83
+
84
+ def _reagent_cost_for_step(template: ReactionTemplate, scale_kg: float) -> float:
85
+ """Estimate raw-material cost for one step.
86
+
87
+ Looks up each reagent in REAGENT_DB. Falls back to a conservative
88
+ default of $100/kg if the reagent is not found.
89
+ """
90
+ total = 0.0
91
+ for rname in template.reagents:
92
+ reagent = get_reagent(rname)
93
+ price_per_kg = reagent.cost_per_kg if reagent and reagent.cost_per_kg > 0 else 100.0
94
+ # Assume reagent mass ~ DEFAULT_EQUIV * product mass (rough stoichiometry)
95
+ reagent_kg = scale_kg * _DEFAULT_REAGENT_EQUIV
96
+ total += price_per_kg * reagent_kg
97
+
98
+ # Catalyst cost (used in smaller amounts: ~0.05 equiv)
99
+ for cname in template.catalysts:
100
+ cat_reagent = get_reagent(cname)
101
+ cat_price = cat_reagent.cost_per_kg if cat_reagent and cat_reagent.cost_per_kg > 0 else 500.0
102
+ catalyst_kg = scale_kg * 0.05
103
+ total += cat_price * catalyst_kg
104
+
105
+ # Solvent cost
106
+ for sname in template.solvents[:1]: # primary solvent only
107
+ solvent = get_solvent(sname)
108
+ cost_per_L = solvent.cost_per_L if solvent and solvent.cost_per_L > 0 else 15.0
109
+ solvent_L = scale_kg * _SOLVENT_L_PER_KG
110
+ total += cost_per_L * solvent_L
111
+
112
+ return total
113
+
114
+
115
+ def _labor_cost_for_step(template: ReactionTemplate, scale_kg: float) -> float:
116
+ """Estimate labour cost for one step.
117
+
118
+ Base 3 h per step. Add time for:
119
+ - Cryogenic work (+1 h)
120
+ - Chromatography purification (+2 h)
121
+ - Scale > 50 kg (+1 h for logistics)
122
+ """
123
+ hours = _BASE_LABOR_HOURS_PER_STEP
124
+
125
+ mean_t = (template.temperature_range[0] + template.temperature_range[1]) / 2.0
126
+ if mean_t < -20:
127
+ hours += 1.0
128
+ if template.category in {ReactionCategory.REARRANGEMENT, ReactionCategory.MISC}:
129
+ hours += 1.0 # complexity
130
+ if scale_kg > 50:
131
+ hours += 1.0 # material handling
132
+ if scale_kg > 200:
133
+ hours += 1.0 # additional logistics
134
+
135
+ return hours * _LABOR_RATE_USD_H
136
+
137
+
138
+ def _equipment_cost_for_step(template: ReactionTemplate, scale_kg: float) -> float:
139
+ """Depreciation-based equipment cost per batch.
140
+
141
+ Uses six-tenths rule for vessel capital cost then applies per-batch
142
+ depreciation fraction.
143
+ """
144
+ volume_L = scale_kg * 6.0 # rough vessel sizing
145
+ base_capital = 15_000.0 # reference 100 L vessel
146
+ scale_factor = (max(volume_L, 1.0) / 100.0) ** 0.6
147
+ capital = base_capital * max(scale_factor, 0.3)
148
+ return capital * _EQUIPMENT_DEPRECIATION_PER_BATCH
149
+
150
+
151
+ def _energy_cost_for_step(template: ReactionTemplate, scale_kg: float) -> float:
152
+ """Estimate energy consumption in USD.
153
+
154
+ Heating/cooling energy based on temperature delta, agitation power,
155
+ and distillation energy for solvent removal.
156
+ """
157
+ mean_t = (template.temperature_range[0] + template.temperature_range[1]) / 2.0
158
+ delta_t = abs(mean_t - 20.0) # from ambient
159
+
160
+ # Heating or cooling energy (kWh) ~ mass * Cp * deltaT / 3600
161
+ # Approximate solution Cp ~ 2 kJ/(kg*K), plus safety factor
162
+ mass_kg = scale_kg * _SOLVENT_L_PER_KG # total charge mass approx
163
+ energy_kwh = mass_kg * 2.0 * delta_t / 3600.0
164
+
165
+ # Agitation: ~0.5 kW per 100 L for 2 h
166
+ volume_L = scale_kg * 6.0
167
+ agitation_kwh = (volume_L / 100.0) * 0.5 * 2.0
168
+
169
+ # Solvent removal (rotovap / distillation): ~0.3 kWh per L
170
+ solvent_removal_kwh = scale_kg * _SOLVENT_L_PER_KG * 0.3
171
+
172
+ total_kwh = energy_kwh + agitation_kwh + solvent_removal_kwh
173
+ return total_kwh * _ENERGY_COST_KWH
174
+
175
+
176
+ def _waste_cost_for_step(template: ReactionTemplate, scale_kg: float) -> float:
177
+ """Estimate waste disposal cost.
178
+
179
+ Waste includes spent solvent, aqueous washes, and by-products.
180
+ Typically 5-10x product mass.
181
+ """
182
+ # Hazardous waste multiplier
183
+ hazardous = False
184
+ for rname in template.reagents:
185
+ reagent = get_reagent(rname)
186
+ if reagent:
187
+ dangerous_codes = {"H300", "H310", "H330", "H340", "H350", "H360"}
188
+ if set(reagent.ghs_hazards) & dangerous_codes:
189
+ hazardous = True
190
+ break
191
+
192
+ waste_multiplier = 8.0 if hazardous else 5.0
193
+ waste_kg = scale_kg * waste_multiplier
194
+ disposal_rate = _WASTE_DISPOSAL_PER_KG * (2.0 if hazardous else 1.0)
195
+ return waste_kg * disposal_rate
196
+
197
+
198
+ # =====================================================================
199
+ # Public API
200
+ # =====================================================================
201
+
202
+ def estimate_cost(
203
+ steps: list[Any],
204
+ scale_kg: float,
205
+ ) -> CostEstimate:
206
+ """Estimate total manufacturing cost for a synthesis route.
207
+
208
+ Parameters
209
+ ----------
210
+ steps : list
211
+ Each element is expected to have a ``.template`` attribute
212
+ (:class:`ReactionTemplate`) and a ``.precursors`` attribute
213
+ (used for context but not directly priced -- precursor costs are
214
+ captured through the reagent lookup on each template).
215
+ Duck typing is used; no specific class is required.
216
+ scale_kg : float
217
+ Target production scale in kilograms of final product.
218
+
219
+ Returns
220
+ -------
221
+ CostEstimate
222
+ Complete cost estimate with per-kg pricing and itemised breakdown.
223
+ """
224
+ if not steps:
225
+ breakdown = CostBreakdown(0, 0, 0, 0, 0, 0)
226
+ return CostEstimate(
227
+ total_usd=0.0, per_kg_usd=0.0,
228
+ breakdown=breakdown, scale_kg=scale_kg,
229
+ notes=["No synthesis steps provided; returning zero cost."],
230
+ )
231
+ for i, step in enumerate(steps):
232
+ if not hasattr(step, 'template'):
233
+ raise TypeError(
234
+ f"Step {i} must have a 'template' attribute, "
235
+ f"got {type(step).__name__}"
236
+ )
237
+
238
+ if scale_kg <= 0:
239
+ scale_kg = 0.001 # avoid division by zero
240
+
241
+ total_materials = 0.0
242
+ total_labor = 0.0
243
+ total_equipment = 0.0
244
+ total_energy = 0.0
245
+ total_waste = 0.0
246
+ notes: list[str] = []
247
+
248
+ num_steps = len(steps)
249
+ if num_steps == 0:
250
+ notes.append("No synthesis steps provided; returning zero cost.")
251
+ breakdown = CostBreakdown(0, 0, 0, 0, 0, 0)
252
+ return CostEstimate(
253
+ total_usd=0.0, per_kg_usd=0.0,
254
+ breakdown=breakdown, scale_kg=scale_kg, notes=notes,
255
+ )
256
+
257
+ # Cumulative yield loss: each step reduces effective scale
258
+ cumulative_scale = scale_kg
259
+ for step in reversed(steps):
260
+ template = step.template
261
+ # Average yield for this step
262
+ avg_yield = (template.typical_yield[0] + template.typical_yield[1]) / 2.0 / 100.0
263
+ if avg_yield <= 0:
264
+ avg_yield = 0.5
265
+ # Scale needed at this step to deliver cumulative_scale
266
+ step_scale = cumulative_scale / avg_yield
267
+
268
+ total_materials += _reagent_cost_for_step(template, step_scale)
269
+ total_labor += _labor_cost_for_step(template, step_scale)
270
+ total_equipment += _equipment_cost_for_step(template, step_scale)
271
+ total_energy += _energy_cost_for_step(template, step_scale)
272
+ total_waste += _waste_cost_for_step(template, step_scale)
273
+
274
+ cumulative_scale = step_scale
275
+
276
+ # Overhead
277
+ direct_costs = total_materials + total_labor + total_equipment + total_energy + total_waste
278
+ total_overhead = direct_costs * _OVERHEAD_FRACTION
279
+
280
+ total_usd = direct_costs + total_overhead
281
+
282
+ # Notes
283
+ notes.append(f"Costing based on {num_steps} synthesis step(s) at {scale_kg:.2f} kg scale.")
284
+ if scale_kg < 0.1:
285
+ notes.append(
286
+ "Lab-scale pricing; per-kg cost will decrease significantly at larger scale."
287
+ )
288
+ if scale_kg > 100:
289
+ notes.append(
290
+ "Bulk pricing may apply for reagents; actual costs could be 20-40% lower."
291
+ )
292
+ if any(
293
+ get_reagent(r) and get_reagent(r).cost_per_kg > 5000 # type: ignore[union-attr]
294
+ for step in steps for r in step.template.reagents
295
+ ):
296
+ notes.append(
297
+ "One or more expensive reagents detected (>$5000/kg); "
298
+ "consider catalyst recycling or alternative chemistry."
299
+ )
300
+
301
+ breakdown = CostBreakdown(
302
+ raw_materials_usd=round(total_materials, 2),
303
+ labor_usd=round(total_labor, 2),
304
+ equipment_usd=round(total_equipment, 2),
305
+ energy_usd=round(total_energy, 2),
306
+ waste_disposal_usd=round(total_waste, 2),
307
+ overhead_usd=round(total_overhead, 2),
308
+ )
309
+
310
+ return CostEstimate(
311
+ total_usd=round(total_usd, 2),
312
+ per_kg_usd=round(total_usd / scale_kg, 2),
313
+ breakdown=breakdown,
314
+ scale_kg=scale_kg,
315
+ notes=notes,
316
+ )