stjames 0.0.39__py3-none-any.whl → 0.0.41__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.

stjames/settings.py CHANGED
@@ -5,7 +5,7 @@ import pydantic
5
5
  from .base import Base, UniqueList
6
6
  from .basis_set import BasisSet
7
7
  from .correction import Correction
8
- from .method import Method
8
+ from .method import METHODS_WITH_CORRECTION, PREPACKAGED_METHODS, Method
9
9
  from .mode import Mode
10
10
  from .opt_settings import OptimizationSettings
11
11
  from .scf_settings import SCFSettings
@@ -13,26 +13,7 @@ from .solvent import SolventSettings
13
13
  from .task import Task
14
14
  from .thermochem_settings import ThermochemistrySettings
15
15
 
16
- PREPACKAGED_METHODS = [
17
- Method.HF3C,
18
- Method.B973C,
19
- Method.R2SCAN3C,
20
- Method.AIMNET2_WB97MD3,
21
- Method.GFN2_XTB,
22
- Method.GFN1_XTB,
23
- Method.GFN0_XTB,
24
- Method.GFN_FF,
25
- ]
26
-
27
- METHODS_WITH_CORRECTION = [
28
- Method.WB97XD3,
29
- Method.WB97XV,
30
- Method.WB97MV,
31
- Method.WB97MD3BJ,
32
- Method.DSDBLYPD3BJ,
33
- ]
34
-
35
- T = TypeVar("T")
16
+ _T = TypeVar("_T")
36
17
 
37
18
 
38
19
  class Settings(Base):
@@ -51,7 +32,8 @@ class Settings(Base):
51
32
  thermochem_settings: ThermochemistrySettings = ThermochemistrySettings()
52
33
 
53
34
  # mypy has this dead wrong (https://docs.pydantic.dev/2.0/usage/computed_fields/)
54
- @pydantic.computed_field # type: ignore[misc]
35
+ # Python 3.12 narrows the reason for the ignore to prop-decorator
36
+ @pydantic.computed_field # type: ignore[misc, prop-decorator, unused-ignore]
55
37
  @property
56
38
  def level_of_theory(self) -> str:
57
39
  corrections = list(filter(lambda x: x not in (None, ""), self.corrections))
@@ -81,11 +63,15 @@ class Settings(Base):
81
63
  if self.method == Method.HF3C:
82
64
  self.basis_set = BasisSet(name="minix")
83
65
  elif self.method == Method.B973C:
84
- self.basis_set = BasisSet(name="mTZVP")
66
+ self.basis_set = BasisSet(name="def2-mTZVP")
67
+ elif self.method == Method.R2SCAN3C:
68
+ self.basis_set = BasisSet(name="def2-mTZVPP")
69
+ elif self.method == Method.WB97X3C:
70
+ self.basis_set = BasisSet(name="vDZP")
85
71
 
86
72
  @pydantic.field_validator("basis_set", mode="before")
87
73
  @classmethod
88
- def parse_basis_set(cls, v: Any) -> BasisSet | dict | None:
74
+ def parse_basis_set(cls, v: Any) -> BasisSet | dict[str, Any] | None:
89
75
  """Turn a string into a ``BasisSet`` object. (This is a little crude.)"""
90
76
  if isinstance(v, BasisSet):
91
77
  return None if v.name is None else v
@@ -103,9 +89,9 @@ class Settings(Base):
103
89
 
104
90
  @pydantic.field_validator("corrections", mode="before")
105
91
  @classmethod
106
- def remove_empty_string(cls, v: list[T]) -> list[T]:
92
+ def remove_empty_string(cls, v: list[_T]) -> list[_T]:
107
93
  """Remove empty string values."""
108
- return [c for c in v if c]
94
+ return [c for c in v if c] if v is not None else v
109
95
 
110
96
 
111
97
  def _assign_settings_by_mode(settings: Settings) -> None:
@@ -198,25 +184,26 @@ def _assign_settings_by_mode(settings: Settings) -> None:
198
184
 
199
185
  # cf. DLFIND manual, and https://www.cup.uni-muenchen.de/ch/compchem/geom/basic.html
200
186
  # and the discussion at https://geometric.readthedocs.io/en/latest/how-it-works.html
187
+ # in periodic systems, "normal" is 0.05 eV/Å ~= 2e-3 Hartree/Å, and "careful" is 0.01 ~= 4e-4
201
188
  if mode == Mode.RECKLESS:
