stjames 0.0.39__tar.gz → 0.0.41__tar.gz

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 (60) hide show
  1. {stjames-0.0.39/stjames.egg-info → stjames-0.0.41}/PKG-INFO +3 -3
  2. {stjames-0.0.39 → stjames-0.0.41}/README.md +1 -1
  3. {stjames-0.0.39 → stjames-0.0.41}/pyproject.toml +16 -6
  4. {stjames-0.0.39 → stjames-0.0.41}/stjames/__init__.py +3 -0
  5. stjames-0.0.41/stjames/atom.py +66 -0
  6. stjames-0.0.41/stjames/base.py +42 -0
  7. {stjames-0.0.39 → stjames-0.0.41}/stjames/basis_set.py +3 -7
  8. {stjames-0.0.39 → stjames-0.0.41}/stjames/calculation.py +2 -0
  9. stjames-0.0.41/stjames/constraint.py +36 -0
  10. stjames-0.0.41/stjames/data/__init__.py +1 -0
  11. stjames-0.0.41/stjames/data/elements.py +27 -0
  12. stjames-0.0.41/stjames/data/read_nist_isotopes.py +116 -0
  13. {stjames-0.0.39 → stjames-0.0.41}/stjames/method.py +34 -1
  14. stjames-0.0.41/stjames/molecule.py +192 -0
  15. stjames-0.0.41/stjames/opt_settings.py +21 -0
  16. stjames-0.0.41/stjames/periodic_cell.py +34 -0
  17. {stjames-0.0.39 → stjames-0.0.41}/stjames/settings.py +26 -39
  18. {stjames-0.0.39 → stjames-0.0.41}/stjames/solvent.py +2 -0
  19. {stjames-0.0.39 → stjames-0.0.41}/stjames/task.py +1 -0
  20. stjames-0.0.41/stjames/types.py +8 -0
  21. {stjames-0.0.39 → stjames-0.0.41}/stjames/workflows/__init__.py +6 -0
  22. stjames-0.0.41/stjames/workflows/admet.py +7 -0
  23. stjames-0.0.41/stjames/workflows/basic_calculation.py +9 -0
  24. stjames-0.0.41/stjames/workflows/bde.py +269 -0
  25. {stjames-0.0.39 → stjames-0.0.41}/stjames/workflows/conformer.py +1 -0
  26. {stjames-0.0.39 → stjames-0.0.41}/stjames/workflows/descriptors.py +2 -1
  27. stjames-0.0.41/stjames/workflows/fukui.py +12 -0
  28. stjames-0.0.41/stjames/workflows/molecular_dynamics.py +60 -0
  29. stjames-0.0.41/stjames/workflows/multistage_opt.py +261 -0
  30. {stjames-0.0.39 → stjames-0.0.41}/stjames/workflows/redox_potential.py +9 -8
  31. {stjames-0.0.39 → stjames-0.0.41}/stjames/workflows/scan.py +6 -6
  32. stjames-0.0.41/stjames/workflows/spin_states.py +144 -0
  33. stjames-0.0.41/stjames/workflows/workflow.py +46 -0
  34. {stjames-0.0.39 → stjames-0.0.41/stjames.egg-info}/PKG-INFO +3 -3
  35. {stjames-0.0.39 → stjames-0.0.41}/stjames.egg-info/SOURCES.txt +14 -1
  36. stjames-0.0.41/tests/test_molecule.py +39 -0
  37. stjames-0.0.39/stjames/base.py +0 -42
  38. stjames-0.0.39/stjames/constraint.py +0 -16
  39. stjames-0.0.39/stjames/molecule.py +0 -91
  40. stjames-0.0.39/stjames/opt_settings.py +0 -16
  41. stjames-0.0.39/stjames/workflows/fukui.py +0 -13
  42. stjames-0.0.39/stjames/workflows/workflow.py +0 -16
  43. {stjames-0.0.39 → stjames-0.0.41}/LICENSE +0 -0
  44. {stjames-0.0.39 → stjames-0.0.41}/setup.cfg +0 -0
  45. {stjames-0.0.39 → stjames-0.0.41}/stjames/_deprecated_solvent_settings.py +0 -0
  46. {stjames-0.0.39 → stjames-0.0.41}/stjames/correction.py +0 -0
  47. {stjames-0.0.39 → stjames-0.0.41}/stjames/diis_settings.py +0 -0
  48. {stjames-0.0.39 → stjames-0.0.41}/stjames/grid_settings.py +0 -0
  49. {stjames-0.0.39 → stjames-0.0.41}/stjames/int_settings.py +0 -0
  50. {stjames-0.0.39 → stjames-0.0.41}/stjames/message.py +0 -0
  51. {stjames-0.0.39 → stjames-0.0.41}/stjames/mode.py +0 -0
  52. {stjames-0.0.39 → stjames-0.0.41}/stjames/py.typed +0 -0
  53. {stjames-0.0.39 → stjames-0.0.41}/stjames/scf_settings.py +0 -0
  54. {stjames-0.0.39 → stjames-0.0.41}/stjames/status.py +0 -0
  55. {stjames-0.0.39 → stjames-0.0.41}/stjames/thermochem_settings.py +0 -0
  56. {stjames-0.0.39 → stjames-0.0.41}/stjames/workflows/pka.py +0 -0
  57. {stjames-0.0.39 → stjames-0.0.41}/stjames/workflows/tautomer.py +0 -0
  58. {stjames-0.0.39 → stjames-0.0.41}/stjames.egg-info/dependency_links.txt +0 -0
  59. {stjames-0.0.39 → stjames-0.0.41}/stjames.egg-info/requires.txt +0 -0
  60. {stjames-0.0.39 → stjames-0.0.41}/stjames.egg-info/top_level.txt +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stjames
