stjames 0.0.43__py3-none-any.whl → 0.0.45__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 stjames might be problematic. Click here for more details.

@@ -0,0 +1,345 @@
1
+ """Conformer Search Workflow."""
2
+
3
+ from abc import ABC
4
+ from typing import Self, Sequence, TypeVar
5
+
6
+ from pydantic import BaseModel, Field, field_validator, model_validator
7
+
8
+ from ..base import LowercaseStrEnum
9
+ from ..constraint import Constraint
10
+ from ..method import Method, XTBMethod
11
+ from ..mode import Mode
12
+ from ..types import UUID
13
+ from .multistage_opt import MultiStageOptMixin
14
+ from .workflow import Workflow
15
+
16
+ _sentinel = object()
17
+
18
+ _T = TypeVar("_T")
19
+ _U = TypeVar("_U")
20
+
21
+
22
+ def check_sentinel(value: _T, default: _U) -> _T | _U:
23
+ """Return value unless _sentinel, then return default."""
24
+ return default if value is _sentinel else value
25
+
26
+
27
+ class ScreeningSettings(BaseModel):
28
+ """
29
+ Settings for determing unique and useful conformers.
30
+
31
+ :param energy_threshhold: maximum relative energy for screening
32
+ :param rotational_constants_threshhold: maximum difference in rotational constants for screening
33
+ :param rmsd: cartesian RMSD for screening
34
+ :param max_confs: maximum number of conformers to keep
35
+ """
36
+
37
+ energy_threshhold: float | None = None # kcal/mol
38
+ rotational_constants_threshhold: float | None = 0.02
39
+ rmsd: float | None = 0.25
40
+ max_confs: int | None = None
41
+
42
+
43
+ class ConformerGenSettings(BaseModel):
44
+ """
45
+ Conformer generation settings.
46
+
47
+ Conformers are generated and an initial screening is performed to remove duplicates and high-energy conformers.
48
+
49
+ :param mode: Mode for calculations
50
+ :param conf_opt_method: method for the optimization
51
+ :param screening: post-generation screening settings
52
+ :param constraints: constraints for conformer generation
53
+ :param nci: add a constraining potential for non-covalent interactions
54
+ :param max_confs: maximum number of conformers to keep
55
+ """
56
+
57
+ mode: Mode = Mode.RAPID
58
+ conf_opt_method: XTBMethod = Method.GFN_FF
59
+ screening: ScreeningSettings | None = None
60
+ constraints: Sequence[Constraint] = tuple()
61
+ nci: bool = False
62
+ max_confs: int | None = None
63
+
64
+ def __str__(self) -> str:
65
+ """Return a string representation of the ConformerGenSettings."""
66
+ return repr(self)
67
+
68
+ def __repr__(self) -> str:
69
+ """Return a string representation of the ConformerGenSettings."""
70
+ return f"<{type(self).__name__} {self.mode.name}>"
71
+
72
+
73
+ class ETKDGSettings(ConformerGenSettings):
74
+ """
75
+ Settings for ETKDG conformer generation.
76
+
77
+ Inherited:
78
+ :param mode: Mode for calculations
79
+ :param screening: post-generation screening settings
80
+ :param constraints: constraints for conformer generation
81
+ :param nci: add a constraining potential for non-covalent interactions (not supported in ETKDG)
82
+ :param conf_opt_method: method for the optimization
83
+
84
+ New:
85
+ :param num_initial_confs: number of initial conformers to generate
86
+ :param num_confs_considered: number of conformers to consider for optimization
87
+ :param num_confs_taken: number of final conformers to take
88
+ :param max_mmff_energy: MMFF energy cutoff
89
+ :param max_mmff_iterations: MMFF optimization iterations
90
+ :param max_confs: maximum number of conformers to keep
91
+ """
92
+
93
+ num_initial_confs: int = 300
94
+ num_confs_considered: int = 100
95
+ max_mmff_iterations: int = 500
96
+ max_mmff_energy: float | None = 30
97
+
98
+ @field_validator("constraints")
99
+ def check_constraints(cls, constraints: Sequence[Constraint]) -> Sequence[Constraint]:
100
+ if constraints:
101
+ raise ValueError("ETKDG does not support constraints")
102
+
103
+ return tuple(constraints)
104
+
105
+ @field_validator("nci")
106
+ def check_nci(cls, nci: bool) -> bool:
107
+ if nci:
108
+ raise ValueError("ETKDG does not support NCI")
109
+
110
+ return nci
111
+
112
+ @model_validator(mode="after")
113
+ def validate_and_build(self) -> Self:
114
+ match self.mode:
115
+ case Mode.MANUAL:
116
+ pass
117
+ case Mode.RECKLESS:
118
+ self.num_initial_confs = 200
119
+ self.num_confs_considered = 50
120
+ self.max_confs = 20 if self.max_confs is _sentinel else self.max_confs
121
+ self.max_mmff_energy = 20
122
+ case Mode.RAPID:
123
+ self.max_confs = 50 if self.max_confs is _sentinel else self.max_confs
124
+ self.conf_opt_method = Method.GFN0_XTB
125
+ case _:
126
+ raise NotImplementedError(f"Unsupported mode: {self.mode}")
127
+
128
+ return self
129
+
130
+
131
+ class iMTDSpeeds(LowercaseStrEnum):
132
+ MEGAQUICK = "megaquick"
133
+ SUPERQUICK = "superquick"
134
+ QUICK = "quick"
135
+ NORMAL = "normal"
136
+ EXTENSIVE = "extensive"
137
+
138
+
139
+ class iMTDSettings(ConformerGenSettings, ABC):
140
+ """
141
+ Settings for iMTD style conformer generation.
142
+
143
+ RECKLESS:
144
+ - GFN-FF//MTD(GFN-FF)
145
+ - Megaquick
146
+ - No GC
147
+ - No rotamer metadynamics
148
+ - Energy window = 5.0
149
+ - Run scaling factor = 0.5
150
+ - 6 MTD runs
151
+ RAPID:
152
+ - GFN0//MTD(GFN-FF)
153
+ - Superquick
154
+ - No GC
155
+ - No rotamer metadynamics
156
+ - Energy window = 5.0
157
+ - Run scaling factor = 0.5
158
+ - 6 MTD runs
159
+ CAREFUL:
160
+ - GFN2//MTD(GFN-FF)
161
+ - Quick
162
+ - GC (for iMTD-GC)
163
+ - Rotamer metadynamics (for iMTD-GC)
164
+ - Energy window = 5.0
165
+ - Run scaling factor = 0.5
166
+ - 6 MTD runs
167
+ METICULOUS:
168
+ - GFN2//MTD(GFN2)
169
+ - "Normal"
170
+ - GC (for iMTD-GC)
171
+ - Rotamer metadynamics (for iMTD-GC)
172
+ - Energy window = 6.0
173
+ - Run scaling factor = 1
174
+ - 14 MTD runs (2 with extreme values)
175
+
176
+
177
+ See https://github.com/crest-lab/crest/blob/5ca82feb2ec4df30a0129db957163c934f085952/src/choose_settings.f90#L202
178
+ and https://github.com/crest-lab/crest/blob/5ca82feb2ec4df30a0129db957163c934f085952/src/confparse.f90#L825
179
+ for how quick, superquick, and megaquick are defined.
180
+
181
+ Additional notes:
182
+ Extensive mode
183
+ - GC
184
+ - Rotamer metadynamics
185
+ - Energy window = 8.0
186
+ - Run scaling factor = 2
187
+ - 14 MTD runs (2 with extreme values)
188
+
189
+ --NCI may switch things to QUICK?
190
+
191
+ Inherited:
192
+ :param mode: Mode for calculations
193
+ :param conf_opt_method: method for the optimization
194
+ :param screening: post-generation screening settings (not used)
195
+ :param constraints: constraints to add
196
+ :param nci: add an ellipsoide potential around the input structure
197
+
198
+ New:
199
+ :param mtd_method: method for the metadynamics
200
+ :param speed: speed of the calculations (CREST specific setting)
201
+ :param reopt: re-optimize conformers (corrects for the lack of rotamer metadynamics and GC)
202
+ :param free_energy_weights: calculate frequencies and re-weight based on free energies
203
+ """
204
+
205
+ mtd_method: XTBMethod = Method.GFN_FF
206
+ mtd_runtype: str = "imtd-gc"
207
+
208
+ speed: iMTDSpeeds = iMTDSpeeds.QUICK
209
+ reopt: bool = _sentinel # type: ignore [assignment]
210
+ free_energy_weights: bool = False
211
+
212
+ @model_validator(mode="after")
213
+ def validate_and_build_imtdgc_settings(self) -> Self:
214
+ match self.mode:
215
+ case Mode.MANUAL:
216
+ if self.reopt is _sentinel:
217
+ raise ValueError("Must specify reopt with MANUAL mode")
218
+ case Mode.RECKLESS: # GFN-FF//MTD(GFN-FF)
219
+ self.max_confs = 20 if self.max_confs is _sentinel else self.max_confs
220
+ self.speed = iMTDSpeeds.MEGAQUICK
221
+ self.reopt = check_sentinel(self.reopt, True)
222
+ case Mode.RAPID: # GFN0//MTD(GFN-FF)
223
+ self.max_confs = 50 if self.max_confs is _sentinel else self.max_confs
224
+ self.speed = iMTDSpeeds.SUPERQUICK
225
+ self.conf_opt_method = Method.GFN0_XTB
226
+ self.reopt = check_sentinel(self.reopt, True)
227
+ case Mode.CAREFUL: # GFN2//MTD(GFN-FF)
228
+ self.speed = iMTDSpeeds.QUICK
229
+ self.conf_opt_method = Method.GFN2_XTB
230
+ self.reopt = check_sentinel(self.reopt, False)
231
+ case Mode.METICULOUS: # GFN2//MTD(GFN2)
232
+ self.speed = iMTDSpeeds.NORMAL
233
+ self.mtd_method = Method.GFN2_XTB
234
+ self.conf_opt_method = Method.GFN2_XTB
235
+ self.reopt = check_sentinel(self.reopt, False)
236
+ # case Mode.EXTREME: # GFN2//MTD(GFN2)
237
+ # self.mtd_method = Method.GFN2_XTB
238
+ # self.conf_opt_method = Method.GFN2_XTB
239
+ # self.speed = iMTDSpeeds.EXTENSIVE
240
+ # self.reopt = check_sentinel(self.reopt, False)
241
+ case _:
242
+ raise NotImplementedError(f"Unsupported mode: {self.mode}")
243
+
244
+ return self
245
+
246
+
247
+ class iMTDGCSettings(iMTDSettings):
248
+ run_type: str = "imtdgc"
249
+
250
+
251
+ class iMTDsMTDSettings(iMTDSettings):
252
+ run_type: str = "imtd-smtd"
253
+
254
+
255
+ class ConformerGenMixin(BaseModel):
256
+ """
257
+ Mixin for workflows that need conformer generation.
258
+
259
+ :param conf_gen_mode: Mode for calculations
260
+ :param conf_gen_settings: settings for conformer generation
261
+ """
262
+
263
+ conf_gen_mode: Mode = Mode.RAPID
264
+ conf_gen_settings: ConformerGenSettings = _sentinel # type: ignore [assignment]
265
+ constraints: Sequence[Constraint] = tuple()
266
+
267
+ @model_validator(mode="after")
268
+ def validate_and_build_conf_gen_settings(self) -> Self:
269
+ """Validate and build the ConformerGenSettings."""
270
+ if self.conf_gen_settings is not _sentinel and self.conf_gen_mode != Mode.MANUAL:
271
+ raise ValueError("Cannot specify conf_gen_settings with non-MANUAL mode")
272
+
273
+ match self.conf_gen_mode:
274
+ case Mode.MANUAL:
275
+ if self.conf_gen_settings is _sentinel:
276
+ raise ValueError("Must specify conf_gen_settings with MANUAL mode")
277
+
278
+ case Mode.RECKLESS | Mode.RAPID:
279
+ # ETKDGSettings will error if constraints added
280
+ self.conf_gen_settings = ETKDGSettings(mode=self.conf_gen_mode, constraints=self.constraints)
281
+ case Mode.CAREFUL | Mode.METICULOUS:
282
+ self.conf_gen_settings = iMTDSettings(mode=self.conf_gen_mode, constraints=self.constraints)
283
+
284
+ case _:
285
+ raise NotImplementedError(f"Unsupported mode: {self.conf_gen_mode}")
286
+
287
+ return self
288
+
289
+
290
+ class ConformerSearchMixin(ConformerGenMixin, MultiStageOptMixin):
291
+ """
292
+ Mixin for workflows that need conformer search—a combination of conformer generation and optimization.
293
+
294
+ Inherited:
295
+ :param conf_gen_mode: Mode for conformer generation
296
+ :param mso_mode: Mode for MultiStageOptSettings
297
+ :param conf_gen_settings: settings for conformer generation
298
+ :param multistage_opt_settings: set by mso_mode unless mode=MANUAL (ignores additional settings if set)
299
+ :param solvent: solvent to use
300
+ :param xtb_preopt: pre-optimize with xtb (sets based on mode when None)
301
+ :param constraints: constraints to add (diamond inheritance, works as expected)
302
+ :param transition_state: whether this is a transition state
303
+
304
+ Overridden:
305
+ :param frequencies: whether to calculate frequencies (turned off)
306
+ """
307
+
308
+ frequencies: bool = False
309
+
310
+ def __str__(self) -> str:
311
+ """Return a string representation of the ConformerSearch workflow."""
312
+ return repr(self)
313
+
314
+ def __repr__(self) -> str:
315
+ """Return a string representation of the ConformerSearch workflow."""
316
+ return f"<{type(self).__name__} {self.conf_gen_mode.name} {self.mso_mode.name}>"
317
+
318
+
319
+ class ConformerSearchWorkflow(ConformerSearchMixin, Workflow):
320
+ """
321
+ ConformerSearch Workflow.
322
+
323
+ Inherited:
324
+ :param initial_molecule: Molecule of interest
325
+ :param conf_gen_mode: Mode for calculations
326
+ :param conf_gen_settings: settings for conformer generation
327
+ :param mso_mode: Mode for MultiStageOptSettings
328
+ :param multistage_opt_settings: set by mode unless mode=MANUAL (ignores additional settings if set)
329
+ :param solvent: solvent to use
330
+ :param xtb_preopt: pre-optimize with xtb (sets based on mode when None)
331
+ :param constraints: constraints to add
332
+ :param transition_state: whether this is a transition state
333
+ :param frequencies: whether to calculate frequencies (turned off)
334
+
335
+ Ignored:
336
+ :param mode: Mode to use (not used)
337
+
338
+ New:
339
+ :param conformer_uuids: list of UUIDs of the Molecules generated
340
+ :param energies: energies of the molecules
341
+ """
342
+
343
+ # Results
344
+ conformer_uuids: list[list[UUID | None]] = Field(default_factory=list)
345
+ energies: list[float] = Field(default_factory=list)
@@ -0,0 +1,86 @@
1
+ from pydantic import NonNegativeFloat, NonNegativeInt
2
+
3
+ from ..base import Base
4
+ from ..settings import Settings
5
+ from ..types import UUID, FloatPerAtom, Matrix3x3, Vector3D
6
+ from .workflow import Workflow
7
+
8
+
9
+ class PropertyCubePoint(Base):
10
+ x: float
11
+ y: float
12
+ z: float
13
+ val: float
14
+
15
+
16
+ class PropertyCube(Base):
17
+ """
18
+ Represents a "cubefile" of some property.
19
+ """
20
+
21
+ cube_data: list[PropertyCubePoint]
22
+
23
+
24
+ class MolecularOrbitalCube(PropertyCube):
25
+ """
26
+ Inherits `cube_data`.
27
+ """
28
+
29
+ occupation: NonNegativeInt
30
+ energy: float
31
+
32
+
33
+ class ElectronicPropertiesWorkflow(Workflow):
34
+ """
35
+ Workflow for computing electronic properties!
36
+
37
+ Inherited
38
+ :param initial_molecule: molecule of interest
39
+
40
+ Config settings:
41
+ :param settings: the level of theory to use
42
+ :param compute_density_cube: whether or not to compute the density on a cube
43
+ :param compute_electrostatic_potential_cube: whether or not to compute the electrostatic potential on a cube
44
+ :param compute_num_occupied_orbitals: how many occupied orbitals to save
45
+ :param compute_num_virtual_orbitals: how many virtual orbitals to save
46
+
47
+ Populated while running:
48
+ :param calculation: the UUID of the calculation
49
+ :param dipole: the dipole moment
50
+ :param quadrupole: the quadrupole moment
51
+ :param mulliken_charges: the Mulliken charges
52
+ :param lowdin_charges: the Lowdin charges
53
+ :param wiberg_bond_orders: the Wiberg bond orders (`atom1`, `atom2`, `order`)
54
+ :param mayer_bond_orders: the Mayer bond orders (`atom1`, `atom2`, `order`)
55
+ :param density_cube: the electron density, as a cube
56
+ :param electrostatic_potential_cube: the electrostatic potential, as a cube
57
+ :param molecular_orbitals_alpha: for open-shell species (UHF/ROHF), a dict containing the alpha MOs
58
+ (The key is the absolute orbital index.)
59
+ :param molecular_orbitals_beta: for open-shell species (UHF/ROHF), a dict containing the beta MOs
60
+ (The key is the absolute orbital index.)
61
+ :param molecular_orbitals: for closed-shell species (RHF), a dict containing the MOs
62
+ (The key is the absolute orbital index.)
63
+ """
64
+
65
+ settings: Settings
66
+ compute_density_cube: bool = True
67
+ compute_electrostatic_potential_cube: bool = True
68
+ compute_num_occupied_orbitals: NonNegativeInt = 1
69
+ compute_num_virtual_orbitals: NonNegativeInt = 1
70
+
71
+ calculation: UUID | None = None
72
+
73
+ dipole: Vector3D | None = None
74
+ quadrupole: Matrix3x3 | None = None
75
+
76
+ mulliken_charges: FloatPerAtom | None = None
77
+ lowdin_charges: FloatPerAtom | None = None
78
+
79
+ wiberg_bond_orders: list[tuple[NonNegativeInt, NonNegativeInt, NonNegativeFloat]] = []
80
+ mayer_bond_orders: list[tuple[NonNegativeInt, NonNegativeInt, NonNegativeFloat]] = []
81
+
82
+ density_cube: PropertyCube | None = None
83
+ electrostatic_potential_cube: PropertyCube | None = None
84
+ molecular_orbitals_alpha: dict[NonNegativeInt, MolecularOrbitalCube] = {}
85
+ molecular_orbitals_beta: dict[NonNegativeInt, MolecularOrbitalCube] = {}
86
+ molecular_orbitals: dict[NonNegativeInt, MolecularOrbitalCube] = {}
@@ -9,6 +9,11 @@ from ..types import UUID
9
9
  from .workflow import Workflow
