rowan-python 3.0.12__py3-none-any.whl → 3.1.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 (47) hide show
  1. rowan/__init__.py +13 -1
  2. rowan/config.py +1 -7
  3. rowan/folder.py +5 -5
  4. rowan/molecule.py +13 -6
  5. rowan/project.py +4 -4
  6. rowan/protein.py +8 -7
  7. rowan/types.py +4 -2
  8. rowan/user.py +26 -20
  9. rowan/workflows/__init__.py +9 -2
  10. rowan/workflows/admet.py +2 -3
  11. rowan/workflows/analogue_docking.py +31 -14
  12. rowan/workflows/base.py +49 -13
  13. rowan/workflows/basic_calculation.py +18 -10
  14. rowan/workflows/batch_docking.py +2 -2
  15. rowan/workflows/bde.py +37 -22
  16. rowan/workflows/conformer_search.py +116 -13
  17. rowan/workflows/descriptors.py +14 -5
  18. rowan/workflows/docking.py +23 -7
  19. rowan/workflows/double_ended_ts_search.py +20 -9
  20. rowan/workflows/electronic_properties.py +13 -6
  21. rowan/workflows/fukui.py +12 -5
  22. rowan/workflows/hydrogen_bond_donor_acceptor_strength.py +19 -7
  23. rowan/workflows/interaction_energy_decomposition.py +17 -9
  24. rowan/workflows/ion_mobility.py +19 -6
  25. rowan/workflows/irc.py +124 -67
  26. rowan/workflows/macropka.py +2 -3
  27. rowan/workflows/membrane_permeability.py +8 -5
  28. rowan/workflows/multistage_optimization.py +18 -4
  29. rowan/workflows/nmr.py +12 -4
  30. rowan/workflows/pka.py +5 -3
  31. rowan/workflows/pose_analysis_md.py +42 -5
  32. rowan/workflows/protein_binder_design.py +14 -1
  33. rowan/workflows/protein_cofolding.py +11 -2
  34. rowan/workflows/protein_md.py +53 -2
  35. rowan/workflows/rbfe_graph.py +11 -3
  36. rowan/workflows/redox_potential.py +7 -5
  37. rowan/workflows/scan.py +47 -12
  38. rowan/workflows/solubility.py +2 -3
  39. rowan/workflows/solvent_dependent_conformers.py +19 -23
  40. rowan/workflows/spin_states.py +52 -4
  41. rowan/workflows/strain.py +7 -5
  42. rowan/workflows/tautomer_search.py +9 -7
  43. {rowan_python-3.0.12.dist-info → rowan_python-3.1.0.dist-info}/METADATA +6 -2
  44. rowan_python-3.1.0.dist-info/RECORD +55 -0
  45. {rowan_python-3.0.12.dist-info → rowan_python-3.1.0.dist-info}/WHEEL +1 -1
  46. rowan_python-3.0.12.dist-info/RECORD +0 -55
  47. {rowan_python-3.0.12.dist-info → rowan_python-3.1.0.dist-info}/licenses/LICENSE +0 -0