202
- opt_settings.energy_threshold = 1e-5
203
- opt_settings.max_gradient_threshold = 4.5e-3
204
- opt_settings.rms_gradient_threshold = 3e-3
205
- elif (mode == Mode.RAPID) or (mode == Mode.CAREFUL and has_constraints):
189
+ opt_settings.energy_threshold = 2e-5
190
+ opt_settings.max_gradient_threshold = 7e-3
191
+ opt_settings.rms_gradient_threshold = 6e-3
192
+ elif mode == Mode.RAPID or (mode == Mode.CAREFUL and has_constraints):
206
193
  opt_settings.energy_threshold = 5e-5
207
- opt_settings.max_gradient_threshold = 2.5e-3
208
- opt_settings.rms_gradient_threshold = 1.7e-3
209
- elif mode == Mode.CAREFUL or (mode == mode.METICULOUS and has_constraints):
194
+ opt_settings.max_gradient_threshold = 5e-3
195
+ opt_settings.rms_gradient_threshold = 3.5e-3
196
+ elif mode == Mode.CAREFUL or (mode == Mode.METICULOUS and has_constraints):
210
197
  opt_settings.energy_threshold = 1e-6
211
- opt_settings.max_gradient_threshold = 4.5e-4
212
- opt_settings.rms_gradient_threshold = 3e-4
198
+ opt_settings.max_gradient_threshold = 9e-4
199
+ opt_settings.rms_gradient_threshold = 6e-4
213
200
  elif mode == Mode.METICULOUS:
214
201
  opt_settings.energy_threshold = 1e-6
215
- opt_settings.max_gradient_threshold = 1.5e-5
216
- opt_settings.rms_gradient_threshold = 1e-5
202
+ opt_settings.max_gradient_threshold = 3e-5
203
+ opt_settings.rms_gradient_threshold = 2e-5
217
204
  elif mode == Mode.DEBUG:
218
205
  opt_settings.energy_threshold = 1e-6
219
- opt_settings.max_gradient_threshold = 2e-6
220
- opt_settings.rms_gradient_threshold = 1e-6
206
+ opt_settings.max_gradient_threshold = 4e-6
207
+ opt_settings.rms_gradient_threshold = 2e-6
221
208
  else:
222
209
  raise ValueError(f"Unknown mode ``{mode.value}``!")
stjames/solvent.py CHANGED
@@ -35,6 +35,8 @@ class SolventModel(LowercaseStrEnum):
35
35
  CPCM = "cpcm"
36
36
  ALPB = "alpb"
37
37
  COSMO = "cosmo"
38
+ GBSA = "gbsa"
39
+ CPCMX = "cpcmx"
38
40
 
39
41
 
40
42
  class SolventSettings(Base):
stjames/task.py CHANGED
@@ -11,3 +11,4 @@ class Task(LowercaseStrEnum):
11
11
  DIPOLE = "dipole"
12
12
  HESSIAN = "hessian"
13
13
  FREQUENCIES = "frequencies"
14
+ STRESS = "stress"
stjames/types.py ADDED
@@ -0,0 +1,8 @@
1
+ from typing import TypeAlias
2
+
3
+ UUID: TypeAlias = str
4
+
5
+ Vector3D: TypeAlias = tuple[float, float, float]
6
+ Vector3DPerAtom: TypeAlias = list[Vector3D]
7
+
8
+ Matrix3x3: TypeAlias = tuple[Vector3D, Vector3D, Vector3D]
@@ -1,7 +1,13 @@
1
+ from .admet import *
2
+ from .basic_calculation import *
3
+ from .bde import *
1
4
  from .conformer import *
2
5
  from .descriptors import *
3
6
  from .fukui import *
7
+ from .molecular_dynamics import *
8
+ from .multistage_opt import *
4
9
  from .pka import *
5
10
  from .redox_potential import *
6
11
  from .scan import *
12
+ from .spin_states import *
7
13
  from .tautomer import *