3
- Version: 0.0.39
3
+ Version: 0.0.41
4
4
  Summary: standardized JSON atom/molecule encoding scheme
5
5
  Author-email: Corin Wagen <corin@rowansci.com>
6
6
  Project-URL: Homepage, https://github.com/rowansci/stjames
7
7
  Project-URL: Bug Tracker, https://github.com/rowansci/stjames/issues
8
- Requires-Python: >=3.8
8
+ Requires-Python: >=3.11
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: pydantic>=2.4
@@ -27,7 +27,7 @@ This is not intended to be run as a standalone library: it's basically just a bi
27
27
 
28
28
  ## Installation
29
29
 
30
- To install, ensure you have Python 3.8 or newer. Then run:
30
+ To install, ensure you have Python 3.11 or newer. Then run:
31
31
 
32
32
  ```
33
33
  pip install stjames
@@ -14,7 +14,7 @@ This is not intended to be run as a standalone library: it's basically just a bi
14
14
 
15
15
  ## Installation
16
16
 
17
- To install, ensure you have Python 3.8 or newer. Then run:
17
+ To install, ensure you have Python 3.11 or newer. Then run:
18
18
 
19
19
  ```
20
20
  pip install stjames
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "stjames"
3
- version = "0.0.39"
3
+ version = "0.0.41"
4
4
  description = "standardized JSON atom/molecule encoding scheme"
5
5
  readme = "README.md"
6
- requires-python = ">=3.8"
6
+ requires-python = ">=3.11"
7
7
  authors = [
8
8
  { name = "Corin Wagen", email = "corin@rowansci.com" },
9
9
  ]
@@ -33,15 +33,25 @@ line-length = 160
33
33
 
34
34
  [tool.ruff.lint]
35
35
  select = [
36
- "E", # pycodestyle errors
37
- "F", # pyflakes
38
- "I", # isort
39
- "W", # pycodestyle warnings
36
+ "E", # pycodestyle errors
37
+ "F", # pyflakes
38
+ "I", # isort
39
+ "W", # pycodestyle warnings
40
40
  ]
41
41
  ignore = ["E741"]
42
42
 
43
43
  [tool.ruff.lint.per-file-ignores]
44
44
  "__init__.py" = ["F401", "F403"]
45
45
 
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["stjames", "tests"]
48
+ addopts = "--doctest-modules"
49
+ doctest_optionflags = "NORMALIZE_WHITESPACE"
50
+ markers = [
51
+ "smoke: sanity tests to reveal simple failures"
52
+ ]
53
+
46
54
  [tool.mypy]
