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.
- molbuilder/__init__.py +8 -0
- molbuilder/__main__.py +6 -0
- molbuilder/atomic/__init__.py +4 -0
- molbuilder/atomic/bohr.py +235 -0
- molbuilder/atomic/quantum_atom.py +334 -0
- molbuilder/atomic/quantum_numbers.py +196 -0
- molbuilder/atomic/wavefunctions.py +297 -0
- molbuilder/bonding/__init__.py +4 -0
- molbuilder/bonding/covalent.py +442 -0
- molbuilder/bonding/lewis.py +347 -0
- molbuilder/bonding/vsepr.py +433 -0
- molbuilder/cli/__init__.py +1 -0
- molbuilder/cli/demos.py +516 -0
- molbuilder/cli/menu.py +127 -0
- molbuilder/cli/wizard.py +831 -0
- molbuilder/core/__init__.py +6 -0
- molbuilder/core/bond_data.py +170 -0
- molbuilder/core/constants.py +51 -0
- molbuilder/core/element_properties.py +183 -0
- molbuilder/core/elements.py +181 -0
- molbuilder/core/geometry.py +232 -0
- molbuilder/gui/__init__.py +2 -0
- molbuilder/gui/app.py +286 -0
- molbuilder/gui/canvas3d.py +115 -0
- molbuilder/gui/dialogs.py +117 -0
- molbuilder/gui/event_handler.py +118 -0
- molbuilder/gui/sidebar.py +105 -0
- molbuilder/gui/toolbar.py +71 -0
- molbuilder/io/__init__.py +1 -0
- molbuilder/io/json_io.py +146 -0
- molbuilder/io/mol_sdf.py +169 -0
- molbuilder/io/pdb.py +184 -0
- molbuilder/io/smiles_io.py +47 -0
- molbuilder/io/xyz.py +103 -0
- molbuilder/molecule/__init__.py +2 -0
- molbuilder/molecule/amino_acids.py +919 -0
- molbuilder/molecule/builders.py +257 -0
- molbuilder/molecule/conformations.py +70 -0
- molbuilder/molecule/functional_groups.py +484 -0
- molbuilder/molecule/graph.py +712 -0
- molbuilder/molecule/peptides.py +13 -0
- molbuilder/molecule/stereochemistry.py +6 -0
- molbuilder/process/__init__.py +3 -0
- molbuilder/process/conditions.py +260 -0
- molbuilder/process/costing.py +316 -0
- molbuilder/process/purification.py +285 -0
- molbuilder/process/reactor.py +297 -0
- molbuilder/process/safety.py +476 -0
- molbuilder/process/scale_up.py +427 -0
- molbuilder/process/solvent_systems.py +204 -0
- molbuilder/reactions/__init__.py +3 -0
- molbuilder/reactions/functional_group_detect.py +728 -0
- molbuilder/reactions/knowledge_base.py +1716 -0
- molbuilder/reactions/reaction_types.py +102 -0
- molbuilder/reactions/reagent_data.py +1248 -0
- molbuilder/reactions/retrosynthesis.py +1430 -0
- molbuilder/reactions/synthesis_route.py +377 -0
- molbuilder/reports/__init__.py +158 -0
- molbuilder/reports/cost_report.py +206 -0
- molbuilder/reports/molecule_report.py +279 -0
- molbuilder/reports/safety_report.py +296 -0
- molbuilder/reports/synthesis_report.py +283 -0
- molbuilder/reports/text_formatter.py +170 -0
- molbuilder/smiles/__init__.py +4 -0
- molbuilder/smiles/parser.py +487 -0
- molbuilder/smiles/tokenizer.py +291 -0
- molbuilder/smiles/writer.py +375 -0
- molbuilder/visualization/__init__.py +1 -0
- molbuilder/visualization/bohr_viz.py +166 -0
- molbuilder/visualization/molecule_viz.py +368 -0
- molbuilder/visualization/quantum_viz.py +434 -0
- molbuilder/visualization/theme.py +12 -0
- molbuilder-1.0.0.dist-info/METADATA +360 -0
- molbuilder-1.0.0.dist-info/RECORD +78 -0
- molbuilder-1.0.0.dist-info/WHEEL +5 -0
- molbuilder-1.0.0.dist-info/entry_points.txt +2 -0
- molbuilder-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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,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
|
+
)
|