rowan-python 3.1.3__py3-none-any.whl → 3.1.4__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.
rowan/__init__.py CHANGED
@@ -2,6 +2,8 @@
2
2
  from . import constants
3
3
  from stjames import (
4
4
  Atom,
5
+ BindingPoseContact,
6
+ ConformerGenSettings,
5
7
  Constraint,
6
8
  ConstraintType,
7
9
  ConformerClusteringSettings,
@@ -14,15 +16,19 @@ from stjames import (
14
16
  KMeansClusteringSettings,
15
17
  Method,
16
18
  Mode,
19
+ MSAFormat,
17
20
  MultiStageOptSettings,
18
21
  OpenConfSettings,
19
22
  OptimizationSettings,
20
23
  PBCDFTSettings,
21
24
  PeriodicCell,
25
+ ProteinSequence,
26
+ ScanSettings,
22
27
  Settings,
23
28
  Solvent,
24
29
  SolventSettings,
25
30
  Task,
31
+ VibrationalMode,
26
32
  )
27
33
  from stjames.workflows.relative_binding_free_energy_perturbation import RBFEGraph, RBFEGraphEdge
28
34
  from stjames.excited_state_settings import TDDFTSettings
rowan/calculation.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from typing import Self
4
4
 
5
+ import httpx
5
6
  import stjames
6
7
  from pydantic import BaseModel, ConfigDict, Field
7
8
 
@@ -62,10 +63,13 @@ class Calculation(BaseModel):
62
63
  :param in_place: If True, update this instance in-place. If False, return new instance.
63
64
  :returns: Updated Calculation object.
64
65
  """
65
- with api_client() as client:
66
- response = client.get(f"/calculation/{self.uuid}/stjames")
67
- response.raise_for_status()
68
- data = response.json()
66
+ try:
67
+ with api_client() as client:
68
+ response = client.get(f"/calculation/{self.uuid}/stjames")
69
+ response.raise_for_status()
70
+ data = response.json()
71
+ except httpx.TimeoutException as e:
72
+ raise TimeoutError(f"Timed out fetching calculation {self.uuid}.") from e
69
73
 
70
74
  molecules = _parse_molecules(data.get("molecules", []))
71
75
 
@@ -94,11 +98,15 @@ def retrieve_calculation(uuid: str) -> Calculation:
94
98
  :param uuid: UUID of the calculation to retrieve.
95
99
  :returns: Calculation object with the fetched data.
96
100
  :raises requests.HTTPError: If the API request fails.
101
+ :raises TimeoutError: If the response times out.
97
102
  """
98
- with api_client() as client:
99
- response = client.get(f"/calculation/{uuid}/stjames")
100
- response.raise_for_status()
101
- data = response.json()
103
+ try:
104
+ with api_client() as client:
105
+ response = client.get(f"/calculation/{uuid}/stjames")
106
+ response.raise_for_status()
107
+ data = response.json()
108
+ except httpx.TimeoutException as e:
109
+ raise TimeoutError(f"Timed out fetching calculation {uuid}.") from e
102
110
 
103
111
  molecules = _parse_molecules(data.get("molecules", []))
104
112
 
rowan/folder.py CHANGED
@@ -1,11 +1,16 @@
1
+ from __future__ import annotations
2
+
1
3
  from datetime import datetime
2
- from typing import Any, Self
4
+ from typing import TYPE_CHECKING, Any, Self
3
5
 
4
6
  from pydantic import BaseModel
5
7
 
6
8
  from .project import default_project, retrieve_project
7
9
  from .utils import api_client, get_project_uuid
8
10
 
11
+ if TYPE_CHECKING:
12
+ from .workflows.base import Workflow
13
+
9
14
 
10
15
  class Folder(BaseModel):
11
16
  """
@@ -121,6 +126,77 @@ class Folder(BaseModel):
121
126
  """
122
127
  print_folder_tree(self.uuid, max_depth, show_uuids)
123
128
 
129
+ def children(self, size: int = 100) -> list[Folder]:
130
+ """
131
+ List all child folders directly inside this folder.
132
+
133
+ :param size: Maximum number of child folders to return.
134
+ :returns: List of child Folder objects.
135
+ :raises HTTPError: If the API request fails.
136
+ """
137
+ return list_folders(parent_uuid=self.uuid, size=size)
138
+
139
+ def workflows(self, size: int = 100) -> list[Workflow]:
140
+ """
141
+ List all workflows directly inside this folder.
142
+
143
+ :param size: Maximum number of workflows to return.
144
+ :returns: List of Workflow objects.
145
+ :raises HTTPError: If the API request fails.
146
+ """
147
+ from .workflows.base import list_workflows
148
+
149
+ return list_workflows(parent_uuid=self.uuid, size=size)
150
+
151
+ def contents(self, size: int = 100) -> list[Folder | Workflow]:
152
+ """
153
+ List everything directly inside this folder, both child folders and workflows.
154
+
155
+ Folders come first, followed by workflows. For a single type, use :func:`children`
156
+ or :func:`workflows`.
157
+
158
+ :param size: Maximum number of items of each type to return.
159
+ :returns: List of Folder and Workflow objects.
160
+ :raises HTTPError: If the API request fails.
161
+ """
162
+ return [*self.children(size=size), *self.workflows(size=size)]
163
+
164
+ def parent(self) -> Folder | None:
165
+ """
166
+ Retrieve the parent folder, or None if this is a root folder.
167
+
168
+ :returns: Parent Folder, or None if there is no parent.
169
+ :raises HTTPError: If the API request fails.
170
+ """
171
+ if self.parent_uuid is None:
172
+ return None
173
+ return retrieve_folder(self.parent_uuid)
174
+
175
+ def __truediv__(self, name: str) -> Folder:
176
+ """
177
+ Traverse into a child folder by name using the ``/`` operator.
178
+
179
+ Example::
180
+
181
+ root = rowan.root_folder()
182
+ subfolder = root / "CDK2" / "docking"
183
+
184
+ :param name: Exact name of the child folder to navigate into.
185
+ :returns: Child Folder with the given name.
186
+ :raises ValueError: If no child with that name exists, or if multiple children share
187
+ the same name (use :func:`children` and select by UUID to disambiguate).
188
+ """
189
+ matches = [f for f in self.children(size=200) if f.name == name]
190
+ if not matches:
191
+ raise ValueError(f"No child folder named {name!r} in {self.name!r} ({self.uuid})")
192
+ if len(matches) > 1:
193
+ uuids = ", ".join(f.uuid for f in matches)
194
+ raise ValueError(
195
+ f"Multiple child folders named {name!r} in {self.name!r} ({self.uuid}). "
196
+ f"Use retrieve_folder() with one of these UUIDs to disambiguate: {uuids}"
197
+ )
198
+ return matches[0]
199
+
124
200
 
125
201
  def retrieve_folder(uuid: str) -> Folder:
126
202
  """
@@ -147,6 +223,9 @@ def list_folders(
147
223
  """
148
224
  Retrieve a list of folders based on the specified criteria.
149
225
 
226
+ If no ``parent_uuid`` is given and a project is active (via :func:`set_project` or
227
+ ``rowan.project_uuid``), lists folders rooted at that project's root folder.
228
+
150
229
  :param parent_uuid: UUID of the parent folder to filter by.
151
230
  :param name_contains: Substring to search for in folder names.
152
231
  :param public: Filter folders by their public status.
@@ -156,6 +235,11 @@ def list_folders(
156
235
  :returns: List of Folder objects that match the search criteria.
157
236
  :raises requests.HTTPError: if the request to the API fails.
158
237
  """
238
+ if parent_uuid is None:
239
+ if project_uuid := get_project_uuid():
240
+ parent_uuid = retrieve_project(project_uuid).root_folder_uuid
241
+ else:
242
+ parent_uuid = default_project().root_folder_uuid
159
243
 
160
244
  params: dict[str, Any] = {
161
245
  "page": page,
@@ -189,6 +273,9 @@ def create_folder(
189
273
  """
190
274
  Create a new folder.
191
275
 
276
+ If no ``parent_uuid`` is given and a project is active (via :func:`set_project` or
277
+ ``rowan.project_uuid``), the folder is created inside that project's root folder.
278
+
192
279
  :param name: Name of the folder.
193
280
  :param parent_uuid: UUID of the parent folder.
194
281
  :param notes: Description of the folder.
@@ -196,6 +283,12 @@ def create_folder(
196
283
  :param public: Whether the folder is public.
197
284
  :returns: Newly created folder.
198
285
  """
286
+ if parent_uuid is None:
287
+ if project_uuid := get_project_uuid():
288
+ parent_uuid = retrieve_project(project_uuid).root_folder_uuid
289
+ else:
290
+ parent_uuid = default_project().root_folder_uuid
291
+
199
292
  data = {
200
293
  "name": name,
201
294
  "parent_uuid": parent_uuid,
@@ -210,6 +303,32 @@ def create_folder(
210
303
  return Folder(**folder_data)
211
304
 
212
305
 
306
+ def root_folder() -> Folder:
307
+ """
308
+ Get the root folder of the active project.
309
+
310
+ The root folder is the top of the folder tree you navigate and store workflows in. Use the
311
+ active project set via :func:`set_project` (or ``rowan.project_uuid``), falling back to the
312
+ default project.
313
+
314
+ Example::
315
+
316
+ root = rowan.root_folder()
317
+ for child in root.children():
318
+ print(child.name)
319
+ batch = root / "CDK2" / "docking"
320
+
321
+ :returns: Root Folder of the active or default project.
322
+ :raises HTTPError: If the API request fails.
323
+ """
324
+ if project_uuid := get_project_uuid():
325
+ root_uuid = retrieve_project(project_uuid).root_folder_uuid
326
+ else:
327
+ root_uuid = default_project().root_folder_uuid
328
+ assert root_uuid is not None
329
+ return retrieve_folder(root_uuid)
330
+
331
+
213
332
  def get_folder(path: str, create: bool = True) -> Folder:
214
333
  """
215
334
  Get a folder by name or nested path within the default project.
rowan/molecule.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Molecule class for representing molecular structures and computed properties."""
2
2
 
3
+ import math
4
+ import random
3
5
  from pathlib import Path
4
6
  from typing import Any, Self
5
7
 
@@ -272,6 +274,77 @@ class Molecule(BaseModel):
272
274
  """
273
275
  return self._stjames.dihedral(i, j, k, l, degrees=degrees)
274
276
 
277
+ def perturb(self, stddev: float = 0.005) -> "Molecule":
278
+ """
279
+ Return a copy with random Gaussian noise added to each atom position.
280
+
281
+ Useful for breaking symmetry before resubmitting an optimization (e.g. when
282
+ a calculation is stuck in a saddle point or converges to an unwanted geometry).
283
+
284
+ :param stddev: standard deviation of the Gaussian displacement (Angstroms)
285
+ :returns: new Molecule with perturbed coordinates
286
+ """
287
+
288
+ def _gauss() -> float:
289
+ u1 = random.random()
290
+ u2 = random.random()
291
+ return math.sqrt(-2.0 * math.log(u1)) * math.cos(2.0 * math.pi * u2) * stddev
292
+
293
+ new_atoms = [
294
+ stjames.Atom(
295
+ atomic_number=atom.atomic_number,
296
+ position=(
297
+ atom.position[0] + _gauss(),
298
+ atom.position[1] + _gauss(),
299
+ atom.position[2] + _gauss(),
300
+ ),
301
+ mass=atom.mass,
302
+ )
303
+ for atom in self._stjames.atoms
304
+ ]
305
+ new_mol = self._stjames.model_copy(update={"atoms": new_atoms})
306
+ return Molecule(_stjames=new_mol)
307
+
308
+ def displace_along_mode(self, mode: stjames.VibrationalMode, displacement: float) -> "Molecule":
309
+ """
310
+ Return a copy with atom positions displaced along a vibrational normal mode.
311
+
312
+ Requires a prior frequency calculation. For a transition state, the imaginary
313
+ mode has a negative frequency and points toward the reactant or product.
314
+
315
+ :param mode: vibrational mode to displace along, from ``vibrational_modes``
316
+ :param displacement: displacement distance along the normalized mode (Angstroms)
317
+ :returns: new Molecule with displaced coordinates
318
+ :raises ValueError: if the mode has no displacements or zero-norm displacements
319
+ """
320
+ raw = mode.displacements
321
+ if not raw:
322
+ raise ValueError(
323
+ "Vibrational mode has no displacements. "
324
+ "Rerun the calculation with frequencies=True to displace along a mode."
325
+ )
326
+ norm = math.sqrt(sum(x**2 + y**2 + z**2 for x, y, z in raw))
327
+ if norm == 0:
328
+ raise ValueError(
329
+ "Vibrational mode has zero-norm displacements. "
330
+ "This indicates malformed frequency output, try displacing along a different mode."
331
+ )
332
+
333
+ new_atoms = [
334
+ stjames.Atom(
335
+ atomic_number=atom.atomic_number,
336
+ position=(
337
+ atom.position[0] + (dx / norm) * displacement,
338
+ atom.position[1] + (dy / norm) * displacement,
339
+ atom.position[2] + (dz / norm) * displacement,
340
+ ),
341
+ mass=atom.mass,
342
+ )
343
+ for atom, (dx, dy, dz) in zip(self._stjames.atoms, raw, strict=True)
344
+ ]
345
+ new_mol = self._stjames.model_copy(update={"atoms": new_atoms})
346
+ return Molecule(_stjames=new_mol)
347
+
275
348
 
276
349
  def load_named_ligands(path: Path | str) -> dict[str, Molecule]:
277
350
  """
rowan/protein.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import time
2
+ import warnings
2
3
  from datetime import datetime
3
4
  from pathlib import Path
4
5
  from typing import Any, Self
@@ -39,6 +40,85 @@ class Protein(BaseModel):
39
40
  def __repr__(self) -> str:
40
41
  return f"<Protein name='{self.name}' created_at='{self.created_at}' uuid='{self.uuid}'>"
41
42
 
43
+ @property
44
+ def chains(self) -> list[str]:
45
+ """Polymer chain IDs present in this protein."""
46
+ if not self.data:
47
+ return []
48
+ model = self.data["models"][0]
49
+ chain_ids: set[str] = set(model["polymer"].keys())
50
+ for section in ("non_polymer", "water", "branched"):
51
+ for entity in model.get(section, {}).values():
52
+ if chain := entity.get("polymer"):
53
+ chain_ids.add(chain)
54
+ return list(chain_ids)
55
+
56
+ def select_chains(self, chains: list[str]) -> "Protein":
57
+ """
58
+ Create a new protein record containing only the specified chains.
59
+
60
+ :param chains: Chain IDs to keep (e.g. ``["A"]``).
61
+ :returns: New Protein object with only the selected chains.
62
+ :raises ValueError: If any requested chain is not present.
63
+ :raises requests.HTTPError: If the API request fails.
64
+ """
65
+ if not self.data:
66
+ raise ValueError("Protein data not loaded — call refresh() first.")
67
+ available = self.chains
68
+ missing = [c for c in chains if c not in available]
69
+ if missing:
70
+ raise ValueError(f"Chain(s) {missing} not found. Available: {available}")
71
+
72
+ chain_set = set(chains)
73
+ model = self.data["models"][0]
74
+
75
+ filtered_polymer = {k: v for k, v in model["polymer"].items() if k in chain_set}
76
+ filtered_non_polymer = {
77
+ k: v for k, v in model["non_polymer"].items() if v.get("polymer") in chain_set
78
+ }
79
+ filtered_water = {k: v for k, v in model["water"].items() if v.get("polymer") in chain_set}
80
+ filtered_branched = {
81
+ k: v for k, v in model["branched"].items() if v.get("polymer") in chain_set
82
+ }
83
+
84
+ valid_atom_ids: set[int] = set()
85
+ for polymer in filtered_polymer.values():
86
+ for residue in polymer["residues"].values():
87
+ valid_atom_ids.update(int(a) for a in residue["atoms"])
88
+ for entity in filtered_non_polymer.values():
89
+ valid_atom_ids.update(int(a) for a in entity.get("atoms", {}))
90
+ for entity in filtered_water.values():
91
+ valid_atom_ids.update(int(a) for a in entity.get("atoms", {}))
92
+ for entity in filtered_branched.values():
93
+ valid_atom_ids.update(int(a) for a in entity.get("atoms", {}))
94
+
95
+ filtered_connections = []
96
+ for row in model.get("connections", []):
97
+ if row[0] not in valid_atom_ids:
98
+ continue
99
+ targets = [t for t in row[1:] if t in valid_atom_ids]
100
+ if targets:
101
+ filtered_connections.append([row[0], *targets])
102
+
103
+ filtered_model = {
104
+ "polymer": filtered_polymer,
105
+ "non_polymer": filtered_non_polymer,
106
+ "water": filtered_water,
107
+ "branched": filtered_branched,
108
+ "connections": filtered_connections,
109
+ }
110
+ filtered_data = {**self.data, "models": [filtered_model]}
111
+
112
+ with api_client() as client:
113
+ payload = {
114
+ "name": f"{self.name} (chains {','.join(chains)})",
115
+ "protein_data": filtered_data,
116
+ "ancestor_uuid": self.uuid,
117
+ }
118
+ response = client.post("/protein", json=payload)
119
+ response.raise_for_status()
120
+ return Protein(**response.json())
121
+
42
122
  def refresh(self, in_place: bool = True) -> Self:
43
123
  """
44
124
  Loads protein data
@@ -323,7 +403,6 @@ def upload_protein(
323
403
  # Step 1: Read the file and post it to the conversion endpoint.
324
404
  conversion_payload = {"name": name, "text": file_path.read_text()}
325
405
  conversion_response = client.post("/convert/pdb_file_to_protein", json=conversion_payload)
326
- conversion_response.raise_for_status() # Ensure the request was successful
327
406
 
328
407
  # Extract the JSON data from the conversion response.
329
408
  protein_data = conversion_response.json()
@@ -335,20 +414,19 @@ def upload_protein(
335
414
  "project_uuid": project_uuid,
336
415
  }
337
416
  final_response = client.post("/protein", json=creation_payload)
338
- final_response.raise_for_status()
339
417
 
340
418
  # Deserialize the final JSON response into a Protein object and return it.
341
419
  return Protein(**final_response.json())
342
420
 
343
421
 
344
422
  def create_protein_from_pdb_id(
345
- name: str, code: str, project_uuid: str | Project | None = None
423
+ code: str, name: str | None = None, project_uuid: str | Project | None = None
346
424
  ) -> Protein:
347
425
  """
348
426
  Creates a protein from a PDB ID.
349
427
 
350
- :param name: Name of the protein to create
351
428
  :param code: PDB ID of the protein to create
429
+ :param name: Name of the protein. Defaults to the PDB ID.
352
430
  :param project_uuid: UUID of the project to create the protein in
353
431
  :returns: Protein object representing the created protein
354
432
  :raises requests.HTTPError: if the request to the API fails
@@ -356,21 +434,22 @@ def create_protein_from_pdb_id(
356
434
  if isinstance(project_uuid, Project):
357
435
  project_uuid = project_uuid.uuid
358
436
  with api_client() as client:
359
- # Step 1: Read the file and post it to the conversion endpoint.
360
437
  conversion_response = client.post(f"/convert/pdb_id_to_protein?pdb_id={code}")
361
- conversion_response.raise_for_status() # Ensure the request was successful
362
-
363
- # Extract the JSON data from the conversion response.
364
438
  protein_data = conversion_response.json()
365
439
 
366
- # Step 2: Use the converted data to create the final protein object.
367
440
  creation_payload = {
368
- "name": name,
441
+ "name": name or code,
369
442
  "protein_data": protein_data,
370
443
  "project_uuid": project_uuid,
371
444
  }
372
445
  final_response = client.post("/protein", json=creation_payload)
373
- final_response.raise_for_status()
374
446
 
375
- # Deserialize the final JSON response into a Protein object and return it.
376
- return Protein(**final_response.json())
447
+ protein = Protein(**final_response.json())
448
+ chains = protein.chains
449
+ if len(chains) > 1:
450
+ warnings.warn(
451
+ f"{code} has multiple chains {chains}. Select one with "
452
+ f"protein.select_chains(['{chains[0]}']).",
453
+ stacklevel=2,
454
+ )
455
+ return protein
rowan/utils.py CHANGED
@@ -52,13 +52,33 @@ def smiles_to_stjames(smiles: str) -> stjames.Molecule:
52
52
  return stjames.Molecule.from_smiles(smiles)
53
53
 
54
54
 
55
+ def _raise_for_status(response: httpx.Response) -> None:
56
+ """Response hook that raises HTTPStatusError with the API's detail message if available."""
57
+ try:
58
+ response.raise_for_status()
59
+ except httpx.HTTPStatusError as e:
60
+ try:
61
+ response.read()
62
+ detail = response.json().get("detail")
63
+ except Exception:
64
+ detail = None
65
+ if detail:
66
+ raise httpx.HTTPStatusError(
67
+ f"{e.response.status_code} {detail}",
68
+ request=e.request,
69
+ response=e.response,
70
+ ) from None
71
+ raise
72
+
73
+
55
74
  @contextmanager
56
75
  def api_client() -> Generator[httpx.Client, None, None]:
57
76
  """Wraps `httpx.Client` with Rowan-specific kwargs."""
58
77
  with httpx.Client(
59
78
  base_url=API_URL,
60
79
  headers={"X-API-Key": get_api_key()},
61
- timeout=30,
80
+ timeout=120,
81
+ event_hooks={"response": [_raise_for_status]},
62
82
  ) as client:
63
83
  yield client
64
84
 
rowan/workflows/base.py CHANGED
@@ -9,14 +9,16 @@ from datetime import datetime
9
9
  from pathlib import Path
10
10
  from typing import Any, Callable, ClassVar, Self
11
11
 
12
+ import httpx
12
13
  import stjames
13
14
  from pydantic import BaseModel, ConfigDict, Field, ValidationError
14
15
  from rdkit import Chem
15
16
 
16
17
  from ..folder import Folder
17
18
  from ..molecule import Molecule as RowanMolecule
19
+ from ..project import default_project, retrieve_project
18
20
  from ..types import SMILES, StructureInput
19
- from ..utils import api_client
21
+ from ..utils import api_client, get_project_uuid
20
22
 
21
23
  logger = logging.getLogger(__name__)
22
24
  logger.setLevel(logging.INFO)
@@ -648,6 +650,9 @@ def molecule_to_dict(mol: StructureInput | dict[str, Any]) -> dict[str, Any]:
648
650
  raise TypeError(f"Cannot convert {type(mol)} to molecule dict")
649
651
 
650
652
 
653
+ _FEATURE_GATE_DETAIL = "You do not have access to this feature."
654
+
655
+
651
656
  def submit_workflow(
652
657
  workflow_type: stjames.WORKFLOW_NAME,
653
658
  workflow_data: dict[str, Any] | None = None,
@@ -700,8 +705,16 @@ def submit_workflow(
700
705
  raise ValueError("You must provide either `initial_smiles` or a valid `initial_molecule`.")
701
706
 
702
707
  with api_client() as client:
703
- response = client.post("/workflow", json=data)
704
- response.raise_for_status()
708
+ try:
709
+ response = client.post("/workflow", json=data)
710
+ except httpx.HTTPStatusError as e:
711
+ if _FEATURE_GATE_DETAIL in str(e):
712
+ raise PermissionError(
713
+ f"{e} Visit https://labs.rowansci.com/account/settings to upgrade your account"
714
+ " or contact us for access. Call rowan.whoami() to see your current"
715
+ " .enabled_workflows and .feature_list."
716
+ ) from None
717
+ raise
705
718
  return Workflow(**response.json())
706
719
 
707
720
 
@@ -792,6 +805,12 @@ def list_workflows(
792
805
  :returns: List of Workflow objects that match the search criteria.
793
806
  :raises requests.HTTPError: if the request to the API fails.
794
807
  """
808
+ if parent_uuid is None:
809
+ if project_uuid := get_project_uuid():
810
+ parent_uuid = retrieve_project(project_uuid).root_folder_uuid
811
+ else:
812
+ parent_uuid = default_project().root_folder_uuid
813
+
795
814
  params: dict[str, Any] = {"page": page, "size": size}
796
815
 
797
816
  if parent_uuid is not None:
@@ -271,7 +271,7 @@ def submit_basic_calculation_workflow(
271
271
  workflow = stjames.BasicCalculationWorkflow(
272
272
  initial_molecule=mol_dict,
273
273
  settings=settings,
274
- tasks=settings.tasks,
274
+ tasks=tasks,
275
275
  engine=settings.engine,
276
276
  )
277
277
 
@@ -192,12 +192,12 @@ def submit_docking_workflow(
192
192
  if isinstance(protein, Protein):
193
193
  protein = protein.uuid
194
194
 
195
- docking_settings = {
196
- "executable": executable,
197
- "exhaustiveness": exhaustiveness,
198
- "max_poses": max_poses,
199
- "scoring_function": scoring_function,
200
- }
195
+ docking_settings = stjames.VinaSettings(
196
+ executable=executable,
197
+ exhaustiveness=exhaustiveness,
198
+ max_poses=max_poses,
199
+ scoring_function=scoring_function,
200
+ )
201
201
 
202
202
  workflow = stjames.DockingWorkflow(
203
203
  initial_molecule=mol_dict,
@@ -209,9 +209,12 @@ def submit_docking_workflow(
209
209
  docking_settings=docking_settings,
210
210
  )
211
211
 
212
+ workflow_data = workflow.model_dump(serialize_as_any=True, mode="json")
213
+ workflow_data["docking_settings"].setdefault("settings_type", "vina")
214
+
212
215
  data = {
213
216
  "workflow_type": "docking",
214
- "workflow_data": workflow.model_dump(serialize_as_any=True, mode="json"),
217
+ "workflow_data": workflow_data,
215
218
  "initial_molecule": mol_dict,
216
219
  "name": name,
217
220
  "folder_uuid": folder_uuid,
rowan/workflows/fukui.py CHANGED
@@ -68,9 +68,8 @@ def submit_fukui_workflow(
68
68
 
69
69
  - ``"solvent"``: solvent name string (e.g. ``"water"``, ``"dichloromethane"``,
70
70
  ``"dmso"``). See ``rowan.Solvent`` for all valid values.
71
- - ``"model"``: solvation model. Use ``"alpb"`` or ``"gbsa"`` for xTB methods
72
- (the defaults ``gfn1_xtb`` / ``gfn2_xtb``); use ``"cpcm"`` or ``"smd"`` for
73
- DFT methods.
71
+ - ``"model"``: solvation model (e.g. ``"alpb"``, ``"gbsa"``, ``"cpcmx"`` for xTB;
72
+ ``"cpcm"``, ``"pcm"`` for DFT). Must be compatible with the engine for the chosen method.
74
73
 
75
74
  Example: ``solvent_settings={"solvent": "water", "model": "alpb"}``
76
75
  :param name: Name of the workflow.
@@ -80,7 +79,6 @@ def submit_fukui_workflow(
80
79
  :param webhook_url: URL that Rowan will POST to when the workflow completes.
81
80
  :param is_draft: If True, submit the workflow as a draft without starting execution.
82
81
  :returns: Workflow object representing the submitted workflow.
83
- :raises ValueError: If the solvent model is incompatible with the chosen method.
84
82
  :raises requests.HTTPError: if the request to the API fails.
85
83
  """
86
84
  require_coordinates(initial_molecule)
@@ -88,21 +86,6 @@ def submit_fukui_workflow(
88
86
  raise ValueError("Provide either `folder` or `folder_uuid`, not both.")
89
87
  if folder:
90
88
  folder_uuid = folder.uuid
91
- if solvent_settings is not None:
92
- model = solvent_settings.get("model")
93
- is_xtb = stjames.Method(fukui_method) in stjames.XTB_METHODS
94
- xtb_models = {"alpb", "gbsa"}
95
- dft_models = {"pcm", "cpcm", "cosmo", "cpcmx", "smd"}
96
- if is_xtb and model not in xtb_models:
97
- raise ValueError(
98
- f"xTB Fukui methods require 'alpb' or 'gbsa' solvation model, got '{model}'"
99
- )
100
- if not is_xtb and model not in dft_models:
101
- raise ValueError(
102
- f"DFT Fukui methods require 'cpcm', 'smd', 'pcm', 'cosmo', or 'cpcmx' "
103
- f"solvation model, got '{model}'"
104
- )
105
-
106
89
  mol_dict = molecule_to_dict(initial_molecule)
107
90
 
108
91
  optimization_settings = stjames.Settings(method=optimization_method)
rowan/workflows/msa.py CHANGED
@@ -25,7 +25,7 @@ class MSAResult(WorkflowResult):
25
25
  def _output_formats(self) -> list[str]:
26
26
  """Output formats requested for the MSA."""
27
27
  fmts = getattr(self._workflow, "output_formats", []) or []
28
- return [f.value if hasattr(f, "value") else str(f) for f in fmts]
28
+ return [f.value for f in fmts]
29
29
 
30
30
  def download_files(
31
31
  self,
@@ -41,17 +41,16 @@ class MSAResult(WorkflowResult):
41
41
  :raises ValueError: If the requested format wasn't in the original output_formats.
42
42
  :raises HTTPError: If the API request fails.
43
43
  """
44
- if format is not None and format not in self._output_formats:
44
+ fmt_str: str | None = format.value if isinstance(format, stjames.MSAFormat) else format
45
+ if fmt_str is not None and fmt_str not in self._output_formats:
45
46
  raise ValueError(
46
- f"Format '{format}' was not requested. Available formats: {self._output_formats}"
47
+ f"Format '{fmt_str}' was not requested. Available formats: {self._output_formats}"
47
48
  )
48
49
 
49
50
  path = Path(path) if path is not None else Path.cwd()
50
-
51
51
  path.mkdir(parents=True, exist_ok=True)
52
52
 
53
- fmt_str: str = format.value if isinstance(format, stjames.MSAFormat) else format # type: ignore[assignment]
54
- formats_to_download = [fmt_str] if format is not None else self._output_formats
53
+ formats_to_download = [fmt_str] if fmt_str is not None else self._output_formats
55
54
  downloaded_paths = []
56
55
 
57
56
  for fmt in formats_to_download:
@@ -84,8 +83,7 @@ def submit_msa_workflow(
84
83
  Submits a Multiple Sequence Alignment (MSA) workflow to the API.
85
84
 
86
85
  :param initial_protein_sequences: List of protein sequences to align (amino acid strings).
87
- :param output_formats: Output formats for the MSA files ("colabfold", "chai", "boltz").
88
- Defaults to {"colabfold"}.
86
+ :param output_formats: Output formats for the MSA files. Defaults to {MSAFormat.COLABFOLD}.
89
87
  :param name: Name to assign to the workflow.
90
88
  :param folder_uuid: UUID of the folder where the workflow will be stored.
91
89
  :param folder: Folder object to store the workflow in.
@@ -102,20 +100,15 @@ def submit_msa_workflow(
102
100
  if output_formats is None:
103
101
  output_formats = {"colabfold"}
104
102
 
105
- # Convert to stjames types
106
- protein_sequences = []
107
- for seq in initial_protein_sequences:
108
- if isinstance(seq, stjames.ProteinSequence):
109
- protein_sequences.append(seq)
110
- else:
111
- protein_sequences.append(stjames.ProteinSequence(sequence=seq))
112
-
113
- msa_formats = []
114
- for fmt in output_formats:
115
- if isinstance(fmt, stjames.MSAFormat):
116
- msa_formats.append(fmt)
117
- else:
118
- msa_formats.append(stjames.MSAFormat(fmt))
103
+ protein_sequences = [
104
+ seq if isinstance(seq, stjames.ProteinSequence) else stjames.ProteinSequence(sequence=seq)
105
+ for seq in initial_protein_sequences
106
+ ]
107
+
108
+ msa_formats = [
109
+ fmt if isinstance(fmt, stjames.MSAFormat) else stjames.MSAFormat(fmt)
110
+ for fmt in output_formats
111
+ ]
119
112
 
120
113
  workflow = stjames.MSAWorkflow(
121
114
  initial_protein_sequences=protein_sequences,
rowan/workflows/pka.py CHANGED
@@ -43,10 +43,9 @@ class pKaMicrostate:
43
43
  uncertainty: float | None = None
44
44
 
45
45
 
46
- # Methods grouped by input type and solvent support
46
+ # Methods grouped by input type
47
47
  _PKA_3D_METHODS = {"aimnet2_wagen2024", "gxtb_wagen2026"}
48
48
  _PKA_SMILES_METHODS = {"chemprop_nevolianis2025", "starling"}
49
- _PKA_WATER_ONLY_METHODS = {"aimnet2_wagen2024", "gxtb_wagen2026", "starling"}
50
49
 
51
50
 
52
51
  @register_result("pka")
@@ -163,7 +162,7 @@ def submit_pka_workflow(
163
162
  :param webhook_url: URL that Rowan will POST to when the workflow completes.
164
163
  :param is_draft: If True, submit the workflow as a draft without starting execution.
165
164
  :returns: Workflow object representing the submitted workflow.
166
- :raises ValueError: If method and input type don't match, or solvent is unsupported.
165
+ :raises ValueError: If method/input type mismatch, or chemprop used with protonate_elements.
167
166
  :raises requests.HTTPError: if the request to the API fails.
168
167
  """
169
168
  if folder and folder_uuid:
@@ -187,9 +186,6 @@ def submit_pka_workflow(
187
186
  raise ValueError(
188
187
  f"{method} requires a SMILES string. Provide a SMILES string, not a 3D structure."
189
188
  )
190
- if method in _PKA_WATER_ONLY_METHODS and solvent != "water":
191
- raise ValueError(f"{method} only supports water as solvent.")
192
-
193
189
  if method == "chemprop_nevolianis2025" and protonate_elements:
194
190
  raise ValueError(
195
191
  "chemprop_nevolianis2025 was only trained on deprotonation data; "
@@ -101,6 +101,28 @@ class ProteinMDResult(WorkflowResult):
101
101
  """Any messages or warnings from the workflow."""
102
102
  return parse_messages(getattr(self._workflow, "messages", None))
103
103
 
104
+ def get_atom_distances(
105
+ self,
106
+ atom_pairs: list[tuple[int, int]],
107
+ replicate: int = 0,
108
+ ) -> list[list[float]]:
109
+ """
110
+ Fetch interatomic distances over the trajectory for specified atom pairs.
111
+
112
+ :param atom_pairs: List of (atom_i, atom_j) index pairs (0-indexed).
113
+ :param replicate: Trajectory replicate index (default 0).
114
+ :returns: List of distance arrays, one per pair, over all frames (Angstrom).
115
+ :raises HTTPError: If the API request fails.
116
+ """
117
+ with api_client() as client:
118
+ response = client.post(
119
+ f"/trajectory/{self.workflow_uuid}/atom_trajectories",
120
+ params={"replicate": replicate},
121
+ json=atom_pairs,
122
+ )
123
+ response.raise_for_status()
124
+ return response.json()
125
+
104
126
  def download_trajectories(
105
127
  self,
106
128
  replicates: list[int],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rowan-python
3
- Version: 3.1.3
3
+ Version: 3.1.4
4
4
  Summary: Rowan Python Library
5
5
  Project-URL: Homepage, https://github.com/rowansci/rowan-client
6
6
  Project-URL: Bug Tracker, https://github.com/rowansci/rowan-client/issues
@@ -1,45 +1,45 @@
1
- rowan/__init__.py,sha256=UhyjCkQh7YDTtemmetfelGD1d36WSJ6oaWct7CP2eyQ,1331
1
+ rowan/__init__.py,sha256=AfjV9IsjgykiU39Eh9vjUP_3at-OSqqhSgNhPxutma0,1456
2
2
  rowan/api_keys.py,sha256=TvG5l5MmQ3Qt8Z3Y7jCA_lcrwEHuI7xHy-KLbIdQ8_A,4793
3
- rowan/calculation.py,sha256=lZZ52DxPsuJWCTzFZXjhauHK6dV0KCUwzoxtmoxSY48,3442
3
+ rowan/calculation.py,sha256=nuUtLTtn9D-FCM9TJl3h5euBgIYqa5fr5r_PSNBS7Xo,3809
4
4
  rowan/config.py,sha256=TejQKSxnzNKKTNL9-2bCLq6RvAh54oVA5Ivl1p_ZT8Q,20899
5
5
  rowan/constants.py,sha256=emCH4m9OL2Hm5E-6mJGM_FgzrK_JrZT-FiKJ6pMNQ4Y,84
6
- rowan/folder.py,sha256=eyKdZjHR-LsNK0yS8iedb0vPi3OB10gluuA1-Ee_JCg,9202
7
- rowan/molecule.py,sha256=LyneBgSbNS7ByQ2sI8hcRLOXwljZMZQtALgAYcqX2tM,10844
6
+ rowan/folder.py,sha256=RRg68dObluJ2RCTKX3rb7RxziOXOQUYxkfQSU2R5VMk,13708
7
+ rowan/molecule.py,sha256=W6eBuIQAjZs5JiQFmyRnbcbj2Qy2XbZnm_J0WuMslG4,13881
8
8
  rowan/project.py,sha256=RtxYE9jv3Yz6fH5I56iDGRG5EbwlOHiSK-HF1uxc0d4,4582
9
- rowan/protein.py,sha256=hfDqk_LgU8o39Zg2kHHWf8EqPcQOnT3Mo43Ik1gAO68,14856
9
+ rowan/protein.py,sha256=T2JAqfncP80yV5gEWYA-TlpNY3WeWaNHPE0Xg5voU24,17968
10
10
  rowan/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  rowan/types.py,sha256=MvcYvEYr6N9RXLal44MqsnMxoWfcBa-VLD_rGsW4HOc,591
12
12
  rowan/user.py,sha256=rOCYVhSoRY71fCG_OCSD7cLAWWnAMANWGitF3ogfq30,6212
13
- rowan/utils.py,sha256=c1s6Ze-OqLtfvrD23OV60otskejmj-CD88nNf8_nFcw,3636
13
+ rowan/utils.py,sha256=DBphY1he8Vl3RNf6CbLp5BSX9RG5EfHzhMs-9TmcE_g,4299
14
14
  rowan/workflows/__init__.py,sha256=ohl2q-Nqgs4E9cl5KIGigQgE2ifiTmY5dib5R_qNxO4,4510
15
15
  rowan/workflows/admet.py,sha256=jDx1Kkgmp0XaeojPzaCFY_yUwvqtSM7Cezps-93cqPw,2865
16
16
  rowan/workflows/analogue_docking.py,sha256=IxVaYD8ITsLFfvZ6N7v4KmvIS25DcUQkv-_tMhEfG_4,9707
17
- rowan/workflows/base.py,sha256=UI5KoraDCR9GX1eRBMEYCEb3mV737xP_5T0M_gfGqag,32176
18
- rowan/workflows/basic_calculation.py,sha256=nAQGwpZnlR9Bi_p4U79_NO6IbcY3gOXQxMpubNHB9AY,11249
17
+ rowan/workflows/base.py,sha256=k858otXLuorw4MZ1hvqZI_m0n_38htC7Hy3OMCX1TOA,32961
18
+ rowan/workflows/basic_calculation.py,sha256=CnAnbRYrnjbxgcPAZxPUARUjaqPCnAAjf-RFku8R1r0,11240
19
19
  rowan/workflows/batch_docking.py,sha256=ePnb1hcsmnUfffl1-k9HNkOC09RjY9bZS9XcqAGNeFs,3621
20
20
  rowan/workflows/bde.py,sha256=1ieYUeu-5Lg637eSjhbRr_6qSIiIcjhsLOzaA4zbg3o,6681
21
21
  rowan/workflows/conformer_search.py,sha256=9_DpRfmeNWIVm68Y95zcHCK5Q0yt9le6W70rMeIQndY,15467
22
22
  rowan/workflows/constants.py,sha256=el8jWE9gnGTLNWn5_n_V0H362vIRneOqgy7BOQ8CScg,575
23
23
  rowan/workflows/descriptors.py,sha256=QjjpzPDpAO_EhPOK--gle6sGpVHMpdAs_yrvC0oiseY,3092
24
- rowan/workflows/docking.py,sha256=iEljm5N9A9fJrqpX2_0YznGpN9z3__UDvYoP-5t8n3c,7914
24
+ rowan/workflows/docking.py,sha256=VTauRB6knysUsAzGhEhI95F6BQLAuZbNEA468wByohc,8031
25
25
  rowan/workflows/double_ended_ts_search.py,sha256=eZNEXhfh5W5j5JcEzKnKMMf9HPbIM6HDLnzoh_vVFXk,8115
26
26
  rowan/workflows/electronic_properties.py,sha256=MXbsI4OHyXWt3f4Qc5g7-SNjyyEoYzocWVdcP1H06sY,8436
27
- rowan/workflows/fukui.py,sha256=MKfjbPkZ7EBQhDf0R4o79Np4ZuRPshhJJnqW6kQmF4o,5171
27
+ rowan/workflows/fukui.py,sha256=Lyx3FgSdidzPMj-PpSzOb9uXCZj2S_52p8zBYxBbprg,4412
28
28
  rowan/workflows/hydrogen_bond_donor_acceptor_strength.py,sha256=xgFFemIZpIOkgkiYx20TmCexWNxNgRZl4H4t-zfQqUE,5254
29
29
  rowan/workflows/interaction_energy_decomposition.py,sha256=wK9Mc9uGmFBG8H03LRRRz3nZzM6p7Z2ol1yiZbUB5ec,6176
30
30
  rowan/workflows/ion_mobility.py,sha256=GqVVPRffaBJvygB3mwbMJq2aW7y19J3AnkDx_ZMIRss,4021
31
31
  rowan/workflows/irc.py,sha256=Zf1zh-UjTrQnZ4fwQ4jDJKh80nsyMymAPK6qUoOShKI,10075
32
32
  rowan/workflows/macropka.py,sha256=IxGwr9LziIwj_x6pXEhJN6SywS_KULziuYoETNqM370,5684
33
33
  rowan/workflows/membrane_permeability.py,sha256=wHZ1IPlXwBB343uVAm7EuhedcMPd-Uy4jMqHfXRHwqY,4680
34
- rowan/workflows/msa.py,sha256=V3B1SyWPR8MT306hh9W-T9JTpi_E-XgAIeF9yRQZ7tI,5075
34
+ rowan/workflows/msa.py,sha256=k_FlxxtdgvmxDN3LyLDEQeFfI8_lyBJz1KFtz3S4oNo,4816
35
35
  rowan/workflows/multistage_optimization.py,sha256=04kZPC3SmVOxqEQOwyBrS3DlelM-b8e531T_LuVt3tU,7385
36
36
  rowan/workflows/nmr.py,sha256=68be_Xgiy1AykGdIL4fR1pqKllD1nLm3IidsStzKtz4,5521
37
- rowan/workflows/pka.py,sha256=xQPaOHxPQqCY_zi97LW9brpXPn3xjyeF9RDE1Rz8HQk,8772
37
+ rowan/workflows/pka.py,sha256=NZ-3i-cH_b7YOHj2iq0roXEcZmC8SYapmzMOX6ObRqw,8546
38
38
  rowan/workflows/pocket_detection.py,sha256=aGHY0puxekp4c4nsNYHcvKCe1fsetygL04BcSvNFvE8,3864
39
39
  rowan/workflows/pose_analysis_md.py,sha256=XJIfvn-H7GA6lVtw9uKjlgznVSSsr9bJiDt_PjKlPbA,11572
40
40
  rowan/workflows/protein_binder_design.py,sha256=pgywTQDuXHplZYka-61_S6CC4WTPDCwJrbupp825eC4,9281
41
41
  rowan/workflows/protein_cofolding.py,sha256=CV13VUHC0NLDDaS1-GGTQ3RySkzEE30xzu1_4oPu6lo,16239
42
- rowan/workflows/protein_md.py,sha256=41S-GIHP91ahW5mIVxjAkpZbxept98IgausM5JoeckA,9276
42
+ rowan/workflows/protein_md.py,sha256=zqKEtL514uchgbxsyO4vhokzTU-X4lTFA1BM5O8HvTU,10117
43
43
  rowan/workflows/rbfe_graph.py,sha256=7UJA3ZBbvtaB9l1KQi9279Pc89moVnBHO-XJpxEr5uQ,7238
44
44
  rowan/workflows/redox_potential.py,sha256=bBeT1K9XGVpjCFZeeuv7Vtr3EYHN2okJ4LqYc3gMV04,5427
45
45
  rowan/workflows/relative_binding_free_energy_perturbation.py,sha256=vi5Qj5aDAERtjcHDsgzI6vRV_BZJnJ8boOj5aG6GXzo,13817
@@ -49,7 +49,7 @@ rowan/workflows/solvent_dependent_conformers.py,sha256=Z2xESmcM8WcTsR2TSTUa--Rrh
49
49
  rowan/workflows/spin_states.py,sha256=rjkgie2-XVNIN7O6P93yn6EPv9Ogjy8iTj3ufZIsUgY,9331
50
50
  rowan/workflows/strain.py,sha256=kCW_BlX__sdQG1JVbFZuB-57rkpnxa_JCw3h4uKdGDk,6425
51
51
  rowan/workflows/tautomer_search.py,sha256=mbRl0ZJ7wibueRF8c8_idhXJ1rtwg7LBt--5QwQ_Cck,5767
52
- rowan_python-3.1.3.dist-info/METADATA,sha256=mv9y4fxN9Ds7WCnd2W-vZL-LdLCNkp_1XxWipE5Q2Yg,2052
53
- rowan_python-3.1.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
54
- rowan_python-3.1.3.dist-info/licenses/LICENSE,sha256=i05z7xEhyrg6f8j0lR3XYjShnF-MJGFQ-DnpsZ8yiVI,1084
55
- rowan_python-3.1.3.dist-info/RECORD,,
52
+ rowan_python-3.1.4.dist-info/METADATA,sha256=qogpVoI_aoX_24Z2diW8JdOR2sphEYp-HuLtTFvvN-w,2052
53
+ rowan_python-3.1.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
54
+ rowan_python-3.1.4.dist-info/licenses/LICENSE,sha256=i05z7xEhyrg6f8j0lR3XYjShnF-MJGFQ-DnpsZ8yiVI,1084
55
+ rowan_python-3.1.4.dist-info/RECORD,,