47
55
  plugins = ["pydantic.mypy"]
56
+ strict = true
57
+ warn_unused_ignores = true
@@ -1,6 +1,8 @@
1
1
  # ruff: noqa: I001
2
2
 
3
3
  from .calculation import *
4
+ from .atom import *
5
+ from .periodic_cell import *
4
6
  from .molecule import *
5
7
  from .workflows import *
6
8
 
@@ -21,3 +23,4 @@ from .mode import *
21
23
  from .status import *
22
24
  from .constraint import *
23
25
  from .message import *
26
+ from .types import *
@@ -0,0 +1,66 @@
1
+ from typing import Self, Sequence
2
+
3
+ from pydantic import NonNegativeInt
4
+
5
+ from .base import Base
6
+ from .data import ELEMENT_SYMBOL, SYMBOL_ELEMENT
7
+ from .types import Vector3D
8
+
9
+
10
+ class Atom(Base):
11
+ atomic_number: NonNegativeInt
12
+ position: Vector3D # in Å
13
+
14
+ def __repr__(self) -> str:
15
+ """
16
+ >>> Atom(atomic_number=2, position=[0, 1, 2])
17
+ Atom(2, [0.00000, 1.00000, 2.00000])
18
+ """
19
+ x, y, z = self.position
20
+ return f"Atom({self.atomic_number}, [{x:.5f}, {y:.5f}, {z:.5f}])"
21
+
22
+ def __str__(self) -> str:
23
+ """
24
+ >>> str(Atom(atomic_number=2, position=[0, 1, 2]))
25
+ 'He 0.0000000000 1.0000000000 2.0000000000'
26
+ """
27
+ x, y, z = self.position
28
+ return f"{self.atomic_symbol:2} {x:15.10f} {y:15.10f} {z:15.10f}"
29
+
30
+ @property
31
+ def atomic_symbol(self) -> str:
32
+ """
33
+ >>> Atom(atomic_number=2, position=[0, 1, 2]).atomic_symbol
34
+ 'He'
35
+ """
36
+ return ELEMENT_SYMBOL[self.atomic_number]
37
+
38
+ def edited(self, atomic_number: int | None = None, position: Sequence[float] | None = None) -> Self:
39
+ """
40
+ Create a new Atom with the specified changes.
41
+
42
+ >>> a = Atom(atomic_number=2, position=[0, 1, 2])
43
+ >>> a2 = a.edited(3)
44
+ >>> a is a2
45
+ False
46
+ >>> a2
47
+ Atom(3, [0.00000, 1.00000, 2.00000])
48
+ """
49
+ if atomic_number is None:
50
+ atomic_number = self.atomic_number
51
+ if position is None:
52
+ position = list(self.position)
53
+
54
+ return self.__class__(atomic_number=atomic_number, position=position)
55
+
56
+ @classmethod
57
+ def from_xyz(cls: type[Self], xyz_line: str) -> Self:
58
+ """
59
+ >>> Atom.from_xyz("H 0 0 0")
60
+ Atom(1, [0.00000, 0.00000, 0.00000])
61
+ """
62
+ name, *xyz = xyz_line.split()
63
+ symbol = int(name) if name.isdigit() else SYMBOL_ELEMENT[name]
64
+ if not len(xyz) == 3:
65
+ raise ValueError("XYZ file should have 3 coordinates per atom")
66
+ return cls(atomic_number=symbol, position=xyz)
@@ -0,0 +1,42 @@
1
+ from enum import Enum
2
+ from typing import Annotated, Any, Hashable, TypeVar
3
+
4
+ import numpy as np
5
+ import pydantic
6
+
7
+ _T = TypeVar("_T")
8
+
9
+
10
+ class Base(pydantic.BaseModel):
11
+ @pydantic.field_validator("*", mode="before")
12
+ @classmethod
13
+ def coerce_numpy(cls, val: _T) -> _T | list[Any]:
14
+ if isinstance(val, np.ndarray):
15
+ return val.tolist() # type: ignore [no-any-return, unused-ignore]
16
+
17
+ return val
18
+
19
+
20
+ class LowercaseStrEnum(str, Enum):
21
+ """Enum where hyphens, underscores, and case are ignored."""
22
+
23
+ @classmethod
24
+ def _missing_(cls, value: object) -> str | None:
25
+ for member in cls:
26
+ if isinstance(value, str):
27
+ if member.lower().replace("-", "").replace("_", "") == value.lower().replace("-", "").replace("_", ""):
28
+ return member
29
+ return None
30
+
31
+
32
+ # cf. https://github.com/pydantic/pydantic-core/pull/820#issuecomment-1670475909
33
+ _H = TypeVar("_H", bound=Hashable)
34
+
35
+
36
+ def _validate_unique_list(v: list[_H]) -> list[_H]:
37
+ if len(v) != len(set(v)):
38
+ raise ValueError("this list must be unique, and isn't!")
39
+ return v
40
+
41
+
42
+ UniqueList = Annotated[list[_H], pydantic.AfterValidator(_validate_unique_list)]
@@ -1,10 +1,6 @@
1
- import pydantic
2
- from pydantic import PositiveFloat, PositiveInt
1
+ from typing import Optional, Self
3
2
 
