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,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
@@ -0,0 +1,3 @@
1
+ """Reaction templates, functional group detection, retrosynthesis."""
2
+ from molbuilder.reactions.functional_group_detect import detect_functional_groups
3
+ from molbuilder.reactions.retrosynthesis import retrosynthesis, is_purchasable