qcdata 0.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qcdata/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ import importlib
2
+ import warnings
3
+ from importlib import metadata
4
+ from types import ModuleType
5
+ from typing import Any
6
+
7
+ from .models import * # noqa: F403
8
+ from .models.utils import to_multi_xyz
9
+ from .utils import json_dumps
10
+
11
+ __version__ = metadata.version(__name__)
12
+
13
+
14
+ __all__ = [ # noqa: F405
15
+ # Core Models
16
+ "CalcType",
17
+ "Model",
18
+ "Provenance",
19
+ "Structure",
20
+ "Molecule",
21
+ "Files",
22
+ "FileInput",
23
+ "ProgramInput",
24
+ "ProgramOutput",
25
+ "Results",
26
+ "DualProgramInput",
27
+ "SinglePointData",
28
+ "OptimizationData",
29
+ "ConformerSearchData",
30
+ "SinglePointResults",
31
+ "OptimizationResults",
32
+ "ConformerSearchResults",
33
+ "Wavefunction",
34
+ "ProgramArgs",
35
+ "ProgramArgsSub",
36
+ "json_dumps",
37
+ "to_multi_xyz",
38
+ "LengthUnit",
39
+ ]
40
+
41
+ # Map of deprecated symbols → new fully-qualified path
42
+ _DEPRECATED: dict[str, str] = {
43
+ "rmsd": "qcinf.rmsd",
44
+ "align": "qcinf.align",
45
+ }
46
+
47
+
48
+ def __getattr__(name: str) -> Any: # PEP 562
49
+ """Redirect old qcdata symbols to qcinf (with helpful message)."""
50
+ if name not in _DEPRECATED:
51
+ raise AttributeError(f"module 'qcdata' has no attribute '{name}'")
52
+
53
+ new_path = _DEPRECATED[name]
54
+ mod_path, func_name = new_path.rsplit(".", 1)
55
+
56
+ try:
57
+ module: ModuleType = importlib.import_module(mod_path)
58
+ except ModuleNotFoundError as exc:
59
+ raise ModuleNotFoundError(
60
+ f"`qcinf` is not installed, so `qcdata.{name}` can no longer be "
61
+ "used. Install the dependency with either:\n\n"
62
+ " python -m pip install qcinf\n"
63
+ "or\n"
64
+ " python -m pip install 'qcinf[all]'\n\n"
65
+ "and then import the new function location:\n\n"
66
+ f" from {mod_path} import {func_name}",
67
+ ) from exc
68
+
69
+ else:
70
+ # Emit a FutureWarning so it's shown by default
71
+ warnings.warn(
72
+ f"`qcdata.{name}` has moved to `{new_path}`.\n"
73
+ "Please update your import to:\n\n"
74
+ f" from qcinf import {name}",
75
+ FutureWarning,
76
+ stacklevel=2,
77
+ )
78
+ return getattr(module, func_name)
qcdata/helper_types.py ADDED
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import numpy as np
5
+ from pydantic import BeforeValidator, GetPydanticSchema, PlainSerializer, SkipValidation
6
+ from pydantic_core import core_schema
7
+
8
+ StrOrPath = Annotated[str | Path, PlainSerializer(lambda x: str(x))]
9
+
10
+ # Create the annotated type for numpy array
11
+ SerializableNDArray = Annotated[
12
+ SkipValidation[np.ndarray],
13
+ # Coerce values to float64 numpy array. Not required. Creates consistency.
14
+ BeforeValidator(lambda x: np.array(x, dtype=np.float64)),
15
+ # Serializes the numpy array to a list
16
+ PlainSerializer(lambda x: np.array(x).tolist()),
17
+ # Describe json schema as a list of floats (numbers). Required for ChemCloud docs.
18
+ GetPydanticSchema(
19
+ lambda tp, handler: core_schema.with_default_schema(handler(list[float]))
20
+ ),
21
+ ]
@@ -0,0 +1,5 @@
1
+ from .base_models import * # noqa: F403
2
+ from .data import * # noqa: F403
3
+ from .inputs import * # noqa: F403
4
+ from .outputs import * # noqa: F403
5
+ from .structure import * # noqa: F403
@@ -0,0 +1,402 @@
1
+ """The base model from which all QCData model objects inherit."""
2
+
3
+ import json
4
+ from abc import ABC
5
+ from base64 import b64decode, b64encode
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import numpy as np
11
+ import toml
12
+ import yaml
13
+ from pydantic import BaseModel, field_serializer, field_validator
14
+ from typing_extensions import Self
15
+
16
+ from ..helper_types import StrOrPath
17
+
18
+ if TYPE_CHECKING: # pragma: no cover
19
+ from pydantic.typing import ReprArgs
20
+
21
+
22
+ __all__ = ["Files", "Provenance", "Model", "CalcType", "LengthUnit"]
23
+
24
+
25
+ class LengthUnit(str, Enum):
26
+ """The unit of length.
27
+
28
+ Attributes:
29
+ BOHR (str): Bohr.
30
+ ANGSTROM (str): Angstroms.
31
+
32
+ """
33
+
34
+ BOHR = "bohr"
35
+ ANGSTROM = "angstrom"
36
+
37
+ def __repr__(self) -> str:
38
+ """Custom repr for LengthUnit"""
39
+ return f"'{self.name}'" # pragma: no cover
40
+
41
+
42
+ class QCDataBaseModel(BaseModel, ABC):
43
+ """Base model for all QCData objects.
44
+
45
+ Attributes:
46
+ version: The version of the schema.
47
+ extras: Additional information to bundle with the object. Use for schema
48
+ development and scratch space.
49
+ """
50
+
51
+ # NOTE: No version for now until we have a stable schema
52
+ # Or maybe have version be version of qcdata that generated the object?
53
+ # version: ClassVar[Literal["v1"]] = "v1"
54
+ extras: dict[str, Any] = {}
55
+
56
+ model_config = {
57
+ # Raises an error if extra fields are passed to model.
58
+ "extra": "forbid",
59
+ # Allow numpy types in models. Pydantic will no longer raise an exception for
60
+ # types it doesn't recognize.
61
+ # https://docs.pydantic.dev/latest/usage/types/#arbitrary-types-allowed
62
+ "arbitrary_types_allowed": True,
63
+ # Don't allow mutation of objects
64
+ # https://docs.pydantic.dev/2.3/api/config/#pydantic.config.ConfigDict.frozen
65
+ "frozen": True,
66
+ }
67
+
68
+ @classmethod
69
+ def open(cls, filepath: Path | str) -> Self:
70
+ """Instantiate an object from data saved to disk.
71
+
72
+ Args:
73
+ filepath: The path to the object on disk.
74
+
75
+ Returns:
76
+ The instantiated object.
77
+
78
+ Example:
79
+ ```python
80
+ my_obj = MyModel.open("path/to/file.json")
81
+ ```
82
+ """
83
+ filepath = Path(filepath)
84
+ data = filepath.read_text()
85
+
86
+ if filepath.suffix in [".yaml", ".yml"]:
87
+ return cls.model_validate(yaml.safe_load(data))
88
+ elif filepath.suffix == ".toml":
89
+ return cls.model_validate(toml.loads(data))
90
+
91
+ # Assume json for all other file extensions
92
+ return cls.model_validate_json(data)
93
+
94
+ @classmethod
95
+ def open_multi(cls, filepath: Path | str) -> list[Self]:
96
+ """Instantiate a list of objects from data saved to disk.
97
+
98
+ Args:
99
+ filepath: The path to the object on disk. Must be a list of objects.
100
+
101
+ Returns:
102
+ A list of instantiated objects.
103
+
104
+ Example:
105
+ ```python
106
+ my_objs = MyModel().open_multi("path/to/file.json")
107
+ ```
108
+ """
109
+ filepath = Path(filepath)
110
+ data = filepath.read_text()
111
+
112
+ if filepath.suffix in [".yaml", ".yml"]:
113
+ return [cls.model_validate(d) for d in yaml.safe_load(data)]
114
+ elif filepath.suffix == ".toml":
115
+ return [cls.model_validate(d) for d in toml.loads(data)]
116
+
117
+ # Assume json for all other file extensions
118
+ return [cls.model_validate(d) for d in json.loads(data)]
119
+
120
+ def save(
121
+ self,
122
+ filepath: Path | str,
123
+ exclude_none: bool = True,
124
+ exclude_unset: bool = True,
125
+ indent: int = 4,
126
+ **kwargs,
127
+ ) -> None:
128
+ """
129
+ Save an object to disk as `json`, `yaml`, or `toml`. Objects such as `Structure`
130
+ and `OptimizationResults` can additionally be saved as `xyz` files.
131
+
132
+ Note:
133
+ By default the object will be saved as a `json` file. If the file extension
134
+ is `.yaml` or `.yml`, the object will be saved as a `yaml` file. If the file
135
+ extension is `.toml`, the object will be saved as a `toml` file. If the file
136
+ extension is `.xyz`, the object will be saved as an `xyz` file (for objects
137
+ that support this format such as a `Structure` or an `OptimizationResults`
138
+ which contains `.trajectory: list[Structure]`).
139
+
140
+ Additionally, padding will be added to the file by default to make it more
141
+ human-readable. You can adjust the amount of padding added by changing the
142
+ `indent` parameter. Pass `indent=None` to create a more compact file.
143
+
144
+ Args:
145
+ filepath: The path to write the object to.
146
+ exclude_none: If True, attributes with a value of None will not be written.
147
+ Changing default behavior from pydantic.model_dump() to True.
148
+ exclude_unset: If True, attributes that have not been set will not be
149
+ written (i.e., values set to their default value).
150
+ indent: The number of spaces to use for indentation in the JSON file. 0
151
+ creates a more compact JSON file, 4 is more human-readable.
152
+ **kwargs: Additional keyword arguments to pass to the serialization method.
153
+
154
+ Example:
155
+ ```python
156
+ my_obj.save("path/to/file.json")
157
+ ```
158
+
159
+ ```python
160
+ my_obj.save("path/to/file.yaml")
161
+ ```
162
+
163
+ ```python
164
+ my_obj.save("path/to/file.toml")
165
+ ```
166
+
167
+ ```python
168
+ my_obj.save("path/to/file.xyz")
169
+ ```
170
+ """
171
+ filepath = Path(filepath)
172
+ filepath.parent.mkdir(exist_ok=True, parents=True)
173
+
174
+ if self.extras:
175
+ # Ensure pydantic knows the field has been set
176
+ self.__pydantic_fields_set__.add("extras")
177
+
178
+ model_dict = self.model_dump(
179
+ mode="json",
180
+ exclude_none=exclude_none,
181
+ exclude_unset=exclude_unset,
182
+ **kwargs,
183
+ )
184
+
185
+ if filepath.suffix in [".yaml", ".yml"]:
186
+ data = yaml.dump(model_dict, indent=indent)
187
+
188
+ elif filepath.suffix == ".toml":
189
+ data = toml.dumps(model_dict)
190
+
191
+ else:
192
+ # Write data to json regardless of file extension
193
+ data = json.dumps(model_dict, indent=indent)
194
+
195
+ filepath.write_text(data)
196
+
197
+ def __repr_args__(self) -> "ReprArgs":
198
+ """Only show non empty fields in repr but always show success even if false."""
199
+
200
+ def exists(value):
201
+ if isinstance(value, np.ndarray):
202
+ return value.size > 0
203
+ return bool(value)
204
+
205
+ return [ # pragma: no cover
206
+ (name, value)
207
+ for name, value in self.__dict__.items()
208
+ if name == "success" or exists(value)
209
+ ]
210
+
211
+ def __eq__(self, other: Any) -> bool:
212
+ """Check equality of two objects.
213
+
214
+ Necessary because BaseModel.__eq__ does not compare numpy arrays.
215
+ """
216
+ if isinstance(other, self.__class__):
217
+ return self.model_dump() == other.model_dump()
218
+ return False
219
+
220
+
221
+ class Files(QCDataBaseModel):
222
+ """File model for handling string and binary data.
223
+
224
+ Binary data is encoded as base64 strings during serialization.
225
+
226
+ Attributes:
227
+ files: A dict mapping filename to str or bytes data.
228
+ """
229
+
230
+ files: dict[str, str | bytes] = {}
231
+
232
+ @field_validator("files")
233
+ def _convert_base64_to_bytes(cls, value):
234
+ """Convert base64 encoded data to bytes."""
235
+ for filename, data in value.items():
236
+ if isinstance(data, str) and data.startswith("base64:"):
237
+ value[filename] = b64decode(data[7:])
238
+ return value
239
+
240
+ @field_serializer("files")
241
+ def _serialize_files(self, files, _info) -> dict[str, str]:
242
+ """Serialize files to a dict of filename to base64 encoded string."""
243
+ return {
244
+ filename: (
245
+ f"base64:{b64encode(data).decode('utf-8')}"
246
+ if isinstance(data, bytes)
247
+ else data
248
+ )
249
+ for filename, data in files.items()
250
+ }
251
+
252
+ def add_file(
253
+ self, filepath: Path | str, relative_dir: Path | None = None
254
+ ) -> None:
255
+ """Add a file to the object. The file will be added at to the `files` attribute
256
+ with the filename as the key and the file data as the value.
257
+
258
+ Args:
259
+ filepath: The path to the file.
260
+ relative_dir: The directory to make the file relative to. Helpful when
261
+ adding files from a subdirectory.
262
+
263
+ Example:
264
+ ```python
265
+ my_obj.add_file("path/to/file.txt")
266
+ print(my_obj.files)
267
+ # Output: {"file.txt": "file data"}
268
+ ```
269
+ """
270
+ filepath = Path(filepath)
271
+ raw_bytes = filepath.read_bytes()
272
+ try:
273
+ data: str | bytes = raw_bytes.decode("utf-8") # str
274
+ except UnicodeDecodeError:
275
+ data = raw_bytes # bytes
276
+
277
+ # Set filename relative to relative_dir
278
+ if relative_dir:
279
+ filename = str(filepath.relative_to(relative_dir))
280
+ else:
281
+ filename = filepath.name
282
+
283
+ self.files[filename] = data
284
+ # Add files to __pydantic_fields_set__ to ensure they are included in .save()
285
+ self.__pydantic_fields_set__.add("files")
286
+
287
+ def add_files(
288
+ self,
289
+ directory: StrOrPath,
290
+ recursive: bool = False,
291
+ exclude: list[str] | None = None,
292
+ ) -> None:
293
+ """Add all files in a directory to the object.
294
+
295
+ Args:
296
+ directory: The directory to add files from.
297
+ recursive: Whether to recursively add files from subdirectories.
298
+ exclude: A list of filenames to exclude from the directory.
299
+ """
300
+ directory = Path(directory)
301
+ if exclude is None:
302
+ exclude = []
303
+ if recursive:
304
+ files = directory.rglob("*")
305
+ else:
306
+ files = directory.glob("*")
307
+ for filepath in files:
308
+ if filepath.is_file() and filepath.name not in exclude:
309
+ self.add_file(filepath, directory)
310
+
311
+ def save_files(self, directory: StrOrPath = Path(".")) -> None:
312
+ """Write all files to the specified directory"""
313
+ directory = Path(directory)
314
+ directory.mkdir(exist_ok=True)
315
+ for filename, data in self.files.items():
316
+ mode = "w" if isinstance(data, str) else "wb"
317
+ filepath = directory / filename
318
+ # In case filename is a relative path, create the parent directories
319
+ filepath.parent.mkdir(exist_ok=True, parents=True)
320
+ filepath.open(mode).write(data)
321
+
322
+ def __repr_args__(self) -> "ReprArgs":
323
+ """Replace file data with '<bytes>' or '<str>' in __repr__."""
324
+ rargs = super().__repr_args__()
325
+ return [
326
+ (
327
+ (k, v)
328
+ if k != "files"
329
+ else (
330
+ k,
331
+ {
332
+ name: bytes if isinstance(data, bytes) else str
333
+ for name, data in v.items()
334
+ },
335
+ )
336
+ )
337
+ for k, v in rargs
338
+ ]
339
+
340
+
341
+ class Provenance(QCDataBaseModel):
342
+ """Provenance information for a QC program.
343
+
344
+ Attributes:
345
+ program: The name of the program that created the output.
346
+ version: The version of the program that created the output.
347
+ scratch_dir: The working directory used by the program.
348
+ wall_time: The wall time used by the program.
349
+ hostname: The hostname of the machine the program was run on.
350
+ hostcpus: The number of logical CPUs on the host machine where the program ran.
351
+ hostmem: The amount of memory on the host machine where the program ran in GiB.
352
+ extras: Additional information to bundle with the object. Use for schema
353
+ development and scratch space.
354
+
355
+ """
356
+
357
+ program: str
358
+ program_version: str | None = None
359
+ scratch_dir: Path | None = None
360
+ wall_time: float | None = None
361
+ hostname: str | None = None
362
+ hostcpus: int | None = None
363
+ hostmem: int | None = None
364
+
365
+
366
+ class CalcType(str, Enum):
367
+ """The Calculation type.
368
+
369
+ Attributes:
370
+ energy: Energy calculation.
371
+ gradient: Gradient calculation.
372
+ hessian: Hessian calculation.
373
+ optimization: Geometry optimization (to a minima).
374
+ transition_state: Transition state optimization (to a saddle point).
375
+ conformer_search: Conformer search.
376
+ """
377
+
378
+ energy = "energy"
379
+ gradient = "gradient"
380
+ hessian = "hessian"
381
+ optimization = "optimization"
382
+ transition_state = "transition_state"
383
+ conformer_search = "conformer_search"
384
+
385
+ def __repr__(self) -> str:
386
+ """Custom repr for CalcType"""
387
+ return f"'{self.name}'" # pragma: no cover
388
+
389
+
390
+ class Model(QCDataBaseModel):
391
+ """The model for the quantum chemistry calculation.
392
+
393
+ Attributes:
394
+ method: The name of the method to be used in the calculation. Named according to
395
+ the convention of the program being called. If an MM calculation then the
396
+ name of the force field.
397
+ basis: The name of the basis set to be used in the calculation. Named according
398
+ to the convention of the program being called.
399
+ """
400
+
401
+ method: str
402
+ basis: str | None = None