4
- try:
5
- from typing import Optional, Self
6
- except ImportError:
7
- from typing_extensions import Optional, Self
3
+ from pydantic import PositiveFloat, PositiveInt, model_validator
8
4
 
9
5
  from .base import Base
10
6
 
@@ -14,7 +10,7 @@ class BasisSetOverride(Base):
14
10
  atomic_numbers: Optional[list[PositiveInt]] = None
15
11
  atoms: Optional[list[PositiveInt]] = None # 1-indexed
16
12
 
17
- @pydantic.model_validator(mode="after")
13
+ @model_validator(mode="after")
18
14
  def check_override(self) -> Self:
19
15
  # ^ is xor
20
16
  assert (self.atomic_numbers is not None) ^ (self.atoms is not None), "Exactly one of ``atomic_numbers`` or ``atoms`` must be specified!"
@@ -5,6 +5,7 @@ from .message import Message
5
5
  from .molecule import Molecule
6
6
  from .settings import Settings
7
7
  from .status import Status
8
+ from .types import UUID
8
9
 
9
10
 
10
11
  class StJamesVersion(LowercaseStrEnum):
@@ -29,6 +30,7 @@ class Calculation(Base):
29
30
  messages: list[Message] = []
30
31
 
31
32
  engine: Optional[str] = "peregrine"
33
+ uuids: list[UUID | None] | None = None
32
34
 
33
35
  # not to be changed by end users, diff. versions will have diff. defaults
34
36
  json_format: str = StJamesVersion.V0