10
10
 
11
11
 
12
+ class MolecularDynamicsInitialization(LowercaseStrEnum):
13
+ RANDOM = "random"
14
+ QUASICLASSICAL = "quasiclassical"
15
+
16
+
12
17
  class ThermodynamicEnsemble(LowercaseStrEnum):
13
18
  NPT = "npt"
14
19
  NVT = "nvt"
@@ -18,22 +23,24 @@ class ThermodynamicEnsemble(LowercaseStrEnum):
18
23
  class Frame(Base):
19
24
  index: int # what number frame this is within the MD simulation
20
25
 
21
- uuid: UUID | None = None # UUID of molecule
26
+ calculation_uuid: UUID | None = None # UUID of calculation
22
27
 
23
28
  pressure: float
24
29
  temperature: float
30
+ volume: float
25
31
  energy: float
26
32
 
27
33
 
28
34
  class MolecularDynamicsSettings(Base):
29
35
  ensemble: ThermodynamicEnsemble = ThermodynamicEnsemble.NVT
36
+ initialization: MolecularDynamicsInitialization = MolecularDynamicsInitialization.RANDOM
30
37
 
31
38
  timestep: PositiveFloat = 1.0 # fs
32
39
  num_steps: PositiveInt = 500
33
40
 
34
41
  confining_constraint: SphericalHarmonicConstraint | None = None
