rowan-mcp 2.0.0__py3-none-any.whl → 2.0.1__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.

Potentially problematic release.


This version of rowan-mcp might be problematic. Click here for more details.

Files changed (31) hide show
  1. rowan_mcp/functions/admet.py +89 -0
  2. rowan_mcp/functions/bde.py +106 -0
  3. rowan_mcp/functions/calculation_retrieve.py +89 -0
  4. rowan_mcp/functions/conformers.py +77 -0
  5. rowan_mcp/functions/descriptors.py +89 -0
  6. rowan_mcp/functions/docking.py +290 -0
  7. rowan_mcp/functions/docking_enhanced.py +174 -0
  8. rowan_mcp/functions/electronic_properties.py +202 -0
  9. rowan_mcp/functions/folder_management.py +130 -0
  10. rowan_mcp/functions/fukui.py +216 -0
  11. rowan_mcp/functions/hydrogen_bond_basicity.py +87 -0
  12. rowan_mcp/functions/irc.py +125 -0
  13. rowan_mcp/functions/macropka.py +120 -0
  14. rowan_mcp/functions/molecular_converter.py +423 -0
  15. rowan_mcp/functions/molecular_dynamics.py +191 -0
  16. rowan_mcp/functions/molecule_lookup.py +57 -0
  17. rowan_mcp/functions/multistage_opt.py +168 -0
  18. rowan_mcp/functions/pdb_handler.py +200 -0
  19. rowan_mcp/functions/pka.py +81 -0
  20. rowan_mcp/functions/redox_potential.py +349 -0
  21. rowan_mcp/functions/scan.py +536 -0
  22. rowan_mcp/functions/scan_analyzer.py +347 -0
  23. rowan_mcp/functions/solubility.py +277 -0
  24. rowan_mcp/functions/spin_states.py +747 -0
  25. rowan_mcp/functions/system_management.py +361 -0
  26. rowan_mcp/functions/tautomers.py +88 -0
  27. rowan_mcp/functions/workflow_management.py +422 -0
  28. {rowan_mcp-2.0.0.dist-info → rowan_mcp-2.0.1.dist-info}/METADATA +3 -18
  29. {rowan_mcp-2.0.0.dist-info → rowan_mcp-2.0.1.dist-info}/RECORD +31 -4
  30. {rowan_mcp-2.0.0.dist-info → rowan_mcp-2.0.1.dist-info}/WHEEL +0 -0
  31. {rowan_mcp-2.0.0.dist-info → rowan_mcp-2.0.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,536 @@
1
+ """
2
+ Rowan scan function for potential energy surface scans and IRC workflows.
3
+ """
4
+
5
+ from typing import Optional, Union, List, Dict, Any
6
+ import rowan
7
+ import logging
8
+ import os
9
+
10
+ # Set up logger
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Get API key from environment
14
+ api_key = os.environ.get("ROWAN_API_KEY")
15
+ if api_key:
16
+ rowan.api_key = api_key
17
+ else:
18
+ logger.warning("ROWAN_API_KEY not found in environment")
19
+
20
+ # QC Constants needed for validation
21
+ QC_ENGINES = {
22
+ "psi4": "Hartree–Fock and density-functional theory",
23
+ "terachem": "Hartree–Fock and density-functional theory",
24
+ "pyscf": "Hartree–Fock and density-functional theory",
25
+ "xtb": "Semiempirical calculations",
26
+ "aimnet2": "Machine-learned interatomic potential calculations"
27
+ }
28
+
29
+ QC_METHODS = {
30
+ # Hartree-Fock
31
+ "hf": "Hartree-Fock (unrestricted for open-shell systems)",
32
+
33
+ # Pure Functionals - LDA
34
+ "lsda": "Local Spin Density Approximation (Slater exchange + VWN correlation)",
35
+
36
+ # Pure Functionals - GGA
37
+ "pbe": "Perdew-Burke-Ernzerhof (1996) GGA functional",
38
+ "blyp": "Becke 1988 exchange + Lee-Yang-Parr correlation",
39
+ "bp86": "Becke 1988 exchange + Perdew 1988 correlation",
40
+ "b97-d3": "Grimme's 2006 B97 reparameterization with D3 dispersion",
41
+
42
+ # Pure Functionals - Meta-GGA
43
+ "r2scan": "Furness and Sun's 2020 r2SCAN meta-GGA functional",
44
+ "tpss": "Tao-Perdew-Staroverov-Scuseria meta-GGA (2003)",
45
+ "m06l": "Zhao and Truhlar's 2006 local meta-GGA functional",
46
+
47
+ # Hybrid Functionals - Global
48
+ "pbe0": "PBE0 hybrid functional (25% HF exchange)",
49
+ "b3lyp": "B3LYP hybrid functional (20% HF exchange)",
50
+ "b3pw91": "B3PW91 hybrid functional (20% HF exchange)",
51
+
52
+ # Hybrid Functionals - Range-Separated
53
+ "camb3lyp": "CAM-B3LYP range-separated hybrid (19-65% HF exchange)",
54
+ "wb97x_d3": "ωB97X-D3 range-separated hybrid with D3 dispersion (20-100% HF exchange)",
55
+ "wb97x_v": "ωB97X-V with VV10 nonlocal dispersion (17-100% HF exchange)",
56
+ "wb97m_v": "ωB97M-V meta-GGA with VV10 dispersion (15-100% HF exchange)"
57
+ }
58
+
59
+ QC_BASIS_SETS = {
60
+ # Pople's STO-nG minimal basis sets
61
+ "sto-2g": "STO-2G minimal basis set",
62
+ "sto-3g": "STO-3G minimal basis set (default if none specified)",
63
+ "sto-4g": "STO-4G minimal basis set",
64
+ "sto-5g": "STO-5G minimal basis set",
65
+ "sto-6g": "STO-6G minimal basis set",
66
+
67
+ # Pople's 6-31 basis sets (double-zeta)
68
+ "6-31g": "6-31G split-valence double-zeta basis set",
69
+ "6-31g*": "6-31G(d) - 6-31G with polarization on heavy atoms",
70
+ "6-31g(d)": "6-31G with d polarization on heavy atoms",
71
+ "6-31g(d,p)": "6-31G with polarization on all atoms",
72
+ "6-31+g(d,p)": "6-31G with diffuse and polarization functions",
73
+ "6-311+g(2d,p)": "6-311+G(2d,p) - larger basis for single-point calculations",
74
+
75
+ # Jensen's pcseg-n basis sets (recommended for DFT)
76
+ "pcseg-0": "Jensen pcseg-0 minimal basis set",
77
+ "pcseg-1": "Jensen pcseg-1 double-zeta (better than 6-31G(d))",
78
+ "pcseg-2": "Jensen pcseg-2 triple-zeta basis set",
79
+ "pcseg-3": "Jensen pcseg-3 quadruple-zeta basis set",
80
+ "pcseg-4": "Jensen pcseg-4 quintuple-zeta basis set",
81
+ "aug-pcseg-1": "Augmented Jensen pcseg-1 double-zeta",
82
+ "aug-pcseg-2": "Augmented Jensen pcseg-2 triple-zeta",
83
+
84
+ # Dunning's cc-PVNZ basis sets (use seg-opt variants for speed)
85
+ "cc-pvdz": "Correlation-consistent double-zeta (generally contracted - slow)",
86
+ "cc-pvtz": "Correlation-consistent triple-zeta (generally contracted - slow)",
87
+ "cc-pvqz": "Correlation-consistent quadruple-zeta (generally contracted - slow)",
88
+ "cc-pvdz(seg-opt)": "cc-pVDZ segmented-optimized (preferred over cc-pVDZ)",
89
+ "cc-pvtz(seg-opt)": "cc-pVTZ segmented-optimized (preferred over cc-pVTZ)",
90
+ "cc-pvqz(seg-opt)": "cc-pVQZ segmented-optimized (preferred over cc-pVQZ)",
91
+
92
+ # Ahlrichs's def2 basis sets
93
+ "def2-sv(p)": "Ahlrichs def2-SV(P) split-valence polarized",
94
+ "def2-svp": "Ahlrichs def2-SVP split-valence polarized",
95
+ "def2-tzvp": "Ahlrichs def2-TZVP triple-zeta valence polarized",
96
+
97
+ # Truhlar's efficient basis sets
98
+ "midi!": "MIDI!/MIDIX polarized minimal basis set (very efficient)",
99
+ "midix": "MIDI!/MIDIX polarized minimal basis set (very efficient)"
100
+ }
101
+
102
+ QC_CORRECTIONS = {
103
+ "d3bj": "Grimme's D3 dispersion correction with Becke-Johnson damping",
104
+ "d3": "Grimme's D3 dispersion correction (automatically applied for B97-D3, ωB97X-D3)"
105
+ }
106
+
107
+ def lookup_molecule_smiles(molecule_name: str) -> str:
108
+ """Simple molecule name to SMILES lookup for common molecules."""
109
+ common_molecules = {
110
+ # Simple molecules
111
+ "water": "O",
112
+ "methane": "C",
113
+ "ethane": "CC",
114
+ "propane": "CCC",
115
+ "butane": "CCCC",
116
+ "ethylene": "C=C",
117
+ "acetylene": "C#C",
118
+ "benzene": "c1ccccc1",
119
+ "toluene": "Cc1ccccc1",
120
+ "phenol": "Oc1ccccc1",
121
+
122
+ # Peroxides and simple radicals
123
+ "hydrogen peroxide": "OO",
124
+ "h2o2": "OO",
125
+ "peroxide": "OO",
126
+
127
+ # Common solvents
128
+ "methanol": "CO",
129
+ "ethanol": "CCO",
130
+ "acetone": "CC(=O)C",
131
+ "dmso": "CS(=O)C",
132
+ "thf": "C1CCOC1",
133
+ }
134
+
135
+ # Check if it's already a SMILES string (contains specific characters)
136
+ if any(char in molecule_name.lower() for char in ['=', '#', '[', '(', ')', '@']):
137
+ return molecule_name
138
+
139
+ # Look up common name
140
+ lookup_name = molecule_name.lower().strip()
141
+ smiles = common_molecules.get(lookup_name)
142
+
143
+ if smiles:
144
+ logger.info(f"Molecule lookup: '{molecule_name}' → '{smiles}'")
145
+ return smiles
146
+ else:
147
+ return molecule_name
148
+
149
+ def rowan_scan(
150
+ name: str,
151
+ molecule: str,
152
+ coordinate_type: str,
153
+ atoms: Union[List[int], str],
154
+ start: float,
155
+ stop: float,
156
+ num: int,
157
+ method: Optional[str] = None,
158
+ basis_set: Optional[str] = None,
159
+ engine: Optional[str] = None,
160
+ corrections: Optional[List[str]] = None,
161
+ charge: int = 0,
162
+ multiplicity: int = 1,
163
+ mode: Optional[str] = None,
164
+ constraints: Optional[List[Dict[str, Any]]] = None,
165
+ # New parameters from ScanWorkflow
166
+ coordinate_type_2d: Optional[str] = None,
167
+ atoms_2d: Optional[Union[List[int], str]] = None,
168
+ start_2d: Optional[float] = None,
169
+ stop_2d: Optional[float] = None,
170
+ num_2d: Optional[int] = None,
171
+ wavefront_propagation: bool = True,
172
+ concerted_coordinates: Optional[List[Dict[str, Any]]] = None,
173
+ folder_uuid: Optional[str] = None,
174
+ blocking: bool = True,
175
+ ping_interval: int = 10
176
+ ) -> str:
177
+ """Run potential energy surface scans with full parameter control including 2D and concerted scans.
178
+
179
+ Performs constrained optimizations along reaction coordinates using Rowan's
180
+ wavefront propagation method to avoid local minima. Essential for:
181
+ - Mapping reaction pathways and mechanisms
182
+ - Finding transition state approximations
183
+ - Studying conformational preferences and rotational barriers
184
+ - Analyzing atropisomerism and molecular flexibility
185
+ - 2D potential energy surfaces
186
+ - Concerted coordinate changes
187
+
188
+ Args:
189
+ name: Name for the scan calculation
190
+ molecule: Molecule SMILES string or common name (e.g., "butane", "phenol")
191
+ coordinate_type: Type of coordinate to scan - "bond", "angle", or "dihedral"
192
+ atoms: List of 1-indexed atom numbers or comma-separated string (e.g., [1,2,3,4] or "1,2,3,4")
193
+ start: Starting value of the coordinate (Å for bonds, degrees for angles/dihedrals)
194
+ stop: Ending value of the coordinate
195
+ num: Number of scan points to calculate
196
+ method: QC method (default: "hf-3c" for speed, use "b3lyp" for accuracy)
197
+ basis_set: Basis set (default: auto-selected based on method)
198
+ engine: Computational engine (default: "psi4")
199
+ corrections: List of corrections like ["d3bj"] for dispersion
200
+ charge: Molecular charge (default: 0)
201
+ multiplicity: Spin multiplicity (default: 1 for singlet)
202
+ mode: Calculation precision - "reckless", "rapid", "careful", "meticulous" (default: "rapid")
203
+ constraints: Additional coordinate constraints during optimization
204
+ coordinate_type_2d: Type of second coordinate for 2D scan (optional)
205
+ atoms_2d: Atoms for second coordinate in 2D scan (optional)
206
+ start_2d: Starting value of second coordinate (optional)
207
+ stop_2d: Ending value of second coordinate (optional)
208
+ num_2d: Number of points for second coordinate (must equal num for 2D grid, optional)
209
+ wavefront_propagation: Use wavefront propagation for smoother scans (default: True)
210
+ concerted_coordinates: List of coordinate dictionaries for concerted scans (optional)
211
+ folder_uuid: Optional folder UUID for organization
212
+ blocking: Whether to wait for completion (default: True)
213
+ ping_interval: Check status interval in seconds (default: 10, longer for scans)
214
+
215
+ Returns:
216
+ Scan results with energy profile and structural data
217
+ """
218
+
219
+ # Look up SMILES if a common name was provided
220
+ canonical_smiles = lookup_molecule_smiles(molecule)
221
+
222
+ # Validate coordinate type
223
+ valid_coord_types = ["bond", "angle", "dihedral"]
224
+ coord_type_lower = coordinate_type.lower()
225
+ if coord_type_lower not in valid_coord_types:
226
+ return f"Invalid coordinate_type '{coordinate_type}'. Valid types: {', '.join(valid_coord_types)}"
227
+
228
+ # Handle string input for atoms (common format: "1,2,3,4")
229
+ if isinstance(atoms, str):
230
+ try:
231
+ # Parse comma-separated string to list of integers
232
+ atoms = [int(x.strip()) for x in atoms.split(",")]
233
+ except ValueError as e:
234
+ return f"Invalid atoms string '{atoms}'. Use format '1,2,3,4' or pass as list [1,2,3,4]. Error: {e}"
235
+
236
+ # Ensure atoms is a list
237
+ if not isinstance(atoms, list):
238
+ return f"Atoms must be a list of integers or comma-separated string. Got: {type(atoms).__name__}"
239
+
240
+ # Validate atom count for coordinate type
241
+ expected_atoms = {"bond": 2, "angle": 3, "dihedral": 4}
242
+ expected_count = expected_atoms[coord_type_lower]
243
+ if len(atoms) != expected_count:
244
+ return f"{coordinate_type} requires exactly {expected_count} atoms, got {len(atoms)}. Use format: [1,2,3,4] or '1,2,3,4'"
245
+
246
+ # Validate atoms are positive integers
247
+ if not all(isinstance(atom, int) and atom > 0 for atom in atoms):
248
+ return f"All atom indices must be positive integers (1-indexed). Got: {atoms}. Use format: [1,2,3,4] or '1,2,3,4'"
249
+
250
+ # Validate scan range
251
+ if num < 2:
252
+ return f"Number of scan points must be at least 2, got {num}"
253
+
254
+ if start >= stop:
255
+ return f"Start value ({start}) must be less than stop value ({stop})"
256
+
257
+ # Handle 2D scan validation
258
+ is_2d_scan = any([coordinate_type_2d, atoms_2d, start_2d is not None, stop_2d is not None, num_2d is not None])
259
+
260
+ if is_2d_scan:
261
+ # For 2D scan, all 2D parameters must be provided
262
+ if not all([coordinate_type_2d, atoms_2d, start_2d is not None, stop_2d is not None, num_2d is not None]):
263
+ return f"For 2D scans, all 2D parameters must be provided: coordinate_type_2d, atoms_2d, start_2d, stop_2d, num_2d"
264
+
265
+ # Validate 2D coordinate type
266
+ coord_type_2d_lower = coordinate_type_2d.lower()
267
+ if coord_type_2d_lower not in valid_coord_types:
268
+ return f"Invalid coordinate_type_2d '{coordinate_type_2d}'. Valid types: {', '.join(valid_coord_types)}"
269
+
270
+ # Handle string input for 2D atoms
271
+ if isinstance(atoms_2d, str):
272
+ try:
273
+ atoms_2d = [int(x.strip()) for x in atoms_2d.split(",")]
274
+ except ValueError as e:
275
+ return f"Invalid atoms_2d string '{atoms_2d}'. Use format '1,2,3,4' or pass as list [1,2,3,4]. Error: {e}"
276
+
277
+ # Validate 2D atom count
278
+ expected_count_2d = expected_atoms[coord_type_2d_lower]
279
+ if len(atoms_2d) != expected_count_2d:
280
+ return f"{coordinate_type_2d} requires exactly {expected_count_2d} atoms, got {len(atoms_2d)}"
281
+
282
+ # Validate 2D atoms are positive integers
283
+ if not all(isinstance(atom, int) and atom > 0 for atom in atoms_2d):
284
+ return f"All 2D atom indices must be positive integers (1-indexed). Got: {atoms_2d}"
285
+
286
+ # Validate 2D scan range
287
+ if num_2d < 2:
288
+ return f"Number of 2D scan points must be at least 2, got {num_2d}"
289
+
290
+ if start_2d >= stop_2d:
291
+ return f"2D start value ({start_2d}) must be less than stop value ({stop_2d})"
292
+
293
+ # Handle concerted scan validation
294
+ if concerted_coordinates:
295
+ # Validate each coordinate in the concerted scan
296
+ for i, coord in enumerate(concerted_coordinates):
297
+ required_keys = {"coordinate_type", "atoms", "start", "stop", "num"}
298
+ if not all(key in coord for key in required_keys):
299
+ return f"Concerted coordinate {i+1} missing required keys: {required_keys}"
300
+
301
+ # All concerted coordinates must have same number of steps
302
+ if coord["num"] != num:
303
+ return f"All concerted scan coordinates must have same number of steps. Got {coord['num']} vs {num}"
304
+
305
+ # Validate coordinate type
306
+ if coord["coordinate_type"].lower() not in valid_coord_types:
307
+ return f"Invalid coordinate_type in concerted coordinate {i+1}: '{coord['coordinate_type']}'"
308
+
309
+ # ENFORCE REQUIRED PARAMETERS FOR SCANWORKFLOW - Always provide robust defaults
310
+ if method is None:
311
+ method = "hf-3c" # Fast, reliable default for scans
312
+
313
+ if engine is None:
314
+ engine = "psi4" # Required by ScanWorkflow
315
+
316
+ if mode is None:
317
+ mode = "rapid" # Good balance for scans
318
+
319
+ # Ensure all required defaults are lowercase for API consistency
320
+ method = method.lower()
321
+ engine = engine.lower()
322
+ mode = mode.lower()
323
+
324
+ # Validate QC parameters if provided
325
+ if method and method.lower() not in QC_METHODS and method.lower() != "hf-3c":
326
+ available_methods = ", ".join(list(QC_METHODS.keys()) + ["hf-3c"])
327
+ return f"Invalid method '{method}'. Available methods: {available_methods}"
328
+
329
+ if basis_set and basis_set.lower() not in QC_BASIS_SETS:
330
+ available_basis = ", ".join(QC_BASIS_SETS.keys())
331
+ return f"Invalid basis set '{basis_set}'. Available basis sets: {available_basis}"
332
+
333
+ if engine and engine.lower() not in QC_ENGINES:
334
+ available_engines = ", ".join(QC_ENGINES.keys())
335
+ return f"Invalid engine '{engine}'. Available engines: {available_engines}"
336
+
337
+ if corrections:
338
+ invalid_corrections = [corr for corr in corrections if corr.lower() not in QC_CORRECTIONS]
339
+ if invalid_corrections:
340
+ available_corrections = ", ".join(QC_CORRECTIONS.keys())
341
+ return f"Invalid corrections {invalid_corrections}. Available corrections: {available_corrections}"
342
+
343
+ # Validate mode
344
+ valid_modes = ["reckless", "rapid", "careful", "meticulous"]
345
+ if mode and mode.lower() not in valid_modes:
346
+ return f"Invalid mode '{mode}'. Valid modes: {', '.join(valid_modes)}"
347
+
348
+ # Log the scan parameters
349
+ logger.info(f"Name: {name}")
350
+ logger.info(f"Input: {molecule}")
351
+ if is_2d_scan:
352
+ logger.info(f"2D Grid Size: {num} × {num_2d} = {num * num_2d} total points")
353
+ if concerted_coordinates:
354
+ logger.info(f"Concerted scan with {len(concerted_coordinates) + 1} coordinates")
355
+ logger.info(f"Mode: {mode}")
356
+ logger.info(f"Wavefront Propagation: {wavefront_propagation}")
357
+
358
+ try:
359
+ # Build scan coordinate specification based on scan type
360
+ scan_coordinates = []
361
+
362
+ # BUILD PRIMARY SCAN_SETTINGS - EXACTLY MATCHING STJAMES SCANSETTINGS
363
+ primary_coord = {
364
+ "type": coord_type_lower, # Required: matches stjames ScanSettings.type
365
+ "atoms": atoms, # Required: list of 1-indexed atom numbers
366
+ "start": float(start), # Required: starting coordinate value
367
+ "stop": float(stop), # Required: ending coordinate value
368
+ "num": int(num) # Required: number of scan points
369
+ }
370
+ scan_coordinates.append(primary_coord)
371
+
372
+ # ADD 2D COORDINATE IF SPECIFIED - EXACT STJAMES FORMAT
373
+ if is_2d_scan:
374
+ secondary_coord = {
375
+ "type": coord_type_2d_lower, # Required: matches stjames ScanSettings.type
376
+ "atoms": atoms_2d, # Required: list of 1-indexed atom numbers
377
+ "start": float(start_2d), # Required: starting coordinate value
378
+ "stop": float(stop_2d), # Required: ending coordinate value
379
+ "num": int(num_2d) # Required: number of scan points
380
+ }
381
+ scan_coordinates.append(secondary_coord)
382
+
383
+ # ADD CONCERTED COORDINATES IF SPECIFIED - EXACT STJAMES FORMAT
384
+ if concerted_coordinates:
385
+ for i, coord in enumerate(concerted_coordinates):
386
+ concerted_coord = {
387
+ "type": coord["coordinate_type"].lower(), # Required: matches stjames ScanSettings.type
388
+ "atoms": coord["atoms"], # Required: list of 1-indexed atom numbers
389
+ "start": float(coord["start"]), # Required: starting coordinate value
390
+ "stop": float(coord["stop"]), # Required: ending coordinate value
391
+ "num": int(coord["num"]) # Required: number of scan points
392
+ }
393
+ scan_coordinates.append(concerted_coord)
394
+
395
+ # Build parameters for rowan.compute call
396
+ compute_params = {
397
+ "name": name,
398
+ "molecule": canonical_smiles, # Required by rowan.compute() API
399
+ "folder_uuid": folder_uuid,
400
+ "blocking": blocking,
401
+ "ping_interval": ping_interval,
402
+ # Add initial_molecule parameter for MoleculeWorkflow compatibility
403
+ "initial_molecule": canonical_smiles # Required by stjames MoleculeWorkflow
404
+ }
405
+
406
+ # SCAN_SETTINGS CONSTRUCTION - STRICTLY FOLLOWING STJAMES SCANWORKFLOW REQUIREMENTS
407
+ if len(scan_coordinates) == 1:
408
+ # SINGLE COORDINATE SCAN: scan_settings is a ScanSettings object
409
+ compute_params["scan_settings"] = scan_coordinates[0]
410
+ else:
411
+ if is_2d_scan:
412
+ # 2D SCAN: separate scan_settings and scan_settings_2d fields
413
+ compute_params["scan_settings"] = scan_coordinates[0] # Primary coordinate
414
+ compute_params["scan_settings_2d"] = scan_coordinates[1] # Secondary coordinate
415
+ else:
416
+ # CONCERTED SCAN: scan_settings is a list of ScanSettings
417
+ compute_params["scan_settings"] = scan_coordinates
418
+
419
+ # Add wavefront propagation setting
420
+ compute_params["wavefront_propagation"] = wavefront_propagation
421
+
422
+ # BUILD REQUIRED CALC_SETTINGS - ALWAYS COMPLETE
423
+ calc_settings = {
424
+ "method": method, # Always present due to defaults above
425
+ "mode": mode, # Always present due to defaults above
426
+ }
427
+
428
+ # Add charge/multiplicity only if non-default
429
+ if charge != 0:
430
+ calc_settings["charge"] = charge
431
+ if multiplicity != 1:
432
+ calc_settings["multiplicity"] = multiplicity
433
+
434
+ # Add optional parameters if provided
435
+ if basis_set:
436
+ calc_settings["basis_set"] = basis_set.lower()
437
+ if corrections:
438
+ calc_settings["corrections"] = [corr.lower() for corr in corrections]
439
+ if constraints:
440
+ calc_settings["constraints"] = constraints
441
+
442
+ # REQUIRED FIELDS - ScanWorkflow validation will fail without these
443
+ compute_params["calc_settings"] = calc_settings
444
+ compute_params["calc_engine"] = engine # Always present due to defaults above
445
+
446
+ # Submit the scan workflow
447
+ result = rowan.compute(workflow_type="scan", **compute_params)
448
+
449
+ # Format results based on status
450
+ uuid = result.get('uuid', 'N/A')
451
+ status = result.get('status', 'unknown')
452
+
453
+ # Determine scan type for display
454
+ scan_type = "1D Scan"
455
+ total_points = num
456
+ if is_2d_scan:
457
+ scan_type = "2D Grid Scan"
458
+ total_points = num * num_2d
459
+ elif concerted_coordinates:
460
+ scan_type = f"Concerted Scan ({len(concerted_coordinates) + 1} coordinates)"
461
+ total_points = num
462
+
463
+ # Format response
464
+ if blocking:
465
+ # Blocking mode - check if successful
466
+ if status == "success":
467
+ formatted = f"✅ {scan_type} '{name}' completed successfully!\n"
468
+ formatted += f"🔖 Workflow UUID: {uuid}\n"
469
+ formatted += f"📊 Status: {status}\n\n"
470
+
471
+ # Extract scan results if available
472
+ object_data = result.get("object_data", {})
473
+ scan_points = object_data.get("scan_points", [])
474
+
475
+ if scan_points:
476
+ formatted += f"📈 Scan Results: {len(scan_points)} points calculated\n"
477
+
478
+ # Show first few scan points
479
+ for i, point in enumerate(scan_points[:3]):
480
+ if isinstance(point, dict):
481
+ energy = point.get("energy", "N/A")
482
+ formatted += f" Point {i+1}: Energy = {energy}\n"
483
+
484
+ if len(scan_points) > 3:
485
+ formatted += f" ... and {len(scan_points) - 3} more points\n"
486
+ else:
487
+ formatted += "📈 Results: Check workflow details for scan data\n"
488
+
489
+ return formatted
490
+ else:
491
+ # Failed calculation
492
+ return f"❌ {scan_type} calculation failed\n🔖 UUID: {uuid}\n📋 Status: {status}\n💬 Check workflow details for more information"
493
+ else:
494
+ # Non-blocking mode - just submission confirmation
495
+ formatted = f"📋 {scan_type} '{name}' submitted!\n"
496
+ formatted += f"🔖 Workflow UUID: {uuid}\n"
497
+ formatted += f"⏳ Status: Running...\n"
498
+ formatted += f"💡 Use rowan_workflow_management to check status\n\n"
499
+
500
+ formatted += f"Scan Details:\n"
501
+ formatted += f"🧬 Molecule: {canonical_smiles}\n"
502
+ formatted += f"📐 Coordinate: {coordinate_type.upper()} on atoms {atoms} ({start} to {stop}, {num} points)\n"
503
+ if is_2d_scan:
504
+ formatted += f"📐 Secondary: {coordinate_type_2d.upper()} on atoms {atoms_2d} ({start_2d} to {stop_2d}, {num_2d} points)\n"
505
+ formatted += f"📊 Total Points: {total_points}\n"
506
+ formatted += f"⚙️ Method: {method}, Engine: {engine}\n"
507
+
508
+ return formatted
509
+
510
+ except Exception as e:
511
+ logger.error(f"Error in rowan_scan: {str(e)}")
512
+ return f"PES scan submission failed: {str(e)}"
513
+
514
+ def test_rowan_scan():
515
+ """Test the rowan_scan function."""
516
+ try:
517
+ # Test with minimal parameters - should use all defaults
518
+ result = rowan_scan(
519
+ name="test_scan_ethane",
520
+ molecule="CC",
521
+ coordinate_type="bond",
522
+ atoms=[1, 2],
523
+ start=1.4,
524
+ stop=1.8,
525
+ num=3, # Very short for testing
526
+ blocking=False
527
+ )
528
+ print("✅ Scan test successful!")
529
+ print(f"Result: {result[:200]}..." if len(result) > 200 else result)
530
+ return True
531
+ except Exception as e:
532
+ print(f"❌ Scan test failed: {e}")
533
+ return False
534
+
535
+ if __name__ == "__main__":
536
+ test_rowan_scan()