@@ -0,0 +1,36 @@
1
+ from pydantic import PositiveFloat, PositiveInt
2
+
3
+ from .base import Base, LowercaseStrEnum
4
+
5
+
6
+ class ConstraintType(LowercaseStrEnum):
7
+ """Different sorts of constraints."""
8
+
9
+ BOND = "bond"
10
+ ANGLE = "angle"
11
+ DIHEDRAL = "dihedral"
12
+
13
+
14
+ class Constraint(Base):
15
+ """Represents a single (absolute) constraint."""
16
+
17
+ constraint_type: ConstraintType
18
+ atoms: list[PositiveInt] # 1-indexed
19
+
20
+
21
+ class PairwiseHarmonicConstraint(Base):
22
+ """
23
+ Represents a harmonic constraint, with a characteristic spring constant.
24
+ """
25
+
26
+ atoms: tuple[PositiveInt, PositiveInt] # 1-indexed
27
+ spring_constant: PositiveFloat # kcal/mol / Å**2
28
+
29
+
30
+ class SphericalHarmonicConstraint(Base):
31
+ """
32
+ Represents a spherical harmonic constraint to keep a system near the origin.
33
+ """
34
+
35
+ confining_radius: PositiveFloat
36
+ confining_force_constant: PositiveFloat = 10 # kcal/mol / Å**2
@@ -0,0 +1 @@
1
+ from .elements import *
@@ -0,0 +1,27 @@
1
+ """Read elemental data from files."""
2
+
3
+ import json
4
+ from collections import namedtuple
5
+ from importlib import resources
6
+
7
+ data_dir = resources.files("stjames").joinpath("data")
8
+
9
+ with data_dir.joinpath("symbol_element.json").open() as f:
10
+ SYMBOL_ELEMENT: dict[str, int] = json.loads(f.read())
11
+
12
+ ELEMENT_SYMBOL = {v: k for k, v in SYMBOL_ELEMENT.items()}
13
+
14
+ Isotope = namedtuple("Isotope", ["relative_atomic_mass", "isotopic_composition", "standard_atomic_weight"])
15
+ with data_dir.joinpath("nist_isotopes.json").open() as f:
16
+ d = json.loads(f.read())
17
+
18
+ ISOTOPES: dict[int, dict[int, Isotope]] = {
19
+ int(k): {
20
+ int(kk): Isotope(*vv)
21
+ for kk, vv in v.items() # stay open
22
+ }
23
+ for k, v in d.items()
24
+ }
25
+
26
+ with data_dir.joinpath("bragg_radii.json").open() as f:
27
+ BRAGG_RADII: dict[int, float] = json.loads(f.read())
@@ -0,0 +1,116 @@
1
+ """
2
+ Read the NIST isotopes data file and write it to a JSON file.
3
+
4
+ NIST Isotopes data from:
5
+ https://physics.nist.gov/cgi-bin/Compositions/stand_alone.pl?ele=&all=all&ascii=ascii2
6
+ """
7
+
8
+ import json
9
+ from collections import defaultdict
10
+ from importlib import resources
11
+ from typing import Callable, TypeVar
12
+
13
+ data_dir = resources.files("stjames").joinpath("data")
14
+
15
+ _T = TypeVar("_T")
16
+
17
+
18
+ def process_line(line: str, fmt: Callable[[str], _T] = str) -> _T: # type: ignore[assignment]
19
+ """
20
+ Process a line from the NIST data file.
21
+
22
+ :param line: line to process
23
+ :param fmt: function to format the value
24
+ >>> process_line("Atomic Number = 1", int)
25
+ 1
26
+ """
27
+ return fmt(line.split("=")[-1].strip())
28
+
29
+
30
+ def fmt_float(val: str) -> float:
31
+ """
32
+ Format a float from the NIST data file.
33
+
34
+ >>> fmt_float(" 1.00784(7)")
35
+ 1.00784
36
+ """
37
+ return float(val.strip().split("(")[0])
38
+
39
+
40
+ def fmt_maybe_list(val: str) -> float:
41
+ """
42
+ Format a float or list of floats from the NIST data file.
43
+
44
+ Only the first value is returned.
45
+
46
+ >>> fmt_maybe_list("1.00784(7)")
47
+ 1.00784
48
+ >>> fmt_maybe_list(" [1.00784,1.00811]")
49
+ 1.00784
50
+ >>> fmt_maybe_list(" [98]")
51
+ 98.0
52
+ """
53
+ val = val.strip()
54
+ if val.startswith("["):
55
+ val = val[1:-1].split(",")[0]
56
+ return fmt_float(val)
57
+
58
+
59
+ def process_chunk(chunk: str) -> tuple[int, int, tuple[float, float, float]]:
60
+ r"""
61
+ Atomic Number, Mass Number, (Relative Atomic Mass, Isotopic Composition, Standard Atomic Weight)
62
+
63
+ >>> process_chunk('''\
64
+ ... Atomic Number = 1
65
+ ... Atomic Symbol = H
66
+ ... Mass Number = 1
67
+ ... Relative Atomic Mass = 1.00784(7)
68
+ ... Isotopic Composition = 0.999885(70)
69
+ ... Standard Atomic Weight = [1.00784,1.00811]
70
+ ... Notes = m
71
+ ... ''')
72
+ (1, 1, (1.00784, 0.999885, 1.00784))
73
+ """
74
+ lines = chunk.splitlines()
75
+
76
+ atomic_number = process_line(lines[0], int)
77
+ _atomic_symbol = process_line(lines[1], str)
78
+ mass_number = process_line(lines[2], int)
79
+ relative_atomic_mass = process_line(lines[3], fmt_float)
80
+ try:
81
+ isotopic_composition = process_line(lines[4], fmt_float)
82
+ except ValueError:
83
+ isotopic_composition = 0
84
+ try:
85
+ standard_atomic_weight = process_line(lines[5], fmt_maybe_list)
86
+ except ValueError:
87
+ standard_atomic_weight = relative_atomic_mass
88
+
89
+ return atomic_number, mass_number, (relative_atomic_mass, isotopic_composition, standard_atomic_weight)
90
+
91
+
92
+ def read_nist_isotopes() -> dict[int, dict[int, tuple[float, float, float]]]:
93
+ """
94
+ Read the NIST data file and write it to a JSON file.
95
+
96
+ {Atomic Number: {Mass Number, (Relative Atomic Mass, Isotopic Composition, Standard Atomic Weight)}}
97
+ """
98
+ with data_dir.joinpath("nist_isotopes.txt").open() as f:
99
+ next(f), next(f) # Skip the first two lines
100
+ nist_isotopes = f.read()
101
+
102
+ isotopes: dict[int, dict[int, tuple[float, float, float]]] = defaultdict(dict)
103
+ for chunk in nist_isotopes.split("\n\n"):
104
+ atomic_number, mass_number, values = process_chunk(chunk)
105
+ isotopes[atomic_number][mass_number] = values
106
+
107
+ with open("nist_isotopes.json", "w") as f:
108
+ json.dump(isotopes, f)
109
+
110
+ return isotopes
111
+
112
+
113
+ if __name__ == "__main__":
114
+ from pprint import pprint
115
+
116
+ pprint(read_nist_isotopes())
@@ -29,10 +29,43 @@ class Method(LowercaseStrEnum):
29
29
 