35
42
 
36
- temperature: PositiveFloat | None = 300 # K
43
+ temperature: PositiveFloat = 300 # K
37
44
  pressure: PositiveFloat | None = None # atm
38
45
 
39
46
  langevin_thermostat_timescale: PositiveFloat = 100 # fs
@@ -46,7 +53,7 @@ class MolecularDynamicsSettings(Base):
46
53
  """Check that NVT ensemble always has temperature defined, and that NPT has temp and pressure defined."""
47
54
  if self.ensemble == ThermodynamicEnsemble.NVT and self.temperature is None:
48
55
  raise ValueError("NVT ensemble must have a temperature defined")
49
- if self.ensemble == ThermodynamicEnsemble.NPT and (self.temperature is None or self.pressure is None):
56
+ elif self.ensemble == ThermodynamicEnsemble.NPT and (self.temperature is None or self.pressure is None):
50
57
  raise ValueError("NPT ensemble must have both temperature and pressure defined")
51
58
  return self
52
59
 
@@ -56,5 +63,7 @@ class MolecularDynamicsWorkflow(Workflow):
56
63
  calc_settings: Settings
57
64
  calc_engine: str | None = None
58
65
 
66
+ save_interval: PositiveInt = 10
67
+
59
68
  # uuids of scan points
60
69
  frames: list[Frame] = []