@@ -0,0 +1,7 @@
1
+ from typing import Optional
2
+
3
+ from .workflow import Workflow
4
+
5
+
6
+ class ADMETWorkflow(Workflow):
7
+ properties: Optional[dict[str, float | int]] = None
@@ -0,0 +1,9 @@
1
+ from ..settings import Settings
2
+ from ..types import UUID
3
+ from .workflow import Workflow
4
+
5
+
6
+ class BasicCalculationWorkflow(Workflow):
7
+ settings: Settings
8
+ engine: str
9
+ calculation_uuid: UUID | None = None
@@ -0,0 +1,269 @@
1
+ """Bond Dissociation Energy (BDE) workflow."""
2
+
3
+ import itertools
4
+ from typing import Any, Iterable, Self, TypeVar
5
+
6
+ from pydantic import BaseModel, Field, PositiveInt, ValidationInfo, field_validator, model_validator
7
+
8
+ from ..mode import Mode
9
+ from ..molecule import Molecule
10
+ from ..types import UUID
11
+ from .multistage_opt import MultiStageOptMixin
12
+ from .workflow import Workflow
13
+
14
+ # the id of a mutable object may change, thus using object()
15
+ _sentinel_mso_mode = object()
16
+ _T = TypeVar("_T")
17
+
18
+
19
+ class BDE(BaseModel):
20
+ """
21
+ Bond Dissociation Energy (BDE) result.
22
+
23
+ energy => (E_{fragment1} + E_{fragment2}) - E_{starting molecule}
24
+
25
+ :param fragment_idxs: indices of the atoms in the fragment that was dissociated (1-indexed)
26
+ :param energy: BDE in kcal/mol
27
+ :param fragment_energies: energy of fragments
28
+ :param calculations_uuids: calculation UUIDs
29
+ """
30
+
31
+ fragment_idxs: tuple[PositiveInt, ...]
32
+ energy: float | None
33
+ fragment_energies: tuple[float | None, float | None]
34
+ calculation_uuids: tuple[list[UUID | None], list[UUID | None]]
35
+
36
+ def __str__(self) -> str:
37
+ return repr(self)
38
+
39
+ def __repr__(self) -> str:
40
+ """
41
+ Return a string representation of the BDE result.
42
+
43
+ >>> BDE(fragment_idxs=(1, 2), energy=1.0, fragment_energies=(4, 2), calculation_uuids=([], []))
44
+ <BDE (1, 2) 1.00>
45
+ """
46
+ energy = "None" if self.energy is None else f"{self.energy:>5.2f}"
47
+
48
+ return f"<{type(self).__name__} {self.fragment_idxs} {energy}>"
49
+
50
+
51
+ class BDEWorkflow(Workflow, MultiStageOptMixin):
52
+ """
53
+ Bond Dissociation Energy (BDE) workflow.
54
+
55
+ Uses the modes from MultiStageOptSettings to compute BDEs.
56
+
57
+ Inherited:
58
+ :param initial_molecule: Molecule of interest
59
+ :param multistage_opt_settings: set by mode unless mode=MANUAL (ignores additional settings if set)
60
+ :param solvent: solvent to use
61
+ :param xtb_preopt: pre-optimize with xtb (sets based on mode when None)
62
+
63
+ Overridden:
64
+ :param mso_mode: Mode for MultiStageOptSettings
65
+ :param frequencies: whether to calculate frequencies
66
+
67
+ Turned off:
68
+ :param constraints: constraints to add (not supported)
69
+ :param transition_state: whether this is a transition state (not supported)
70
+
71
+ New:
72
+ :param mode: Mode for workflow
73
+ :param optimize_fragments: whether to optimize the fragments, or just the starting molecule (default depends on mode)
74
+ :param atoms: atoms to dissociate (1-indexed)
75
+ :param fragment_indices: fragments to dissociate (all fields feed into this, 1-indexed)
76
+ :param all_CH: dissociate all C–H bonds
77
+ :param all_CX: dissociate all C–X bonds (X ∈ {F, Cl, Br, I, At, Ts})
78
+ :param optimization_calculation_uuids: UUIDs of initial optimization calculations
79
+ :param optimization_energy: energy of optimized initial molecule
80
+ :param bdes: BDE results
81
+ """
82
+
83
+ mode: Mode
84
+ mso_mode: Mode = _sentinel_mso_mode # type: ignore [assignment]
85
+ frequencies: bool = False
86
+ optimize_fragments: bool = None # type: ignore [assignment]
87
+
88
+ atoms: tuple[PositiveInt, ...] = Field(default_factory=tuple)
89
+ fragment_indices: tuple[tuple[PositiveInt, ...], ...] = Field(default_factory=tuple)
90
+
91
+ all_CH: bool = False
92
+ all_CX: bool = False
93
+
94
+ # Results
95
+ optimization_calculation_uuids: list[UUID | None] | None = None
96
+ optimization_energy: float | None = None
97
+ bdes: list[BDE] = Field(default_factory=list)
98
+
99
+ def __str__(self) -> str:
100
+ r"""
101
+ Return a string representation of the BDE workflow.
102
+
103
+ >>> print(BDEWorkflow(initial_molecule=Molecule.from_xyz("H 0 0 0\nF 0 0 1"), mode=Mode.METICULOUS, atoms=[1, 2]))
104
+ BDEWorkflow METICULOUS
105
+ (1,)
106
+ (2,)
107
+ """
108
+ return f"{type(self).__name__} {self.mode.name}\n" + "\n".join(map(str, self.fragment_indices))
109
+
110
+ def __repr__(self) -> str:
111
+ """
112
+ Return a string representation of the BDE workflow.
113
+
114
+ >>> BDEWorkflow(initial_molecule=Molecule.from_xyz("He 0 0 0"), mode=Mode.METICULOUS, atoms=[])
115
+ <BDEWorkflow METICULOUS>
116
+ """
117
+ return f"<{type(self).__name__} {self.mode.name}>"
118
+
119
+ @property
120
+ def energies(self) -> tuple[float | None, ...]:
121
+ return tuple(bde.energy for bde in self.bdes)
122
+
123
+ @field_validator("constraints", "transition_state")
124
+ @classmethod
125
+ def turned_off(cls, value: _T, info: ValidationInfo) -> _T:
126
+ if value:
127
+ raise ValueError(f"{info.field_name} not supported in BDE workflows.")
128
+
129
+ return value
130
+
131
+ @field_validator("mode")
132
+ @classmethod
133
+ def set_mode_auto(cls, mode: Mode) -> Mode:
134
+ if mode == Mode.AUTO:
135
+ return Mode.RAPID
136
+
137
+ return mode
138
+
139
+ @field_validator("initial_molecule", mode="before")
140
+ @classmethod
141
+ def no_charge_or_spin(cls, mol: Molecule) -> Molecule:
142
+ """Ensure the molecule has no charge or spin."""
143
+ if mol.charge != 0 or mol.multiplicity != 1:
144
+ raise ValueError("Charge and spin partitioning undefined for BDE, only neutral singlet molecules supported.")
145
+
146
+ return mol
147
+
148
+ @model_validator(mode="before")
149
+ @classmethod
150
+ def set_mso_mode(cls, values: dict[str, Any]) -> dict[str, Any]:
151
+ """Set the MultiStageOptSettings mode to match current BDE mode."""
152
+ values["mso_mode"] = values["mode"]
153
+ return values
154
+
155
+ @model_validator(mode="after")
156
+ def validate_and_build(self) -> Self:
157
+ """Validate atomic numbers and build the atoms field."""
158
+ self.atoms = tuple(self.atoms)
159
+ self.fragment_indices = tuple(map(tuple, self.fragment_indices))
160
+
161
+ match self.mode:
162
+ case Mode.RECKLESS | Mode.RAPID:
163
+ # Default off
164
+ self.optimize_fragments = self.optimize_fragments or False
165
+ case Mode.CAREFUL | Mode.METICULOUS:
166
+ # Default on
167
+ self.optimize_fragments = self.optimize_fragments or self.optimize_fragments is None
168
+ case _:
169
+ raise NotImplementedError(f"{self.mode} not implemented.")
170
+
171
+ for atom in self.atoms:
172
+ if atom > len(self.initial_molecule):
173
+ raise ValueError(f"{atom=} is out of range.")
174
+ for fragment_idxs in self.fragment_indices:
175
+ if any(a > len(self.initial_molecule) for a in fragment_idxs):
176
+ raise ValueError(f"{fragment_idxs=} contains atoms that are out of range.")
177
+
178
+ if self.all_CH:
179
+ self.atoms = self.atoms + tuple(H for _C, H in find_CH_bonds(self.initial_molecule))
180
+ if self.all_CX:
181
+ self.atoms = self.atoms + tuple(X for _C, X in find_CX_bonds(self.initial_molecule))
182
+
183
+ # Combine atoms and fragments, remove duplicates, and sort
184
+ self.fragment_indices = self.fragment_indices + tuple((a,) for a in self.atoms)
185
+ assert isinstance(self.fragment_indices, tuple)
186
+ self.fragment_indices = tuple(sorted(set(self.fragment_indices)))
187
+
188
+ return self
189
+
190
+
191
+ def atomic_number_indices(molecule: Molecule, atomic_numbers: set[PositiveInt] | PositiveInt) -> tuple[PositiveInt, ...]:
192
+ r"""
193
+ Return the indices of the atoms with the given atomic numbers.
194
+
195
+ :param molecule: Molecule of interest
196
+ :param atomic_numbers: atomic number(s) of interest
197
+
198
+ >>> H2O = Molecule.from_xyz("H 0 0 0\nO 0 0 1\nH 0 1 1")
199
+ >>> atomic_number_indices(H2O, 1)
200
+ (1, 3)
201
+ >>> atomic_number_indices(H2O, {8, 1})
202
+ (1, 2, 3)
203
+ >>> atomic_number_indices(H2O, {6, 9})
204
+ ()
205
+ """
206
+ if isinstance(atomic_numbers, int):
207
+ return tuple(i for i, an in enumerate(molecule.atomic_numbers, start=1) if an == atomic_numbers)
208
+ return tuple(i for i, an in enumerate(molecule.atomic_numbers, start=1) if an in atomic_numbers)
209
+
210
+
211
+ def find_CH_bonds(molecule: Molecule, distance_max: float = 1.2) -> Iterable[tuple[PositiveInt, PositiveInt]]:
212
+ r"""
213
+ Find all C–H bonds in the molecule.
214
+
215
+ :param molecule: Molecule of interest
216
+ :param distance_max: distance max for bond
217
+
218
+ >>> H2O = Molecule.from_xyz("H 0 0 0\nO 0 0 1\nH 0 1 1")
219
+ >>> CH4 = Molecule.from_xyz("C 0 0 0\nH 0 0 1\nH 0 1 0\nH 1 0 0\nH 0 0 -1")
220
+ >>> ethane = Molecule.from_xyz("C -1 0 0\nC 1 0 0\nH -1 0 1\nH -1 0 -1\nH -1 1 0\nH 1 0 1\nH 1 0 -1\nH 1 1 0")
221
+ >>> list(find_CH_bonds(H2O))
222
+ []
223
+ >>> list(find_CH_bonds(CH4))
224
+ [(1, 2), (1, 3), (1, 4), (1, 5)]
225
+ >>> list(find_CH_bonds(ethane))
226
+ [(1, 3), (1, 4), (1, 5), (2, 6), (2, 7), (2, 8)]
227
+ """
228
+ yield from find_AB_bonds(molecule, 6, 1, distance_max)
229
+
230
+
231
+ def find_CX_bonds(molecule: Molecule) -> Iterable[tuple[PositiveInt, PositiveInt]]:
232
+ r"""
233
+ Find all C–X bonds in the molecule.
234
+
235
+ :param molecule: Molecule of interest
236
+ :param distance_max: distance max for bond
237
+
238
+ >>> HCF = Molecule.from_xyz("H 0 0 0\nC 0 0 1\nF 0 1 1")
239
+ >>> list(find_CX_bonds(HCF))
240
+ [(2, 3)]
241
+ """
242
+ halogens = {9: 2.0, 17: 2.2, 35: 2.5, 53: 2.8, 85: 3.0, 117: 4.0}
243
+ yield from itertools.chain.from_iterable(find_AB_bonds(molecule, 6, x, distance) for x, distance in halogens.items())
244
+
245
+
246
+ def find_AB_bonds(molecule: Molecule, a: int, b: int, distance_max: float) -> Iterable[tuple[PositiveInt, PositiveInt]]:
247
+ r"""
248
+ Find all A–B bonds in the molecule.
249
+
250
+ :param molecule: Molecule of interest
251
+ :param a: atomic number of atom A
252
+ :param b: atomic number of atom B
253
+ :param distance_max: distance max for bond
254
+
255
+ >>> H2O = Molecule.from_xyz("H 0 0 0\nO 0 0 1\nH 0 1 1")
256
+ >>> list(find_AB_bonds(H2O, 8, 1, 1.1))
257
+ [(2, 1), (2, 3)]
258
+ """
259
+
260
+ def close(idxs: tuple[PositiveInt, PositiveInt]) -> bool:
261
+ return molecule.distance(*idxs) < distance_max
262
+
263
+ yield from filter(
264
+ close,
265
+ itertools.product(
266
+ atomic_number_indices(molecule, a),
267
+ atomic_number_indices(molecule, b),
268
+ ),
269
+ )
@@ -13,6 +13,7 @@ class ConformerSettings(Base):
13
13
  num_confs_taken: int = 50