30
30
  AIMNET2_WB97MD3 = "aimnet2_wb97md3"
31
31
 
32
+ GFN_FF = "gfn_ff"
32
33
  GFN0_XTB = "gfn0_xtb"
33
34
  GFN1_XTB = "gfn1_xtb"
34
35
  GFN2_XTB = "gfn2_xtb"
35
- GFN_FF = "gfn_ff"
36
36
 
37
37
  # this was going to be removed, but Jonathon wrote such a nice basis set test... it's off the front end.
38
38
  BP86 = "bp86"
39
+
40
+
41
+ MLFF = [
42
+ Method.AIMNET2_WB97MD3,
43
+ ]
44
+
45
+ XTB_METHODS = [
46
+ Method.GFN_FF,
47
+ Method.GFN0_XTB,
48
+ Method.GFN1_XTB,
49
+ Method.GFN2_XTB,
50
+ ]
51
+
52
+ COMPOSITE_METHODS = [
53
+ Method.HF3C,
54
+ Method.B973C,
55
+ Method.R2SCAN3C,
56
+ Method.WB97X3C,
57
+ ]
58
+
59
+ PREPACKAGED_METHODS = [
60
+ *MLFF,
61
+ *XTB_METHODS,
62
+ *COMPOSITE_METHODS,
63
+ ]
64
+
65
+ METHODS_WITH_CORRECTION = [
66
+ Method.WB97XD3,
67
+ Method.WB97XV,
68
+ Method.WB97MV,
69
+ Method.WB97MD3BJ,
70
+ Method.DSDBLYPD3BJ,
71
+ ]
@@ -0,0 +1,192 @@
1
+ from pathlib import Path
2
+ from typing import Iterable, Optional, Self
3
+
4
+ import pydantic
5
+ from pydantic import NonNegativeInt, PositiveInt
6
+
7
+ from .atom import Atom
8
+ from .base import Base
9
+ from .periodic_cell import PeriodicCell
10
+ from .types import Matrix3x3, Vector3D, Vector3DPerAtom
11
+
12
+
13
+ class MoleculeReadError(RuntimeError):
14
+ pass
15
+
16
+
17
+ class VibrationalMode(Base):
18
+ frequency: float # in cm-1
19
+ reduced_mass: float # amu
20
+
21
+ # todo - check units here?
22
+ force_constant: float
23
+ displacements: Vector3DPerAtom
24
+
25
+
26
+ class Molecule(Base):
27
+ charge: int
28
+ multiplicity: PositiveInt
29
+ atoms: list[Atom]
30
+
31
+ # for periodic boundary conditions
32
+ cell: Optional[PeriodicCell] = None
33
+
34
+ energy: Optional[float] = None # in Hartree
35
+ scf_iterations: Optional[NonNegativeInt] = None
36
+ scf_completed: Optional[bool] = None
37
+ elapsed: Optional[float] = None # in seconds
38
+
39
+ homo_lumo_gap: Optional[float] = None # in eV
40
+
41
+ gradient: Optional[Vector3DPerAtom] = None # Hartree/Å
42
+ stress: Optional[Matrix3x3] = None # Hartree/Å
43
+
44
+ velocities: Optional[Vector3DPerAtom] = None # Å/fs
45
+
46
+ mulliken_charges: Optional[list[float]] = None
47
+ mulliken_spin_densities: Optional[list[float]] = None
48
+ dipole: Optional[Vector3D] = None # in Debye
49
+
50
+ vibrational_modes: Optional[list[VibrationalMode]] = None
51
+
52
+ zero_point_energy: Optional[float] = None
53
+ thermal_energy_corr: Optional[float] = None
54
+ thermal_enthalpy_corr: Optional[float] = None
55
+ thermal_free_energy_corr: Optional[float] = None
56
+
57
+ def __len__(self) -> int:
58
+ return len(self.atoms)
59
+
60
+ def distance(self, atom1: PositiveInt, atom2: PositiveInt) -> float:
61
+ r"""
62
+ Get the distance between atoms.
63
+
64
+ >>> mol = Molecule.from_xyz("H 0 1 0\nH 0 0 1")
65
+ >>> mol.distance(1, 2)
66
+ 1.4142135623730951
67
+ """
68
+ return sum((q2 - q1) ** 2 for q1, q2 in zip(self.atoms[atom1 - 1].position, self.atoms[atom2 - 1].position)) ** 0.5 # type: ignore [no-any-return,unused-ignore]
69
+
70
+ @property
71
+ def coordinates(self) -> Vector3DPerAtom:
72
+ return [a.position for a in self.atoms]
73
+
74
+ @property
75
+ def atomic_numbers(self) -> list[NonNegativeInt]:
76
+ return [a.atomic_number for a in self.atoms]
77
+
78
+ @property
79
+ def sum_energy_zpe(self) -> Optional[float]:
80
+ if (self.energy is None) or (self.zero_point_energy is None):
81
+ return None
82
+ return self.energy + self.zero_point_energy
83
+
84
+ @property
85
+ def sum_energy_thermal_corr(self) -> Optional[float]:
86
+ if (self.energy is None) or (self.thermal_energy_corr is None):
87
+ return None
88
+ return self.energy + self.thermal_energy_corr
89
+
90
+ @property
91
+ def sum_energy_enthalpy(self) -> Optional[float]:
92
+ if (self.energy is None) or (self.thermal_enthalpy_corr is None):
93
+ return None
94
+ return self.energy + self.thermal_enthalpy_corr
95
+
96
+ @property
97
+ def sum_energy_free_energy(self) -> Optional[float]:
98
+ if (self.energy is None) or (self.thermal_free_energy_corr is None):
99
+ return None
100
+ return self.energy + self.thermal_free_energy_corr
101
+
102
+ @pydantic.model_validator(mode="after")
103
+ def check_electron_sanity(self) -> Self:
104
+ num_electrons = sum(self.atomic_numbers) - self.charge
105
+ num_unpaired_electrons = self.multiplicity - 1
106
+ if (num_electrons - num_unpaired_electrons) % 2 != 0:
107
+ raise ValueError(
108
+ f"The combination of {num_electrons} electrons, charge {self.charge}, and multiplicity {self.multiplicity} is impossible. "
109
+ "Double-check the charge and multiplicity values given and verify that they are correct."
110
+ )
111
+
112
+ return self
113
+
114
+ @classmethod
115
+ def from_file(cls: type[Self], filename: Path | str, format: str | None = None, charge: int = 0, multiplicity: PositiveInt = 1) -> Self:
116
+ r"""
117
+ Read a molecule from a file.
118
+
119
+ >>> import tempfile
120
+ >>> with tempfile.NamedTemporaryFile("w+", suffix=".xyz") as f:
121
+ ... _ = f.write("2\nComment\nH 0 0 0\nF 0 0 1")
122
+ ... _ = f.seek(0)
123
+ ... mol = Molecule.from_file(f.name)
124
+ >>> print(mol.to_xyz())
125
+ 2
126
+ <BLANKLINE>
127
+ H 0.0000000000 0.0000000000 0.0000000000
128
+ F 0.0000000000 0.0000000000 1.0000000000
129
+ """
130
+ filename = Path(filename)
131
+ if not format:
132
+ format = filename.suffix[1:]
133
+
134
+ with open(filename) as f:
135
+ match format:
136
+ case "xyz":
137
+ return cls.from_xyz_lines(f.readlines(), charge=charge, multiplicity=multiplicity)
138
+ case _:
139
+ raise ValueError(f"Unsupported {format=}")
140
+
141
+ @classmethod
142
+ def from_xyz(cls: type[Self], xyz: str, charge: int = 0, multiplicity: PositiveInt = 1) -> Self:
143
+ r"""
144
+ Generate a Molecule from an XYZ string.
145
+
146
+ Note: only supports single molecule inputs.
147
+
148
+ >>> len(Molecule.from_xyz("2\nComment\nH 0 0 0\nH 0 0 1"))
149
+ 2
150
+ """
151
+ return cls.from_xyz_lines(xyz.strip().splitlines(), charge=charge, multiplicity=multiplicity)
152
+
153
+ @classmethod
154
+ def from_xyz_lines(cls: type[Self], lines: Iterable[str], charge: int = 0, multiplicity: PositiveInt = 1) -> Self:
155
+ lines = list(lines)
156
+ if len(lines[0].split()) == 1:
157
+ natoms = lines[0].strip()
158
+ if not natoms.isdigit() or (int(lines[0]) != len(lines) - 2):
159
+ raise MoleculeReadError(f"First line of XYZ file should be the number of atoms, got: {lines[0]} != {len(lines) - 2}")
160
+ lines = lines[2:]
161
+
162
+ try:
163
+ return cls(atoms=[Atom.from_xyz(line) for line in lines], charge=charge, multiplicity=multiplicity)
164
+ except Exception as e:
165
+ raise MoleculeReadError("Error reading molecule from xyz") from e
166
+
167
+ def to_xyz(self, comment: str = "", out_file: Path | str | None = None) -> str:
168
+ r"""
169
+ Generate an XYZ string.
170
+
171
+ >>> mol = Molecule.from_xyz("2\nComment\nH 0 1 2\nF 1 2 3")
172
+ >>> print(mol.to_xyz(comment="HF"))
173
+ 2
174
+ HF
175
+ H 0.0000000000 1.0000000000 2.0000000000
176
+ F 1.0000000000 2.0000000000 3.0000000000
177
+ >>> import tempfile
178
+ >>> with tempfile.TemporaryDirectory() as directory:
179
+ ... file = Path(directory) / "mol.xyz"
180
+ ... out = mol.to_xyz(comment="HF", out_file=file)
181
+ ... with file.open() as f:
182
+ ... Molecule.from_xyz(f.read()).to_xyz("HF") == out
183
+ True
184
+ """
185
+ geom = "\n".join(map(str, self.atoms))
186
+ out = f"{len(self)}\n{comment}\n{geom}"
187
+
188
+ if out_file:
189
+ with Path(out_file).open("w") as f:
190
+ f.write(out)
191
+
192
+ return out
@@ -0,0 +1,21 @@
1
+ from typing import Sequence
2
+
3
+ from pydantic import PositiveFloat, PositiveInt
4
+
5
+ from .base import Base
6
+ from .constraint import Constraint
7
+
8
+
9
+ class OptimizationSettings(Base):
10
+ max_steps: PositiveInt = 250
11
+ transition_state: bool = False
12
+
13
+ # when are we converged? (Hartree and Hartree/Å)
14
+ max_gradient_threshold: PositiveFloat = 7e-4
15
+ rms_gradient_threshold: PositiveFloat = 6e-4
16
+ energy_threshold: PositiveFloat = 1e-6
17
+
18
+ # for periodic systems only
19
+ optimize_cell: bool = False
20
+
21
+ constraints: Sequence[Constraint] = tuple()