@@ -22,19 +22,19 @@ class MultiStageOptSettings(BaseModel):
22
22
  RAPID *default
23
23
  r²SCAN-3c//GFN2-xTB with GFN0-xTB pre-opt (off by default)
24
24
  CAREFUL
25
- wB97X-3c//B97-3c with GFN2-xTB pre-opt
25
+ wB97X-3c//r²SCAN-3c with GFN2-xTB pre-opt
26
26
  METICULOUS
27
- wB97M-D3BJ/def2-TZVPPD//wB97X-3c//B97-3c with GFN2-xTB pre-opt
27
+ wB97M-D3BJ/def2-TZVPPD//wB97X-3c//r²SCAN-3c with GFN2-xTB pre-opt
28
28
 
29
29
  Notes:
30
- - No solvent in pre-opt
30
+ - No solvent in any optimizations when using Modes
31
31
  - If solvent: xTB singlepoints use CPCMX, xTB optimizations use ALBP, all else use CPCM
32
32
  - Allows a single point to be called with no optimization
33
33
 
34
34
  :param mode: Mode for settings
35
35
  :param optimization_settings: list of opt settings to apply successively
36
36
  :param singlepoint_settings: final single point settings
37
- :param solvent: solvent to use
37
+ :param solvent: solvent to use for singlepoint
38
38
  :param xtb_preopt: pre-optimize with xtb (sets based on mode when None)