rowan/__init__.py CHANGED
@@ -4,11 +4,14 @@ from stjames import (
4
4
  Atom,
5
5
  Constraint,
6
6
  ConstraintType,
7
+ ConformerClusteringSettings,
7
8
  ConformerGenSettingsUnion,
8
9
  Correction,
9
10
  Engine,
10
11
  ETKDGSettings,
12
+ GreedyClusteringSettings,
11
13
  iMTDSettings,
14
+ KMeansClusteringSettings,
12
15
  Method,
13
16
  Mode,
14
17
  MultiStageOptSettings,
@@ -17,10 +20,19 @@ from stjames import (
17
20
  PBCDFTSettings,
18
21
  PeriodicCell,
19
22
  Settings,
23
+ Solvent,
20
24
  SolventSettings,
21
25
  Task,
22
26
  )
27
+ from stjames.excited_state_settings import TDDFTSettings
23
28
  from stjames.pbc_dft_settings import PBCDFTSmearing
29
+ from stjames.engine_compatibility import (
30
+ ENGINE_METHODS,
31
+ ENGINE_SOLVENT_MODELS,
32
+ ENGINE_SUPPORTS_BASIS_SET,
33
+ METHOD_ENGINES,
34
+ get_supported_corrections,
35
+ )
24
36
  from stjames.optimization.freezing_string_method import (
25
37
  FSMInterpolation,
26
38
  FSMOptimizationCoordinates,
@@ -34,7 +46,7 @@ from .api_keys import *
34
46
  from .calculation import *
35
47
  from .folder import *
36
48
  from .molecule import *
37
- from .types import RdkitMol, StJamesMolecule
49
+ from .types import RdkitMol, StJamesMolecule, StructureInput
38
50
  from .workflows import *
39
51
  from .project import *
40
52
  from .protein import *
rowan/config.py CHANGED
@@ -373,7 +373,7 @@ class ConformerGeneratorSettings:
373
373
  solvent_warning: bool = False
374
374
 
375
375
 
376
- # Main group elements for ETKDG/Lyrebird
376
+ # Main group elements for ETKDG
377
377
  _MAIN_GROUP = {1, 5, 6, 7, 8, 9, 14, 15, 16, 17, 35, 53}
378
378
 
379
379
  CONFORMER_GENERATOR_SETTINGS: dict[str, ConformerGeneratorSettings] = {
@@ -395,12 +395,6 @@ CONFORMER_GENERATOR_SETTINGS: dict[str, ConformerGeneratorSettings] = {
395
395
  disable_ts=True,
396
396
  allowed_engines=["aimnet2", "xtb"], # MCMM only supports these for energy
397
397
  ),
398
- "lyrebird": ConformerGeneratorSettings(
399
- disable_constraints=True,
400
- disable_open_shell=True,
401
- disable_ts=True,
402
- atoms_supported=_MAIN_GROUP,
403
- ),
404
398
  }
405
399
 
406
400
 
rowan/folder.py CHANGED
@@ -11,13 +11,13 @@ class Folder(BaseModel):
11
11
  """
12
12
  A class representing a folder in the Rowan API.
13
13
 
14
- :ivar uuid: The UUID of the folder.
15
- :ivar name: The name of the folder.
16
- :ivar parent_uuid: The UUID of the parent folder.
14
+ :ivar uuid: UUID of the folder.
15
+ :ivar name: Name of the folder.
16
+ :ivar parent_uuid: UUID of the parent folder.
17
17
  :ivar notes: Folder notes.
18
18
  :ivar starred: Whether the folder is starred.
19
19
  :ivar public: Whether the folder is public.
20
- :ivar created_at: The date and time the folder was created.
20
+ :ivar created_at: Date and time the folder was created.
21
21
  """
22
22
 
23
23
  uuid: str
@@ -225,7 +225,7 @@ def get_folder(path: str, create: bool = True) -> Folder:
225
225
  :param path: Folder name or ``/``-separated path, e.g. ``"project/subdir/run1"``.
226
226
  :param create: If True (default), create missing folders. If False, raise ValueError if any
227
227
  segment is not found.
228
- :returns: The deepest :class:`Folder` in the path.
228
+ :returns: Deepest :class:`Folder` in the path.
229
229
  :raises ValueError: If the path is empty, or ``create=False`` and a folder is not found.
230
230
  """
231
231
  segments = [s for s in path.split("/") if s]
rowan/molecule.py CHANGED
@@ -14,12 +14,6 @@ class Molecule(BaseModel):
14
14
 
15
15
  Can be created from SMILES, XYZ, or directly from atoms. Wraps stjames.Molecule
16
16
  internally but provides a cleaner interface.
17
-
18
- :param charge: Molecular charge.
19
- :param multiplicity: Spin multiplicity.
20
- :param atoms: List of atoms with positions.
21
- :param energy: Electronic energy (Hartree).
22
- :param smiles: SMILES string representation.
23
17
  """
24
18
 
25
19
  _stjames: stjames.Molecule = PrivateAttr()
@@ -85,6 +79,19 @@ class Molecule(BaseModel):
85
79
  """
86
80
  return cls.from_xyz(Path(path).read_text(), charge=charge, multiplicity=multiplicity)
87
81
 
82
+ @classmethod
83
+ def molecules_from_sdf(cls, path: str | Path) -> list[Self]:
84
+ """
85
+ Read all records from an SDF file as a list of molecules.
86
+
87
+ Each record becomes one Molecule, in file order, with atom ordering preserved
88
+ across records -- so a multi-conformer SDF reads as a consistent ensemble.
89
+
90
+ :param path: path to the SDF file
91
+ :returns: one Molecule per record
92
+ """
93
+ return [cls.from_stjames(mol) for mol in stjames.Molecule.molecules_from_sdf(path)]
94
+
88
95
  @classmethod
89
96
  def from_stjames(cls, stj: stjames.Molecule) -> Self:
90
97
  """
rowan/project.py CHANGED
@@ -12,9 +12,9 @@ class Project(BaseModel):
12
12
  """
13
13
  A class representing a project in the Rowan API.
14
14
 
15
- :ivar uuid: The UUID of the project.
16
- :ivar name: The name of the project.
17
- :ivar created_at: The date and time the project was created.
15
+ :ivar uuid: UUID of the project.
16
+ :ivar name: Name of the project.
17
+ :ivar created_at: Date and time the project was created.
18
18
  """
19
19
 
20
20
  uuid: str
@@ -139,7 +139,7 @@ def set_project(name: str) -> Project:
139
139
  folder = rowan.get_folder("docking/batch_1")
140
140
 
141
141
  :param name: Exact name of the project to activate.
142
- :returns: The matched Project.
142
+ :returns: Matched project.
143
143
  :raises ValueError: If no project with that name is found.
144
144
  """
145
145
  matches = list_projects(name_contains=name, size=100)
rowan/protein.py CHANGED
@@ -16,13 +16,13 @@ class Protein(BaseModel):
16
16
  Data is not loaded by default to avoid unnecessary downloads that could impact performance.
17
17
  Call `load_data()` to fetch and attach the protein data to this `Protein` object.
18
18
 
19
- :ivar uuid: The UUID of the protein
20
- :ivar created_at: The creation date of the protein
19
+ :ivar uuid: UUID of the protein
20
+ :ivar created_at: Creation date of the protein
21
21
  :ivar used_in_workflow: Whether the protein is used in a workflow
22
- :ivar ancestor_uuid: The UUID of the ancestor protein
22
+ :ivar ancestor_uuid: UUID of the ancestor protein
23
23
  :ivar sanitized: Whether the protein is sanitized
24
- :ivar name: The name of the protein
25
- :ivar data: The data of the protein
24
+ :ivar name: Name of the protein
25
+ :ivar data: Data of the protein
26
26
  :ivar public: Whether the protein is public
27
27
  """
28
28
 
@@ -36,7 +36,7 @@ class Protein(BaseModel):
36
36
  public: bool | None = None
37
37
  pocket: list[list[float]] | None = None
38
38
 
39
- def __repr__(self):
39
+ def __repr__(self) -> str:
40
40
  return f"<Protein name='{self.name}' created_at='{self.created_at}' uuid='{self.uuid}'>"
41
41
 
42
42
  def refresh(self, in_place: bool = True) -> Self:
@@ -67,7 +67,6 @@ class Protein(BaseModel):
67
67
  public: bool | None = None,
68
68
  pocket: list[list[float]] | None = None,
69
69
  ) -> Self:
70
- # Use current values unless new ones are passed in
71
70
  """
72
71
  Updates protein data
73
72
 
@@ -77,6 +76,7 @@ class Protein(BaseModel):
77
76
  :param pocket: New pocket of the protein
78
77
  :returns: Updated protein object
79
78
  """
79
+ # Use current values unless new ones are passed in
80
80
  updated_payload = {
81
81
  "name": name if name is not None else self.name,
82
82
  "data": data if data is not None else self.data,
@@ -312,6 +312,7 @@ def upload_protein(
312
312
 
313
313
  :param name: Name of the protein to create
314
314
  :param file_path: Path to the PDB file to upload
315
+ :param project_uuid: UUID of the project to create the protein in
315
316
  :returns: Protein object representing the uploaded protein
316
317
  :raises requests.HTTPError: if the request to the API fails
317
318
  """
rowan/types.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Shared type aliases for the Rowan package."""
2
2
 
3
- from typing import Any, TypeAlias
3
+ from typing import TypeAlias
4
4
 
5
5
  import stjames
6
6
  from rdkit import Chem
@@ -9,7 +9,9 @@ from .molecule import Molecule as RowanMolecule
9
9
 
10
10
  RdkitMol: TypeAlias = Chem.rdchem.Mol | Chem.rdchem.RWMol
11
11
  StJamesMolecule: TypeAlias = stjames.Molecule
12
- MoleculeInput: TypeAlias = dict[str, Any] | RowanMolecule | StJamesMolecule | RdkitMol | str
12
+ # 3D-structure input: molecule objects carrying coordinates. Excludes bare SMILES strings
13
+ # (no geometry) and dicts (internal serialization only).
14
+ StructureInput: TypeAlias = RowanMolecule | StJamesMolecule | RdkitMol
13
15
  SolventInput: TypeAlias = stjames.Solvent | str | None
14
16
  SMILES: TypeAlias = str
15
17
  UUID: TypeAlias = str
rowan/user.py CHANGED
@@ -11,9 +11,9 @@ class Organization(BaseModel):
11
11
  """
12
12
  A Rowan organization
13
13
 
14
- :ivar name: The name of the organization.
15
- :ivar weekly_credits: The weekly credits of the organization.
16
- :ivar credits: The credits of the organization.
14
+ :ivar name: Name of the organization.
15
+ :ivar weekly_credits: Weekly credits of the organization.
16
+ :ivar credits: Credits of the organization.
17
17
  """
18
18
 
19
19
  name: str
@@ -25,7 +25,7 @@ class OrganizationRole(BaseModel):
25
25
  """
26
26
  A Rowan organization role
27
27
 
28
- :ivar name: The name of the organization role.
28
+ :ivar name: Name of the organization role.
29
29
  """
30
30
 
31
31
  name: str
@@ -35,7 +35,7 @@ class SubscriptionPlan(BaseModel):
35
35
  """
36
36
  A Rowan subscription plan
37
37
 
38
- :ivar name: The name of the subscription plan.
38
+ :ivar name: Name of the subscription plan.
39
39
  """
40
40
 
41
41
  name: str
@@ -45,7 +45,7 @@ class IndividualSubscription(BaseModel):
45
45
  """
46
46
  A Rowan individual subscription
47
47
 
48
- :ivar subscription_plan: The subscription plan of the individual subscription.
48
+ :ivar subscription_plan: Subscription plan of the individual subscription.
49
49
  """
50
50
 
51
51
  subscription_plan: SubscriptionPlan
@@ -55,19 +55,23 @@ class User(BaseModel):
55
55
  """
56
56
  A Rowan user
57
57
 
58
- :ivar uuid: The UUID of the user.
59
- :ivar username: The username of the user.
60
- :ivar email: The email of the user.
61
- :ivar firstname: The first name of the user.
62
- :ivar lastname: The last name of the user.
63
- :ivar weekly_credits: The weekly credits of the user.
64
- :ivar credits: The credits of the user.
65
- :ivar billing_name: The billing name of the user.
66
- :ivar billing_address: The billing address of the user.
67
- :ivar credit_balance_warning: The credit balance warning of the user.
68
- :ivar organization: The organization of the user.
69
- :ivar organization_role: The organization role of the user.
70
- :ivar individual_subscription: The individual subscription of the user.
58
+ :ivar uuid: UUID of the user.
59
+ :ivar username: Username of the user.
60
+ :ivar email: Email of the user.
61
+ :ivar firstname: First name of the user.
62
+ :ivar lastname: Last name of the user.
63
+ :ivar weekly_credits: Weekly credits of the user.
64
+ :ivar credits: Credits of the user.
65
+ :ivar billing_name: Billing name of the user.
66
+ :ivar billing_address: Billing address of the user.
67
+ :ivar credit_balance_warning: Credit balance warning of the user.
68
+ :ivar organization: Organization of the user.
69
+ :ivar organization_role: Organization role of the user.
70
+ :ivar individual_subscription: Individual subscription of the user.
71
+ :ivar enabled_workflows: Workflow types this account may submit, as backend slugs that
72
+ mostly match the ``submit_<slug>_workflow`` names (note ``molecular_dynamics`` is
73
+ protein MD).
74
+ :ivar feature_list: Feature flags enabled for this account.
71
75
  """
72
76
 
73
77
  uuid: str
@@ -83,6 +87,8 @@ class User(BaseModel):
83
87
  organization: Organization | None = None
84
88
  organization_role: OrganizationRole | None = None
85
89
  individual_subscription: IndividualSubscription | None = None
90
+ enabled_workflows: list[str] = []
91
+ feature_list: list[str] = []
86
92
 
87
93
  @property
88
94
  def name(self) -> str:
@@ -173,7 +179,7 @@ def verify_webhook_secret(
173
179
  ) -> bool:
174
180
  """Verify an incoming webhook request from Rowan.
175
181
 
176
- :param raw_body: The raw (unparsed) request body bytes.
182
+ :param raw_body: Raw (unparsed) request body bytes.
177
183
  :param signature_header: Value of the X-Rowan-Signature header.
178
184
  :param secret: Your webhook secret (from :func:`create_webhook_secret` or
179
185
  :func:`rotate_webhook_secret`).
@@ -9,7 +9,6 @@ Basic pattern:
9
9
 
10
10
  from stjames import (
11
11
  ETKDGSettings,
12
- LyrebirdSettings,
13
12
  MonteCarloMultipleMinimumSettings,
14
13
  SolventModel,
15
14
  iMTDGCSettings,
@@ -40,7 +39,14 @@ from .base import (
40
39
  )
41
40
  from .basic_calculation import BasicCalculationResult, submit_basic_calculation_workflow
42
41
  from .batch_docking import BatchDockingResult, submit_batch_docking_workflow
43
- from .bde import BDEEntry, BDEResult, submit_bde_workflow
42
+ from .bde import (
43
+ BDEEntry,
44
+ BDEResult,
45
+ find_bonds,
46
+ find_ch_bonds,
47
+ find_cx_bonds,
48
+ submit_bde_workflow,
49
+ )
44
50
  from .conformer_search import ConformerSearchResult, submit_conformer_search_workflow
45
51
  from .descriptors import DescriptorsResult, submit_descriptors_workflow
46
52
  from .docking import DockingResult, DockingScore, submit_docking_workflow
@@ -94,6 +100,7 @@ from .protein_binder_design import (
94
100
  from .protein_cofolding import (
95
101
  CofoldingModel,
96
102
  CofoldingResult,
103
+ CofoldingTemplate,
97
104
  ConstraintTarget,
98
105
  ContactConstraint,
99
106
  PocketConstraint,
rowan/workflows/admet.py CHANGED
@@ -3,9 +3,8 @@
3
3
  import stjames
4
4
 
5
5
  from ..folder import Folder
6
- from ..types import MoleculeInput
7
6
  from ..utils import api_client
8
- from .base import Workflow, WorkflowResult, extract_smiles, register_result
7
+ from .base import SMILES, Workflow, WorkflowResult, extract_smiles, register_result
9
8
 
10
9
 
11
10
  @register_result("admet")
@@ -26,7 +25,7 @@ class ADMETResult(WorkflowResult):
26
25
 
27
26
 
28
27
  def submit_admet_workflow(
29
- initial_smiles: str | MoleculeInput,
28
+ initial_smiles: SMILES,
30
29
  name: str = "ADMET Workflow",
31
30
  folder_uuid: str | None = None,
32
31
  folder: Folder | None = None,
@@ -1,14 +1,22 @@
1
1
  """Analogue docking workflow - dock analogues using a template ligand."""
2
2
 
3
+ from typing import Literal
4
+
3
5
  import stjames
4
6
 
5
7
  from ..calculation import Calculation, retrieve_calculation
6
8
  from ..folder import Folder
7
9
  from ..molecule import Molecule
8
10
  from ..protein import Protein, retrieve_protein
9
- from ..types import MoleculeInput
10
11
  from ..utils import api_client
11
- from .base import Workflow, WorkflowResult, molecule_to_dict, register_result
12
+ from .base import (
13
+ StructureInput,
14
+ Workflow,
15
+ WorkflowResult,
16
+ molecule_to_dict,
17
+ register_result,
18
+ require_coordinates,
19
+ )
12
20
  from .docking import DockingScore
13
21
 
14
22
 
@@ -32,6 +40,13 @@ class AnalogueDockingResult(WorkflowResult):
32
40
  best_smiles = smiles
33
41
  return f"<AnalogueDockingResult analogues={n} best=({best_score:.2f}, {best_smiles!r})>"
34
42
 
43
+ def __post_init__(self) -> None:
44
+ """Default `complex_pdb` to None on each analogue's scores, then parse."""
45
+ for scores in (self.workflow_data.get("analogue_scores") or {}).values():
46
+ for score in scores:
47
+ score.setdefault("complex_pdb", None)
48
+ super().__post_init__()
49
+
35
50
  @property
36
51
  def analogue_scores(self) -> dict[str, list[DockingScore]]:
37
52
  """Docking scores for each analogue SMILES."""
@@ -157,11 +172,11 @@ class AnalogueDockingResult(WorkflowResult):
157
172
 
158
173
  def submit_analogue_docking_workflow(
159
174
  analogues: list[str],
160
- initial_molecule: MoleculeInput,
175
+ initial_molecule: StructureInput,
161
176
  protein: str | Protein,
162
- executable: str = "vina",
163
- scoring_function: str = "vinardo",
164
- exhaustiveness: float = 8,
177
+ scoring_function: Literal["vina", "vinardo"] = "vinardo",
178
+ exhaustiveness: int = 8,
179
+ max_poses: int = 4,
165
180
  num_conformers_per_analogue: int = 100,
166
181
  require_posebusters: bool = False,
167
182
  run_local_optimization: bool = False,
@@ -178,9 +193,9 @@ def submit_analogue_docking_workflow(
178
193
  :param analogues: SMILES strings to dock.
179
194
  :param initial_molecule: Template to which to align molecules to.
180
195
  :param protein: Protein to dock. Can be input as a uuid or a Protein object.
181
- :param executable: Which docking implementation to use.
182
- :param scoring_function: Which docking scoring function to use.
183
- :param exhaustiveness: Which exhaustiveness to employ.
196
+ :param scoring_function: Docking scoring function: "vina" or "vinardo".
197
+ :param exhaustiveness: How many times Vina attempts to find a pose for each conformer.
198
+ :param max_poses: Maximum number of poses generated per input conformer.
184
199
  :param num_conformers_per_analogue: Maximum number of conformers to generate per analogue.
185
200
  :param require_posebusters: Filter conformers based on PoseBusters validity before docking.
186
201
  :param run_local_optimization: Whether to run a local opt in docking pocket or just score.
@@ -193,24 +208,26 @@ def submit_analogue_docking_workflow(
193
208
  :returns: Workflow object representing the submitted analogue-docking workflow.
194
209
  :raises requests.HTTPError: if the request to the API fails.
195
210
  """
211
+ require_coordinates(initial_molecule)
196
212
  if folder and folder_uuid:
197
213
  raise ValueError("Provide either `folder` or `folder_uuid`, not both.")
198
214
  if folder:
199
215
  folder_uuid = folder.uuid
200
216
  docking_settings = {
201
- "executable": executable,
202
- "exhaustiveness": exhaustiveness,
217
+ "executable": "vina",
203
218
  "scoring_function": scoring_function,
219
+ "exhaustiveness": exhaustiveness,
220
+ "max_poses": max_poses,
204
221
  }
205
222
 
206
- initial_molecule = molecule_to_dict(initial_molecule)
223
+ mol_dict = molecule_to_dict(initial_molecule)
207
224
 
208
225
  if isinstance(protein, Protein):
209
226
  protein = protein.uuid
210
227
 
211
228
  workflow = stjames.AnalogueDockingWorkflow(
212
229
  analogues=analogues,
213
- initial_molecule=initial_molecule,
230
+ initial_molecule=mol_dict,
214
231
  protein=protein,
215
232
  docking_settings=docking_settings,
216
233
  num_conformers_per_analogue=num_conformers_per_analogue,
@@ -219,7 +236,7 @@ def submit_analogue_docking_workflow(
219
236
  )
220
237
 
221
238
  data = {
222
- "initial_molecule": initial_molecule,
239
+ "initial_molecule": mol_dict,
223
240
  "workflow_type": "analogue_docking",
224
241
  "workflow_data": workflow.model_dump(serialize_as_any=True, mode="json"),
225
242
  "name": name,
rowan/workflows/base.py CHANGED
@@ -15,7 +15,7 @@ from rdkit import Chem
15
15
 
16
16
  from ..folder import Folder
17
17
  from ..molecule import Molecule as RowanMolecule
18
- from ..types import SMILES, MoleculeInput
18
+ from ..types import SMILES, StructureInput
19
19
  from ..utils import api_client
20
20
 
21
21
  logger = logging.getLogger(__name__)
@@ -30,7 +30,9 @@ Task = stjames.Task
30
30
  Settings = stjames.Settings
31
31
  MultiStageOptSettings = stjames.MultiStageOptSettings
32
32
  ETKDGSettings = stjames.ETKDGSettings
33
+ OpenConfSettings = stjames.OpenConfSettings
33
34
  ConformerGenSettings = stjames.conformers.ConformerGenSettings
35
+ ConformerClusteringSettings = stjames.ConformerClusteringSettings
34
36
 
35
37
 
36
38
  @dataclass(frozen=True, slots=True)
@@ -106,8 +108,21 @@ class WorkflowResult:
106
108
  try:
107
109
  obj = self._stjames_class.model_validate(self.workflow_data) # type: ignore[attr-defined]
108
110
  object.__setattr__(self, "_workflow", obj)
109
- except ValidationError:
110
- pass # incomplete or invalid data; _workflow stays None
111
+ except ValidationError as e:
112
+ if not self.complete:
113
+ # Still-running workflow: partial data is expected; leave `_workflow`
114
+ # None and fall back to `.data` until the workflow finishes.
115
+ return
116
+ # A completed workflow whose data won't validate is a real mismatch between
117
+ # the installed stjames model and the server response. Fail clearly here
118
+ # rather than as a downstream AttributeError on a None `_workflow`.
119
+ raise ValueError(
120
+ f"""\
121
+ Could not parse the completed '{self.workflow_type}' workflow into \
122
+ {self._stjames_class.__name__}. The installed stjames version is likely out of sync with \
123
+ the server (a field is missing, extra, or the wrong type). Underlying validation error:
124
+ {e}"""
125
+ ) from e
111
126
 
112
127
  @property
113
128
  def data(self) -> dict[str, Any]:
@@ -546,7 +561,7 @@ Workflow: {self.name}
546
561
  f.write(response.content)
547
562
 
548
563
 
549
- def extract_smiles(mol: SMILES | MoleculeInput) -> SMILES:
564
+ def extract_smiles(mol: SMILES | StructureInput | dict[str, Any]) -> SMILES:
550
565
  """
551
566
  Extract a SMILES string from a molecule input or return the string directly.
552
567
 
@@ -575,7 +590,7 @@ def extract_smiles(mol: SMILES | MoleculeInput) -> SMILES:
575
590
  return smiles
576
591
 
577
592
 
578
- def molecule_to_stjames(mol: MoleculeInput) -> stjames.Molecule:
593
+ def molecule_to_stjames(mol: StructureInput | dict[str, Any]) -> stjames.Molecule:
579
594
  """Convert any molecule input type to a stjames.Molecule."""
580
595
  match mol:
581
596
  case RowanMolecule():
@@ -590,16 +605,37 @@ def molecule_to_stjames(mol: MoleculeInput) -> stjames.Molecule:
590
605
  raise TypeError(f"Cannot convert {type(mol)} to stjames.Molecule")
591
606
 
592
607
 
593
- def molecule_to_dict(mol: MoleculeInput) -> dict[str, Any]:
608
+ def require_coordinates(mol: StructureInput) -> None:
594
609
  """
595
- Convert any molecule input type to a dict for API submission.
610
+ Validate that a molecule input carries real 3D coordinates.
611
+
612
+ Geometry-based workflows operate on 3D structure, so reject inputs that have no
613
+ geometry rather than silently embedding an arbitrary conformer: a SMILES string
614
+ (no coordinates) or an RDKit molecule with no conformer.
615
+
616
+ :param mol: molecule input to check
617
+ :raises ValueError: if the input has no 3D coordinates
618
+ """
619
+ if isinstance(mol, str):
620
+ raise ValueError(
621
+ "This workflow requires a 3D structure, not a SMILES string. Build one "
622
+ 'explicitly, e.g. rowan.Molecule.from_smiles("CCO") or rowan.Molecule.from_xyz(...).'
623
+ )
624
+ if isinstance(mol, Chem.Mol) and mol.GetNumConformers() == 0:
625
+ raise ValueError(
626
+ "RDKit molecule has no 3D conformer. Embed one first (e.g. "
627
+ "rdkit.Chem.AllChem.EmbedMolecule) or use rowan.Molecule.from_smiles(...)."
628
+ )
629
+
630
+
631
+ def molecule_to_dict(mol: StructureInput | dict[str, Any]) -> dict[str, Any]:
632
+ """
633
+ Convert a 3D molecule input to a dict for API submission.
596
634
 
597
635
  :param mol: Molecule as Molecule, stjames.Molecule, RDKit Mol, or dict.
598
636
  :returns: Dict representation suitable for API submission.
599
637
  """
600
638
  match mol:
601
- case str():
602
- return RowanMolecule.from_smiles(mol).to_stjames().model_dump(mode="json")
603
639
  case RowanMolecule():
604
640
  return mol.to_stjames().model_dump(mode="json")
605
641
  case stjames.Molecule():
@@ -615,8 +651,8 @@ def molecule_to_dict(mol: MoleculeInput) -> dict[str, Any]:
615
651
  def submit_workflow(
616
652
  workflow_type: stjames.WORKFLOW_NAME,
617
653
  workflow_data: dict[str, Any] | None = None,
618
- initial_molecule: MoleculeInput | None = None,
619
- initial_smiles: str | None = None,
654
+ initial_molecule: StructureInput | dict[str, Any] | None = None,
655
+ initial_smiles: SMILES | None = None,
620
656
  name: str | None = None,
621
657
  folder_uuid: str | Folder | None = None,
622
658
  max_credits: int | None = None,
@@ -785,8 +821,8 @@ def list_workflows(
785
821
  def batch_submit_workflow(
786
822
  workflow_type: stjames.WORKFLOW_NAME,
787
823
  workflow_data: dict[str, Any] | None = None,
788
- initial_molecules: list[MoleculeInput] | None = None,
789
- initial_smileses: list[str] | None = None,
824
+ initial_molecules: list[StructureInput | dict[str, Any]] | None = None,
825
+ initial_smileses: list[SMILES] | None = None,
790
826
  names: list[str] | None = None,
791
827
  folder_uuid: str | Folder | None = None,
792
828
  max_credits: int | None = None,