14
14
  rmsd_cutoff: float = 0.1
15
15
 
16
+ transition_state: bool = False
16
17
  final_method: Method = Method.AIMNET2_WB97MD3
17
18
  solvent: Optional[Solvent] = Solvent.WATER
18
19
  max_energy: float = 5
@@ -1,3 +1,4 @@
1
+ from ..types import UUID
1
2
  from .workflow import Workflow
2
3
 
3
4
  Descriptors = dict[str, dict[str, float] | tuple[float | None, ...] | float]
@@ -5,6 +6,6 @@ Descriptors = dict[str, dict[str, float] | tuple[float | None, ...] | float]
5
6
 
6
7
  class DescriptorsWorkflow(Workflow):
7
8
  # uuid of optimization
8
- optimization: str | None = None
9
+ optimization: UUID | None = None
9
10
 
10
11
  descriptors: Descriptors | None = None
@@ -1,13 +1,12 @@
1
- from typing import Optional
2
-
1
+ from ..types import UUID
3
2
  from .workflow import Workflow
4
3
 
5
4
 
6
5
  class FukuiIndexWorkflow(Workflow):
7
6
  # uuid of optimization
8
- optimization: Optional[str] = None
7
+ optimization: UUID | None = None
9
8
 
10
- global_electrophilicity_index: Optional[float] = None
11
- fukui_positive: Optional[list[float]] = None
12
- fukui_negative: Optional[list[float]] = None
13
- fukui_zero: Optional[list[float]] = None
9
+ global_electrophilicity_index: float | None = None
10
+ fukui_positive: list[float] | None = None
11
+ fukui_negative: list[float] | None = None
12
+ fukui_zero: list[float] | None = None
@@ -0,0 +1,60 @@
1
+ from typing import Self
2
+
3
+ from pydantic import PositiveFloat, PositiveInt, model_validator
4
+
5
+ from ..base import Base, LowercaseStrEnum
6
+ from ..constraint import PairwiseHarmonicConstraint, SphericalHarmonicConstraint
7
+ from ..settings import Settings
8
+ from ..types import UUID
9
+ from .workflow import Workflow
10
+
11
+
12
+ class ThermodynamicEnsemble(LowercaseStrEnum):
13
+ NPT = "npt"
14
+ NVT = "nvt"
15
+ NVE = "nve"
16
+
17
+
18
+ class Frame(Base):
19
+ index: int # what number frame this is within the MD simulation
20
+
21
+ uuid: UUID | None = None # UUID of molecule
22
+
23
+ pressure: float
24
+ temperature: float
25
+ energy: float
26
+
27
+
28
+ class MolecularDynamicsSettings(Base):
29
+ ensemble: ThermodynamicEnsemble = ThermodynamicEnsemble.NVT
30
+
31
+ timestep: PositiveFloat = 1.0 # fs
32
+ num_steps: PositiveInt = 500
33
+
34
+ confining_constraint: SphericalHarmonicConstraint | None = None
35
+
36
+ temperature: PositiveFloat | None = 300 # K
37
+ pressure: PositiveFloat | None = None # atm
38
+
39
+ langevin_thermostat_timescale: PositiveFloat = 100 # fs
40
+ berendsen_barostat_timescale: PositiveFloat = 1000 # fs
41
+
42
+ constraints: list[PairwiseHarmonicConstraint] = []
43
+
44
+ @model_validator(mode="after")
45
+ def validate_ensemble_settings(self) -> Self:
46
+ """Check that NVT ensemble always has temperature defined, and that NPT has temp and pressure defined."""
47
+ if self.ensemble == ThermodynamicEnsemble.NVT and self.temperature is None:
48
+ 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):
50
+ raise ValueError("NPT ensemble must have both temperature and pressure defined")
51
+ return self
52
+
53
+
54
+ class MolecularDynamicsWorkflow(Workflow):
55
+ settings: MolecularDynamicsSettings
56
+ calc_settings: Settings
57
+ calc_engine: str | None = None
58
+
59
+ # uuids of scan points
60
+ frames: list[Frame] = []