39
39
  :param constraints: constraints for optimization
40
40
  :param transition_state: whether this is a transition state
@@ -44,7 +44,7 @@ class MultiStageOptSettings(BaseModel):
44
44
  >>> msos
45
45
  <MultiStageOptSettings RAPID>
46
46
  >>> msos.level_of_theory
47
- 'r2scan_3c/cpcm(water)//gfn2_xtb/alpb(water)'
47
+ 'r2scan_3c/cpcm(water)//gfn2_xtb'
48
48
  """
49
49
 
50
50
  mode: Mode
@@ -78,7 +78,7 @@ class MultiStageOptSettings(BaseModel):
78
78
 
79
79
  >>> msos = MultiStageOptSettings(mode=Mode.RAPID, solvent="hexane")
80
80
  >>> msos.level_of_theory
81
- 'r2scan_3c/cpcm(hexane)//gfn2_xtb/alpb(hexane)'
81
+ 'r2scan_3c/cpcm(hexane)//gfn2_xtb'
82
82
  """
83
83
  methods = [self.singlepoint_settings] if self.singlepoint_settings else []
84
84
  methods += reversed(self.optimization_settings)
@@ -148,14 +148,14 @@ class MultiStageOptSettings(BaseModel):
148
148
  match mode:
149
149
  case Mode.RECKLESS:
