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,427 @@
|
|
|
1
|
+
"""Scale-up analysis for transitioning from lab to production.
|
|
2
|
+
|
|
3
|
+
Evaluates batch vs. continuous manufacturing, estimates cycle times,
|
|
4
|
+
annual capacity, capital costs, and identifies scale-up risks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
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.process import DEFAULT_SOLVENT_L_PER_KG
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# =====================================================================
|
|
18
|
+
# Data class
|
|
19
|
+
# =====================================================================
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ScaleUpAnalysis:
|
|
23
|
+
"""Complete scale-up analysis result."""
|
|
24
|
+
|
|
25
|
+
target_annual_kg: float
|
|
26
|
+
recommended_mode: str # "batch", "continuous", "semi-continuous"
|
|
27
|
+
batch_size_kg: float | None
|
|
28
|
+
batches_per_year: int | None
|
|
29
|
+
cycle_time_hours: float
|
|
30
|
+
annual_capacity_kg: float
|
|
31
|
+
capital_cost_usd: float
|
|
32
|
+
operating_cost_annual_usd: float
|
|
33
|
+
scale_up_risks: list[str]
|
|
34
|
+
recommendations: list[str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# =====================================================================
|
|
38
|
+
# Configuration constants
|
|
39
|
+
# =====================================================================
|
|
40
|
+
|
|
41
|
+
# Annual operating hours (24/7 with ~15% downtime for maintenance)
|
|
42
|
+
_ANNUAL_OPERATING_HOURS = 7500.0
|
|
43
|
+
|
|
44
|
+
# Maximum practical batch size for glass-lined reactors (litres)
|
|
45
|
+
_MAX_BATCH_VOLUME_L = 16_000.0
|
|
46
|
+
|
|
47
|
+
# Typical product density assumption (kg/L)
|
|
48
|
+
_PRODUCT_DENSITY = 1.0
|
|
49
|
+
|
|
50
|
+
# Solvent-to-product volume ratio (shared constant)
|
|
51
|
+
_SOLVENT_RATIO = DEFAULT_SOLVENT_L_PER_KG
|
|
52
|
+
|
|
53
|
+
# Batch turnaround time (cleaning, charging, discharging) in hours
|
|
54
|
+
_TURNAROUND_HOURS = 2.0
|
|
55
|
+
|
|
56
|
+
# Labour rate for production operators (USD/h)
|
|
57
|
+
_OPERATOR_RATE_USD_H = 55.0
|
|
58
|
+
|
|
59
|
+
# Number of operators per shift
|
|
60
|
+
_OPERATORS_PER_SHIFT = 2
|
|
61
|
+
|
|
62
|
+
# Shifts per day for continuous operation
|
|
63
|
+
_SHIFTS_PER_DAY = 3
|
|
64
|
+
|
|
65
|
+
# Utility cost per operating hour (steam, cooling water, electricity)
|
|
66
|
+
_UTILITY_COST_PER_HOUR = 45.0
|
|
67
|
+
|
|
68
|
+
# Maintenance as fraction of capital cost per year
|
|
69
|
+
_MAINTENANCE_FRACTION = 0.05
|
|
70
|
+
|
|
71
|
+
# Raw material cost placeholder (USD per kg product)
|
|
72
|
+
_RAW_MATERIAL_COST_PER_KG = 200.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# =====================================================================
|
|
76
|
+
# Internal helpers
|
|
77
|
+
# =====================================================================
|
|
78
|
+
|
|
79
|
+
def _estimate_cycle_time(steps: list[Any]) -> float:
|
|
80
|
+
"""Estimate total cycle time in hours for one batch through all steps.
|
|
81
|
+
|
|
82
|
+
Includes reaction time, workup, and turnaround per step.
|
|
83
|
+
"""
|
|
84
|
+
total = 0.0
|
|
85
|
+
for step in steps:
|
|
86
|
+
template: ReactionTemplate = step.template
|
|
87
|
+
mean_t = (template.temperature_range[0] + template.temperature_range[1]) / 2.0
|
|
88
|
+
|
|
89
|
+
# Base reaction time estimate from yield and temperature
|
|
90
|
+
_, yield_hi = template.typical_yield
|
|
91
|
+
rxn_hours = 1.0 + (100.0 - yield_hi) * 0.04
|
|
92
|
+
|
|
93
|
+
# Temperature correction
|
|
94
|
+
if mean_t < -20:
|
|
95
|
+
rxn_hours *= 0.8
|
|
96
|
+
elif mean_t > 120:
|
|
97
|
+
rxn_hours *= 0.6
|
|
98
|
+
|
|
99
|
+
# Workup time estimate
|
|
100
|
+
workup_hours = 1.5
|
|
101
|
+
if template.category in {ReactionCategory.COUPLING, ReactionCategory.OXIDATION}:
|
|
102
|
+
workup_hours = 2.0
|
|
103
|
+
if template.category == ReactionCategory.POLYMERIZATION:
|
|
104
|
+
workup_hours = 3.0
|
|
105
|
+
|
|
106
|
+
total += rxn_hours + workup_hours + _TURNAROUND_HOURS
|
|
107
|
+
|
|
108
|
+
return round(max(total, 2.0), 1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _max_batch_kg() -> float:
|
|
112
|
+
"""Maximum practical batch size in kg of product."""
|
|
113
|
+
# Max vessel volume / solvent ratio gives product mass
|
|
114
|
+
return _MAX_BATCH_VOLUME_L / _SOLVENT_RATIO / _PRODUCT_DENSITY
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_continuous_candidate(steps: list[Any], target_annual_kg: float) -> bool:
|
|
118
|
+
"""Decide if continuous processing is feasible.
|
|
119
|
+
|
|
120
|
+
Continuous is preferred when:
|
|
121
|
+
- Annual volume > 50,000 kg
|
|
122
|
+
- All steps are relatively fast (no step > 8 h)
|
|
123
|
+
- No solid-handling steps that complicate continuous flow
|
|
124
|
+
"""
|
|
125
|
+
if target_annual_kg < 50_000:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
for step in steps:
|
|
129
|
+
template: ReactionTemplate = step.template
|
|
130
|
+
_, yield_hi = template.typical_yield
|
|
131
|
+
rxn_hours = 1.0 + (100.0 - yield_hi) * 0.04
|
|
132
|
+
if rxn_hours > 8.0:
|
|
133
|
+
return False
|
|
134
|
+
# Solid handling makes continuous harder
|
|
135
|
+
if template.category in {ReactionCategory.POLYMERIZATION}:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _capital_cost_batch(batch_size_kg: float, num_steps: int) -> float:
|
|
142
|
+
"""Estimate capital cost for a batch plant.
|
|
143
|
+
|
|
144
|
+
Uses the six-tenths rule scaled from a reference 100 kg plant
|
|
145
|
+
costing $500k per step.
|
|
146
|
+
"""
|
|
147
|
+
ref_cost_per_step = 500_000.0
|
|
148
|
+
scale_factor = (max(batch_size_kg, 1.0) / 100.0) ** 0.6
|
|
149
|
+
return round(ref_cost_per_step * max(scale_factor, 0.2) * num_steps, -3)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _capital_cost_continuous(target_annual_kg: float, num_steps: int) -> float:
|
|
153
|
+
"""Estimate capital cost for a continuous plant.
|
|
154
|
+
|
|
155
|
+
Continuous plants are more expensive per step but have lower
|
|
156
|
+
operating costs at high throughput.
|
|
157
|
+
"""
|
|
158
|
+
ref_cost_per_step = 800_000.0
|
|
159
|
+
throughput_factor = (max(target_annual_kg, 1000.0) / 100_000.0) ** 0.5
|
|
160
|
+
return round(ref_cost_per_step * max(throughput_factor, 0.3) * num_steps, -3)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _identify_risks(steps: list[Any], target_annual_kg: float, mode: str) -> list[str]:
|
|
164
|
+
"""Identify scale-up risks for the route."""
|
|
165
|
+
risks: list[str] = []
|
|
166
|
+
|
|
167
|
+
for idx, step in enumerate(steps):
|
|
168
|
+
template: ReactionTemplate = step.template
|
|
169
|
+
mean_t = (template.temperature_range[0] + template.temperature_range[1]) / 2.0
|
|
170
|
+
|
|
171
|
+
# Heat transfer risk
|
|
172
|
+
if mean_t < -40 or mean_t > 150:
|
|
173
|
+
risks.append(
|
|
174
|
+
f"Step {idx+1} ({template.name}): Extreme temperature ({mean_t:.0f} C) "
|
|
175
|
+
"-- heat transfer may be limiting at scale"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Mixing sensitivity
|
|
179
|
+
if template.category in {ReactionCategory.COUPLING, ReactionCategory.RADICAL}:
|
|
180
|
+
risks.append(
|
|
181
|
+
f"Step {idx+1} ({template.name}): Mixing-sensitive reaction "
|
|
182
|
+
"-- verify mass transfer at larger vessel dimensions"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Exothermic risk
|
|
186
|
+
if template.category in {ReactionCategory.ADDITION, ReactionCategory.POLYMERIZATION}:
|
|
187
|
+
risks.append(
|
|
188
|
+
f"Step {idx+1} ({template.name}): Potentially exothermic "
|
|
189
|
+
"-- calorimetry data required; ensure adequate cooling capacity"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Chromatography at scale
|
|
193
|
+
if template.category in {ReactionCategory.REARRANGEMENT, ReactionCategory.MISC}:
|
|
194
|
+
if target_annual_kg > 1000:
|
|
195
|
+
risks.append(
|
|
196
|
+
f"Step {idx+1} ({template.name}): Complex mixture expected "
|
|
197
|
+
"-- chromatographic purification impractical at this scale"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Safety
|
|
201
|
+
if template.safety_notes:
|
|
202
|
+
risks.append(
|
|
203
|
+
f"Step {idx+1} ({template.name}): Safety concern -- {template.safety_notes}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# General risks
|
|
207
|
+
if target_annual_kg > 10_000:
|
|
208
|
+
risks.append(
|
|
209
|
+
"Regulatory: Production at >10 t/year may require environmental "
|
|
210
|
+
"impact assessment and process safety review (PSM/COMAH)"
|
|
211
|
+
)
|
|
212
|
+
if mode == "continuous":
|
|
213
|
+
risks.append(
|
|
214
|
+
"Continuous operation: Requires robust process analytical technology "
|
|
215
|
+
"(PAT) and automated control systems"
|
|
216
|
+
)
|
|
217
|
+
if len(steps) > 5:
|
|
218
|
+
risks.append(
|
|
219
|
+
f"Long linear sequence ({len(steps)} steps): cumulative yield loss "
|
|
220
|
+
"is significant; consider convergent synthesis"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return risks
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _generate_recommendations(
|
|
227
|
+
steps: list[Any],
|
|
228
|
+
target_annual_kg: float,
|
|
229
|
+
mode: str,
|
|
230
|
+
batch_size_kg: float | None,
|
|
231
|
+
) -> list[str]:
|
|
232
|
+
"""Generate actionable scale-up recommendations."""
|
|
233
|
+
recs: list[str] = []
|
|
234
|
+
|
|
235
|
+
if mode == "batch":
|
|
236
|
+
recs.append(
|
|
237
|
+
"Perform reaction calorimetry (RC1) for each step to characterise "
|
|
238
|
+
"thermal hazard and design cooling systems"
|
|
239
|
+
)
|
|
240
|
+
recs.append(
|
|
241
|
+
"Conduct solvent-swap studies to identify process-friendly solvents "
|
|
242
|
+
"that simplify workup and waste treatment"
|
|
243
|
+
)
|
|
244
|
+
if batch_size_kg and batch_size_kg > 500:
|
|
245
|
+
recs.append(
|
|
246
|
+
"At >500 kg batch size, consider in-line analytics (FTIR, Raman) "
|
|
247
|
+
"for real-time reaction monitoring"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if mode == "continuous":
|
|
251
|
+
recs.append(
|
|
252
|
+
"Develop residence time distribution (RTD) model for each "
|
|
253
|
+
"continuous unit operation"
|
|
254
|
+
)
|
|
255
|
+
recs.append(
|
|
256
|
+
"Implement process analytical technology (PAT) for real-time "
|
|
257
|
+
"quality control"
|
|
258
|
+
)
|
|
259
|
+
recs.append(
|
|
260
|
+
"Design start-up and shutdown procedures; define off-spec "
|
|
261
|
+
"material handling protocols"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if mode == "semi-continuous":
|
|
265
|
+
recs.append(
|
|
266
|
+
"Identify which steps benefit from continuous operation "
|
|
267
|
+
"(e.g. fast reactions, dangerous intermediates) and which "
|
|
268
|
+
"are better run batchwise"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Universal recommendations
|
|
272
|
+
recs.append(
|
|
273
|
+
"Conduct HAZOP study before commissioning"
|
|
274
|
+
)
|
|
275
|
+
recs.append(
|
|
276
|
+
"Validate analytical methods for in-process control and "
|
|
277
|
+
"final product release"
|
|
278
|
+
)
|
|
279
|
+
if target_annual_kg > 1000:
|
|
280
|
+
recs.append(
|
|
281
|
+
"Evaluate catalyst recycling and solvent recovery to "
|
|
282
|
+
"reduce operating costs and environmental impact"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return recs
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# =====================================================================
|
|
289
|
+
# Public API
|
|
290
|
+
# =====================================================================
|
|
291
|
+
|
|
292
|
+
def analyze_scale_up(
|
|
293
|
+
steps: list[Any],
|
|
294
|
+
target_annual_kg: float,
|
|
295
|
+
) -> ScaleUpAnalysis:
|
|
296
|
+
"""Analyse scale-up feasibility for *steps* at *target_annual_kg*.
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
steps : list
|
|
301
|
+
Each element must have a ``.template`` attribute
|
|
302
|
+
(:class:`ReactionTemplate`) and a ``.precursors`` attribute.
|
|
303
|
+
Duck typing is used.
|
|
304
|
+
target_annual_kg : float
|
|
305
|
+
Desired annual production in kilograms.
|
|
306
|
+
|
|
307
|
+
Returns
|
|
308
|
+
-------
|
|
309
|
+
ScaleUpAnalysis
|
|
310
|
+
Comprehensive scale-up analysis including mode recommendation,
|
|
311
|
+
batch sizing, capital and operating cost estimates, risks, and
|
|
312
|
+
recommendations.
|
|
313
|
+
"""
|
|
314
|
+
if not steps:
|
|
315
|
+
return ScaleUpAnalysis(
|
|
316
|
+
target_annual_kg=target_annual_kg,
|
|
317
|
+
recommended_mode="batch",
|
|
318
|
+
batch_size_kg=None,
|
|
319
|
+
batches_per_year=None,
|
|
320
|
+
cycle_time_hours=0.0,
|
|
321
|
+
annual_capacity_kg=0.0,
|
|
322
|
+
capital_cost_usd=0.0,
|
|
323
|
+
operating_cost_annual_usd=0.0,
|
|
324
|
+
scale_up_risks=[],
|
|
325
|
+
recommendations=[],
|
|
326
|
+
)
|
|
327
|
+
for i, step in enumerate(steps):
|
|
328
|
+
if not hasattr(step, 'template'):
|
|
329
|
+
raise TypeError(
|
|
330
|
+
f"Step {i} must have a 'template' attribute, "
|
|
331
|
+
f"got {type(step).__name__}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if target_annual_kg <= 0:
|
|
335
|
+
target_annual_kg = 1.0
|
|
336
|
+
|
|
337
|
+
num_steps = len(steps)
|
|
338
|
+
cycle_time = _estimate_cycle_time(steps)
|
|
339
|
+
|
|
340
|
+
# --- Mode selection ---
|
|
341
|
+
if _is_continuous_candidate(steps, target_annual_kg):
|
|
342
|
+
mode = "continuous"
|
|
343
|
+
elif target_annual_kg > 10_000 and num_steps <= 3:
|
|
344
|
+
mode = "semi-continuous"
|
|
345
|
+
else:
|
|
346
|
+
mode = "batch"
|
|
347
|
+
|
|
348
|
+
# --- Batch sizing ---
|
|
349
|
+
batch_size_kg: float | None = None
|
|
350
|
+
batches_per_year: int | None = None
|
|
351
|
+
|
|
352
|
+
if mode in ("batch", "semi-continuous"):
|
|
353
|
+
max_kg = _max_batch_kg()
|
|
354
|
+
# Size batch to produce target with reasonable number of batches
|
|
355
|
+
batches_needed_min = max(1, math.ceil(target_annual_kg / max_kg))
|
|
356
|
+
available_batches = int(_ANNUAL_OPERATING_HOURS / cycle_time)
|
|
357
|
+
if available_batches < 1:
|
|
358
|
+
available_batches = 1
|
|
359
|
+
|
|
360
|
+
if batches_needed_min <= available_batches:
|
|
361
|
+
batches_per_year = batches_needed_min
|
|
362
|
+
batch_size_kg = round(target_annual_kg / batches_per_year, 1)
|
|
363
|
+
else:
|
|
364
|
+
# Cannot meet target with single train; use max batch size
|
|
365
|
+
batches_per_year = available_batches
|
|
366
|
+
batch_size_kg = round(max_kg, 1)
|
|
367
|
+
|
|
368
|
+
# --- Annual capacity ---
|
|
369
|
+
if mode == "continuous":
|
|
370
|
+
# Continuous throughput based on cycle time per step and parallel capacity
|
|
371
|
+
hourly_throughput_kg = target_annual_kg / _ANNUAL_OPERATING_HOURS
|
|
372
|
+
annual_capacity_kg = round(hourly_throughput_kg * _ANNUAL_OPERATING_HOURS * 1.1, 0)
|
|
373
|
+
else:
|
|
374
|
+
if batches_per_year and batch_size_kg:
|
|
375
|
+
annual_capacity_kg = round(batches_per_year * batch_size_kg, 0)
|
|
376
|
+
else:
|
|
377
|
+
annual_capacity_kg = target_annual_kg
|
|
378
|
+
|
|
379
|
+
# --- Capital cost ---
|
|
380
|
+
if mode == "continuous":
|
|
381
|
+
capital_cost = _capital_cost_continuous(target_annual_kg, num_steps)
|
|
382
|
+
else:
|
|
383
|
+
capital_cost = _capital_cost_batch(batch_size_kg or 100.0, num_steps)
|
|
384
|
+
|
|
385
|
+
# --- Operating cost ---
|
|
386
|
+
# Labour
|
|
387
|
+
if mode == "continuous":
|
|
388
|
+
annual_labor_hours = _ANNUAL_OPERATING_HOURS * _OPERATORS_PER_SHIFT
|
|
389
|
+
else:
|
|
390
|
+
batches = batches_per_year or 1
|
|
391
|
+
annual_labor_hours = batches * cycle_time * _OPERATORS_PER_SHIFT
|
|
392
|
+
|
|
393
|
+
labor_cost = annual_labor_hours * _OPERATOR_RATE_USD_H
|
|
394
|
+
|
|
395
|
+
# Utilities
|
|
396
|
+
if mode == "continuous":
|
|
397
|
+
utility_cost = _ANNUAL_OPERATING_HOURS * _UTILITY_COST_PER_HOUR
|
|
398
|
+
else:
|
|
399
|
+
batches = batches_per_year or 1
|
|
400
|
+
utility_cost = batches * cycle_time * _UTILITY_COST_PER_HOUR
|
|
401
|
+
|
|
402
|
+
# Raw materials
|
|
403
|
+
raw_material_cost = target_annual_kg * _RAW_MATERIAL_COST_PER_KG
|
|
404
|
+
|
|
405
|
+
# Maintenance
|
|
406
|
+
maintenance_cost = capital_cost * _MAINTENANCE_FRACTION
|
|
407
|
+
|
|
408
|
+
operating_cost_annual = round(
|
|
409
|
+
labor_cost + utility_cost + raw_material_cost + maintenance_cost, -2
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# --- Risks and recommendations ---
|
|
413
|
+
risks = _identify_risks(steps, target_annual_kg, mode)
|
|
414
|
+
recommendations = _generate_recommendations(steps, target_annual_kg, mode, batch_size_kg)
|
|
415
|
+
|
|
416
|
+
return ScaleUpAnalysis(
|
|
417
|
+
target_annual_kg=target_annual_kg,
|
|
418
|
+
recommended_mode=mode,
|
|
419
|
+
batch_size_kg=batch_size_kg,
|
|
420
|
+
batches_per_year=batches_per_year,
|
|
421
|
+
cycle_time_hours=cycle_time,
|
|
422
|
+
annual_capacity_kg=annual_capacity_kg,
|
|
423
|
+
capital_cost_usd=capital_cost,
|
|
424
|
+
operating_cost_annual_usd=operating_cost_annual,
|
|
425
|
+
scale_up_risks=risks,
|
|
426
|
+
recommendations=recommendations,
|
|
427
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Solvent selection and scoring for reaction optimisation.
|
|
2
|
+
|
|
3
|
+
Scores every solvent in ``SOLVENT_DB`` against the requirements of a
|
|
4
|
+
:class:`ReactionTemplate` and returns a ranked list of
|
|
5
|
+
:class:`SolventRecommendation` instances.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
from molbuilder.reactions.reaction_types import ReactionCategory, ReactionTemplate
|
|
14
|
+
from molbuilder.reactions.reagent_data import SOLVENT_DB, Solvent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# =====================================================================
|
|
18
|
+
# Data class
|
|
19
|
+
# =====================================================================
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SolventRecommendation:
|
|
23
|
+
"""A scored solvent recommendation."""
|
|
24
|
+
|
|
25
|
+
solvent_name: str
|
|
26
|
+
score: float # 0-100 composite score
|
|
27
|
+
reasons: list[str]
|
|
28
|
+
green_score: int # 1-10 (1 = greenest)
|
|
29
|
+
estimated_cost_per_L: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# =====================================================================
|
|
33
|
+
# Internal scoring helpers
|
|
34
|
+
# =====================================================================
|
|
35
|
+
|
|
36
|
+
# Desired polarity index ranges per reaction category
|
|
37
|
+
_POLARITY_PREFERENCES: dict[ReactionCategory, tuple[float, float]] = {
|
|
38
|
+
ReactionCategory.SUBSTITUTION: (3.0, 7.0),
|
|
39
|
+
ReactionCategory.ELIMINATION: (2.0, 5.0),
|
|
40
|
+
ReactionCategory.ADDITION: (2.0, 6.0),
|
|
41
|
+
ReactionCategory.OXIDATION: (4.0, 8.0),
|
|
42
|
+
ReactionCategory.REDUCTION: (3.0, 7.0),
|
|
43
|
+
ReactionCategory.COUPLING: (3.0, 7.0),
|
|
44
|
+
ReactionCategory.CARBONYL: (3.0, 7.0),
|
|
45
|
+
ReactionCategory.PROTECTION: (2.0, 5.0),
|
|
46
|
+
ReactionCategory.DEPROTECTION: (3.0, 7.0),
|
|
47
|
+
ReactionCategory.REARRANGEMENT: (2.0, 5.0),
|
|
48
|
+
ReactionCategory.RADICAL: (1.0, 4.0),
|
|
49
|
+
ReactionCategory.PERICYCLIC: (2.0, 5.0),
|
|
50
|
+
ReactionCategory.POLYMERIZATION: (1.0, 5.0),
|
|
51
|
+
ReactionCategory.MISC: (2.0, 7.0),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _polarity_score(solvent: Solvent, category: ReactionCategory) -> tuple[float, str]:
|
|
56
|
+
"""Score 0-30 based on polarity index match."""
|
|
57
|
+
lo, hi = _POLARITY_PREFERENCES.get(category, (2.0, 7.0))
|
|
58
|
+
pi = solvent.polarity_index
|
|
59
|
+
if lo <= pi <= hi:
|
|
60
|
+
return 30.0, "Polarity index within ideal range for this reaction type"
|
|
61
|
+
distance = min(abs(pi - lo), abs(pi - hi))
|
|
62
|
+
score = max(0.0, 30.0 - distance * 6.0)
|
|
63
|
+
reason = "Polarity index outside preferred range" if score < 15 else "Polarity acceptable"
|
|
64
|
+
return round(score, 1), reason
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _bp_score(solvent: Solvent, template: ReactionTemplate) -> tuple[float, str]:
|
|
68
|
+
"""Score 0-25 based on boiling-point suitability.
|
|
69
|
+
|
|
70
|
+
The solvent bp should be at least 10 degC above the reaction temperature
|
|
71
|
+
(for reflux margin) but not excessively high (hard to remove).
|
|
72
|
+
"""
|
|
73
|
+
mean_t = (template.temperature_range[0] + template.temperature_range[1]) / 2.0
|
|
74
|
+
bp = solvent.bp
|
|
75
|
+
|
|
76
|
+
if bp < mean_t:
|
|
77
|
+
return 0.0, "Boiling point below reaction temperature"
|
|
78
|
+
margin = bp - mean_t
|
|
79
|
+
if 10.0 <= margin <= 60.0:
|
|
80
|
+
return 25.0, "Ideal bp margin above reaction temperature"
|
|
81
|
+
if margin < 10.0:
|
|
82
|
+
return 10.0, "bp margin tight; risk of solvent loss"
|
|
83
|
+
# margin > 60 -- harder to remove
|
|
84
|
+
penalty = min(15.0, (margin - 60.0) * 0.3)
|
|
85
|
+
return round(25.0 - penalty, 1), "High bp; may be difficult to remove"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _green_score(solvent: Solvent) -> tuple[float, str]:
|
|
89
|
+
"""Score 0-20 based on green chemistry rating (lower = greener)."""
|
|
90
|
+
gs = solvent.green_score
|
|
91
|
+
score = max(0.0, 20.0 - (gs - 1) * 2.2)
|
|
92
|
+
if gs <= 3:
|
|
93
|
+
reason = "Excellent green chemistry profile"
|
|
94
|
+
elif gs <= 5:
|
|
95
|
+
reason = "Acceptable green chemistry profile"
|
|
96
|
+
elif gs <= 7:
|
|
97
|
+
reason = "Moderate environmental concern"
|
|
98
|
+
else:
|
|
99
|
+
reason = "Poor green chemistry profile; consider alternatives"
|
|
100
|
+
return round(score, 1), reason
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _cost_score(solvent: Solvent, scale_kg: float) -> tuple[float, str]:
|
|
104
|
+
"""Score 0-15 based on cost (more important at larger scale)."""
|
|
105
|
+
cost = solvent.cost_per_L
|
|
106
|
+
if cost <= 0:
|
|
107
|
+
return 15.0, "Negligible solvent cost"
|
|
108
|
+
# At large scale, cost matters more
|
|
109
|
+
threshold = 20.0 if scale_kg < 10 else 12.0
|
|
110
|
+
if cost <= threshold:
|
|
111
|
+
return 15.0, "Solvent cost acceptable for this scale"
|
|
112
|
+
excess = cost - threshold
|
|
113
|
+
score = max(0.0, 15.0 - excess * 0.5)
|
|
114
|
+
return round(score, 1), "Solvent cost may be significant at scale"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _separation_score(solvent: Solvent) -> tuple[float, str]:
|
|
118
|
+
"""Score 0-10 based on ease of separation (low bp, immiscible with water)."""
|
|
119
|
+
score = 5.0
|
|
120
|
+
reasons = []
|
|
121
|
+
if not solvent.miscible_with_water:
|
|
122
|
+
score += 3.0
|
|
123
|
+
reasons.append("immiscible with water (easy extraction)")
|
|
124
|
+
if solvent.bp < 80.0:
|
|
125
|
+
score += 2.0
|
|
126
|
+
reasons.append("low bp (easy rotovap removal)")
|
|
127
|
+
elif solvent.bp > 150.0:
|
|
128
|
+
score -= 2.0
|
|
129
|
+
reasons.append("high bp (difficult to remove)")
|
|
130
|
+
reason = "; ".join(reasons) if reasons else "Average separation characteristics"
|
|
131
|
+
return round(min(score, 10.0), 1), reason
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _listed_solvent_bonus(solvent: Solvent, template: ReactionTemplate) -> float:
|
|
135
|
+
"""Return 10 bonus points if this solvent appears in the template's solvent list."""
|
|
136
|
+
template_names = {s.lower() for s in template.solvents}
|
|
137
|
+
if solvent.name.lower() in template_names:
|
|
138
|
+
return 10.0
|
|
139
|
+
# Also check common abbreviation-style keys
|
|
140
|
+
for key, db_solvent in SOLVENT_DB.items():
|
|
141
|
+
if db_solvent is solvent and key.lower() in template_names:
|
|
142
|
+
return 10.0
|
|
143
|
+
return 0.0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# =====================================================================
|
|
147
|
+
# Public API
|
|
148
|
+
# =====================================================================
|
|
149
|
+
|
|
150
|
+
def select_solvent(
|
|
151
|
+
template: ReactionTemplate,
|
|
152
|
+
scale_kg: float = 1.0,
|
|
153
|
+
) -> list[SolventRecommendation]:
|
|
154
|
+
"""Score and rank solvents from ``SOLVENT_DB`` for *template*.
|
|
155
|
+
|
|
156
|
+
Returns a list of :class:`SolventRecommendation` sorted best-first.
|
|
157
|
+
Only solvents scoring above 30 are included.
|
|
158
|
+
"""
|
|
159
|
+
recommendations: list[SolventRecommendation] = []
|
|
160
|
+
|
|
161
|
+
for _key, solvent in SOLVENT_DB.items():
|
|
162
|
+
total = 0.0
|
|
163
|
+
reasons: list[str] = []
|
|
164
|
+
|
|
165
|
+
ps, pr = _polarity_score(solvent, template.category)
|
|
166
|
+
total += ps
|
|
167
|
+
reasons.append(pr)
|
|
168
|
+
|
|
169
|
+
bs, br = _bp_score(solvent, template)
|
|
170
|
+
total += bs
|
|
171
|
+
reasons.append(br)
|
|
172
|
+
|
|
173
|
+
gs, gr = _green_score(solvent)
|
|
174
|
+
total += gs
|
|
175
|
+
reasons.append(gr)
|
|
176
|
+
|
|
177
|
+
cs, cr = _cost_score(solvent, scale_kg)
|
|
178
|
+
total += cs
|
|
179
|
+
reasons.append(cr)
|
|
180
|
+
|
|
181
|
+
ss, sr = _separation_score(solvent)
|
|
182
|
+
total += ss
|
|
183
|
+
reasons.append(sr)
|
|
184
|
+
|
|
185
|
+
bonus = _listed_solvent_bonus(solvent, template)
|
|
186
|
+
if bonus:
|
|
187
|
+
total += bonus
|
|
188
|
+
reasons.append("Listed as suitable solvent in reaction template")
|
|
189
|
+
|
|
190
|
+
total = min(total, 100.0)
|
|
191
|
+
|
|
192
|
+
if total >= 30.0:
|
|
193
|
+
recommendations.append(
|
|
194
|
+
SolventRecommendation(
|
|
195
|
+
solvent_name=solvent.name,
|
|
196
|
+
score=round(total, 1),
|
|
197
|
+
reasons=reasons,
|
|
198
|
+
green_score=solvent.green_score,
|
|
199
|
+
estimated_cost_per_L=solvent.cost_per_L,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
recommendations.sort(key=lambda r: r.score, reverse=True)
|
|
204
|
+
return recommendations
|