150
150
  self.xtb_preopt = False
151
- self.optimization_settings = [opt(Method.GFN_FF, solvent=self.solvent, freq=self.frequencies)]
151
+ self.optimization_settings = [opt(Method.GFN_FF, freq=self.frequencies)]
152
152
  self.singlepoint_settings = sp(Method.GFN2_XTB, solvent=self.solvent)
153
153
 
154
154
  case Mode.RAPID:
155
155
  self.xtb_preopt = bool(self.xtb_preopt)
156
156
  self.optimization_settings = [
157
157
  *gfn0_pre_opt * self.xtb_preopt,
158
- opt(Method.GFN2_XTB, solvent=self.solvent, freq=self.frequencies),
158
+ opt(Method.GFN2_XTB, freq=self.frequencies),
159
159
  ]
160
160
  self.singlepoint_settings = sp(Method.R2SCAN3C, solvent=self.solvent)
161
161
 
@@ -163,7 +163,7 @@ class MultiStageOptSettings(BaseModel):
163
163
  self.xtb_preopt = (self.xtb_preopt is None) or self.xtb_preopt
164
164
  self.optimization_settings = [
165
165
  *gfn2_pre_opt * self.xtb_preopt,
166
- opt(Method.B973C, solvent=self.solvent, freq=self.frequencies),
166
+ opt(Method.R2SCAN3C, freq=self.frequencies),
167
167
  ]
168
168
  self.singlepoint_settings = sp(Method.WB97X3C, solvent=self.solvent)
169
169
 
@@ -171,8 +171,8 @@ class MultiStageOptSettings(BaseModel):
171
171
  self.xtb_preopt = (self.xtb_preopt is None) or self.xtb_preopt
172
172
  self.optimization_settings = [
173
173
  *gfn2_pre_opt * self.xtb_preopt,
174
- opt(Method.B973C, solvent=self.solvent),
175
- opt(Method.WB97X3C, solvent=self.solvent, freq=self.frequencies),
174
+ opt(Method.R2SCAN3C),
175
+ opt(Method.WB97X3C, freq=self.frequencies),
176
176
  ]
177
177
  self.singlepoint_settings = sp(Method.WB97MD3BJ, "def2-TZVPPD", solvent=self.solvent)
178
178
 
@@ -191,7 +191,7 @@ class MultiStageOptWorkflow(Workflow, MultiStageOptSettings):
191
191
  :param mode: Mode for workflow
192
192
  :param optimization_settings: list of opt settings to apply successively
193
193
  :param singlepoint_settings: final single point settings
194
- :param solvent: solvent to use
194
+ :param solvent: solvent to use for singlepoint
195
195
  :param xtb_preopt: pre-optimize with xtb (sets based on mode when None)
196
196
  :param constraints: constraints for optimization
197
197
  :param transition_state: whether this is a transition state
@@ -206,12 +206,18 @@ class MultiStageOptWorkflow(Workflow, MultiStageOptSettings):
206
206
  >>> msow
207
207
  <MultiStageOptWorkflow RAPID>
208
208
  >>> msow.level_of_theory
209
- 'r2scan_3c/cpcm(water)//gfn2_xtb/alpb(water)'
209
+ 'r2scan_3c/cpcm(water)//gfn2_xtb'
210
210
  """
211
211
 
212
212
  # Populated while running the workflow
213
213
  calculations: list[UUID | None] = Field(default_factory=list)
214
214
 
215
+ def __repr__(self) -> str:
216
+ if self.mode != Mode.MANUAL:
217
+ return f"<{type(self).__name__} {self.mode.name}>"
218
+
219
+ return f"<{type(self).__name__} {self.level_of_theory}>"
220
+
215
221
 
216
222
  # the id of a mutable object may change, thus using object()
217
223
  _sentinel_msos = object()
@@ -222,7 +228,7 @@ class MultiStageOptMixin(BaseModel):
222
228
  Mixin for workflows that use MultiStageOptSettings.
223
229
  """
224
230
 
225
- mso_mode: Mode
231
+ mso_mode: Mode = Mode.AUTO
226
232
  # Need to use a sentinel object to make both mypy and pydantic happy
227
233
  multistage_opt_settings: MultiStageOptSettings = _sentinel_msos # type: ignore [assignment]
228
234
  solvent: Solvent | None = None
@@ -259,3 +265,74 @@ class MultiStageOptMixin(BaseModel):
259
265
  )
260
266
 
261
267
  return self
268
+
269
+
270
+ def build_mso_settings(
271
+ sp_method: Method,
272
+ sp_basis_set: str | None,
273
+ opt_methods: list[Method],
274
+ opt_basis_sets: list[str | None],
275
+ mode: Mode = Mode.MANUAL,
276
+ solvent: Solvent | None = None,
277
+ use_solvent_for_opt: bool = False,
278
+ constraints: list[Constraint] | None = None,
279
+ transition_state: bool = False,
280
+ frequencies: bool = True,
281
+ ) -> MultiStageOptSettings:
282
+ """
283
+ Helper function to construct multi-stage opt settings objects manually.
284
+
285
+ There's no xTB pre-optimization here - add that yourself!
286
+
287
+ :param optimization_settings: list of opt settings to apply successively
288
+ :param singlepoint_settings: final single point settings
289
+ :param mode: Mode for settings, defaults to `MANUAL`
290
+ :param solvent: solvent to use
291
+ :param use_solvent_for_opt: whether to conduct opts with solvent
292
+ :param constraints: constraints for optimization
293
+ :param transition_state: whether this is a transition state
294
+ :param frequencies: whether to calculate frequencies
295
+ :returns: the final multistage opt settings
296
+ """
297
+ if constraints is None:
298
+ constraints = []
299
+
300
+ opt_settings = OptimizationSettings(constraints=constraints, transition_state=transition_state)
301
+
302
+ OPT = [Task.OPTIMIZE if not transition_state else Task.OPTIMIZE_TS]
303
+
304
+ def opt(method: Method, basis_set: str | None = None, solvent: Solvent | None = None, freq: bool = False) -> Settings:
305
+ """Generates optimization settings."""
306
+ model = "alpb" if method in XTB_METHODS else "cpcm"
307
+
308
+ return Settings(
309
+ method=method,
310
+ basis_set=basis_set,
311
+ tasks=OPT + [Task.FREQUENCIES] * freq,
312
+ solvent_settings=SolventSettings(solvent=solvent, model=model) if (solvent and use_solvent_for_opt) else None,
313
+ opt_settings=opt_settings,
314
+ )
315
+
316
+ def sp(method: Method, basis_set: str | None = None, solvent: Solvent | None = None) -> Settings:
317
+ """Generate singlepoint settings."""
318
+ model = "cpcmx" if method in XTB_METHODS else "cpcm"
319
+
320
+ return Settings(
321
+ method=method,
322
+ basis_set=basis_set,
323
+ tasks=[Task.ENERGY],
324
+ solvent_settings=SolventSettings(solvent=solvent, model=model) if solvent else None,
325
+ )
326
+
327
+ return MultiStageOptSettings(
328
+ mode=mode,
329
+ optimization_settings=[
330
+ opt(method=method, basis_set=basis_set, solvent=solvent, freq=frequencies) for method, basis_set in zip(opt_methods, opt_basis_sets, strict=True)
331
+ ],
332
+ singlepoint_settings=sp(method=sp_method, basis_set=sp_basis_set, solvent=solvent),
333
+ solvent=solvent,
334
+ xtb_preopt=False,
335
+ constraints=constraints,
336
+ transition_state=transition_state,
337
+ frequencies=frequencies,
338
+ )