aiida-chemshell 0.3.0__tar.gz → 0.3.2__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.
- {aiida_chemshell-0.3.0/src/aiida_chemshell.egg-info → aiida_chemshell-0.3.2}/PKG-INFO +2 -1
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/pyproject.toml +2 -2
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/calculations/base.py +122 -21
- aiida_chemshell-0.3.2/src/aiida_chemshell/calculations/utils.py +307 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/parsers/base.py +95 -26
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/units.py +2 -2
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/utils.py +58 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/workflows/batch_calculation.py +23 -157
- aiida_chemshell-0.3.2/src/aiida_chemshell/workflows/isolated_atoms.py +106 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/workflows/optimisation.py +32 -128
- aiida_chemshell-0.3.2/src/aiida_chemshell/workflows/utils.py +34 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2/src/aiida_chemshell.egg-info}/PKG-INFO +2 -1
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/SOURCES.txt +1 -3
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/requires.txt +1 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/tests/test_batch_workcahin.py +28 -1
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/tests/test_calculations.py +18 -5
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/tests/test_inputs.py +109 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/tests/test_isolated_atoms_workchain.py +65 -6
- aiida_chemshell-0.3.2/tests/test_workflows.py +95 -0
- aiida_chemshell-0.3.0/src/aiida_chemshell/calculations/file_conversion.py +0 -206
- aiida_chemshell-0.3.0/src/aiida_chemshell/calculations/solvant_removal.py +0 -115
- aiida_chemshell-0.3.0/src/aiida_chemshell/calculations/utils.py +0 -14
- aiida_chemshell-0.3.0/src/aiida_chemshell/parsers/file_conversion.py +0 -54
- aiida_chemshell-0.3.0/src/aiida_chemshell/workflows/isolated_atoms.py +0 -139
- aiida_chemshell-0.3.0/tests/test_workflows.py +0 -37
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/LICENSE +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/README.md +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/setup.cfg +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/__init__.py +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/calculations/__init__.py +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/parsers/__init__.py +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/periodic_table.py +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/workflows/__init__.py +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/dependency_links.txt +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/entry_points.txt +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/top_level.txt +0 -0
- {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/tests/test_error_codes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiida-chemshell
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: AiiDA workflow plugin for the ChemShell chemical modelling software package
|
|
5
5
|
Author-email: "Dr. Benjamin T. Speake" <benjamin.speake@stfc.ac.uk>
|
|
6
6
|
License: BSD 3-Clause License
|
|
@@ -42,6 +42,7 @@ Requires-Python: >=3.10
|
|
|
42
42
|
Description-Content-Type: text/markdown
|
|
43
43
|
License-File: LICENSE
|
|
44
44
|
Requires-Dist: aiida-core>=2.5
|
|
45
|
+
Requires-Dist: ase>=3.29
|
|
45
46
|
Provides-Extra: dev
|
|
46
47
|
Requires-Dist: aiida-core>=2.8; extra == "dev"
|
|
47
48
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
@@ -3,13 +3,13 @@ requires = ["setuptools"]
|
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
|
-
version = "0.3.
|
|
6
|
+
version = "0.3.2"
|
|
7
7
|
name = "aiida-chemshell"
|
|
8
8
|
description = "AiiDA workflow plugin for the ChemShell chemical modelling software package"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "Dr. Benjamin T. Speake", email = "benjamin.speake@stfc.ac.uk" },
|
|
11
11
|
]
|
|
12
|
-
dependencies = ["aiida-core >= 2.5"]
|
|
12
|
+
dependencies = ["aiida-core >= 2.5", "ase>=3.29"]
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
license = { file = "LICENSE" }
|
|
15
15
|
keywords = ["aiida", "workflows", "chemshell"]
|
|
@@ -36,6 +36,47 @@ class ChemShellCalculation(CalcJob):
|
|
|
36
36
|
FILE_TRJPTH = "path.xyz"
|
|
37
37
|
FILE_TRJFRC = "path_force.xyz"
|
|
38
38
|
|
|
39
|
+
# Default labels and descriptions applied to input nodes that do not already
|
|
40
|
+
# have them set, keyed by the input port name.
|
|
41
|
+
DEFAULT_INPUT_TAGS = {
|
|
42
|
+
"structure": (
|
|
43
|
+
"Input Chemical Structure",
|
|
44
|
+
"The input structure for the ChemShell calculation.",
|
|
45
|
+
),
|
|
46
|
+
"structure_index": (
|
|
47
|
+
"Structure Input Index",
|
|
48
|
+
"Index used to select a structure from a multi-structure input.",
|
|
49
|
+
),
|
|
50
|
+
"structure2": (
|
|
51
|
+
"Secondary Input Structure",
|
|
52
|
+
"The additional (final) input structure for the ChemShell calculation.",
|
|
53
|
+
),
|
|
54
|
+
"calculation_parameters": (
|
|
55
|
+
"ChemShell Calculation Parameters",
|
|
56
|
+
"Base parameters for the ChemShell Task object.",
|
|
57
|
+
),
|
|
58
|
+
"optimisation_parameters": (
|
|
59
|
+
"ChemShell Optimisation Parameters",
|
|
60
|
+
"Parameters for the ChemShell geometry optimisation task.",
|
|
61
|
+
),
|
|
62
|
+
"qm_parameters": (
|
|
63
|
+
"ChemShell QM Parameters",
|
|
64
|
+
"Parameters for the ChemShell QM Theory object.",
|
|
65
|
+
),
|
|
66
|
+
"mm_parameters": (
|
|
67
|
+
"ChemShell MM Parameters",
|
|
68
|
+
"Parameters for the ChemShell MM interface.",
|
|
69
|
+
),
|
|
70
|
+
"force_field_file": (
|
|
71
|
+
"ChemShell Force Field File",
|
|
72
|
+
"Force field parameters for the ChemShell MM interface.",
|
|
73
|
+
),
|
|
74
|
+
"qmmm_parameters": (
|
|
75
|
+
"ChemShell QM/MM Parameters",
|
|
76
|
+
"Parameters for the ChemShell QM/MM interface.",
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
|
|
39
80
|
@classmethod
|
|
40
81
|
def define(cls, spec: CalcJobProcessSpec) -> None:
|
|
41
82
|
"""
|
|
@@ -136,6 +177,17 @@ class ChemShellCalculation(CalcJob):
|
|
|
136
177
|
required=False,
|
|
137
178
|
help="A dictionary of parameters for the ChemShell QM/MM interface.",
|
|
138
179
|
)
|
|
180
|
+
spec.input(
|
|
181
|
+
"output_structure_as_file",
|
|
182
|
+
valid_type=bool,
|
|
183
|
+
non_db=True,
|
|
184
|
+
default=False,
|
|
185
|
+
help=(
|
|
186
|
+
"If True, the optimised structure is returned as a SinglefileData "
|
|
187
|
+
"node containing a ChemShell '.cjson' file. By default (False) it is "
|
|
188
|
+
"returned as an AiiDA StructureData node."
|
|
189
|
+
),
|
|
190
|
+
)
|
|
139
191
|
|
|
140
192
|
## Calculation outputs
|
|
141
193
|
spec.output(
|
|
@@ -156,12 +208,14 @@ class ChemShellCalculation(CalcJob):
|
|
|
156
208
|
)
|
|
157
209
|
spec.output(
|
|
158
210
|
"optimised_structure",
|
|
159
|
-
valid_type=SinglefileData,
|
|
211
|
+
valid_type=(StructureData, SinglefileData),
|
|
160
212
|
required=False,
|
|
161
213
|
help=(
|
|
162
214
|
"The optimised structure of the given system, if a geometry "
|
|
163
|
-
"optimisation task was configured and successfully completed.
|
|
164
|
-
"
|
|
215
|
+
"optimisation task was configured and successfully completed. By "
|
|
216
|
+
"default this is an AiiDA StructureData node; if the "
|
|
217
|
+
"'output_structure_as_file' input is True it is instead a "
|
|
218
|
+
"SinglefileData node containing a ChemShell '.cjson' file."
|
|
165
219
|
),
|
|
166
220
|
)
|
|
167
221
|
spec.output(
|
|
@@ -182,7 +236,10 @@ class ChemShellCalculation(CalcJob):
|
|
|
182
236
|
"vibrational_modes",
|
|
183
237
|
valid_type=ArrayData,
|
|
184
238
|
required=False,
|
|
185
|
-
help=
|
|
239
|
+
help=(
|
|
240
|
+
"The calculated vibrational modes of the system. A negative "
|
|
241
|
+
"frequency indicates an imaginary frequency."
|
|
242
|
+
),
|
|
186
243
|
)
|
|
187
244
|
|
|
188
245
|
# Validate inputs namespace
|
|
@@ -209,11 +266,11 @@ class ChemShellCalculation(CalcJob):
|
|
|
209
266
|
)
|
|
210
267
|
spec.output(
|
|
211
268
|
"trajectory_force",
|
|
212
|
-
valid_type=
|
|
269
|
+
valid_type=ArrayData,
|
|
213
270
|
required=False,
|
|
214
271
|
help=(
|
|
215
|
-
"
|
|
216
|
-
"
|
|
272
|
+
"Per-atom forces (natoms, 3) at each step of a geometry optimisation, "
|
|
273
|
+
"stored one array per frame labelled 'Frame {i}'."
|
|
217
274
|
),
|
|
218
275
|
)
|
|
219
276
|
|
|
@@ -324,13 +381,13 @@ class ChemShellCalculation(CalcJob):
|
|
|
324
381
|
return None
|
|
325
382
|
|
|
326
383
|
@classmethod
|
|
327
|
-
def get_valid_calculation_parameter_keys(cls) -> tuple[str]:
|
|
384
|
+
def get_valid_calculation_parameter_keys(cls) -> tuple[str, str]:
|
|
328
385
|
"""
|
|
329
386
|
Return the valid parameter keys for the ChemShell Single Point calculation.
|
|
330
387
|
|
|
331
388
|
Returns
|
|
332
389
|
-------
|
|
333
|
-
|
|
390
|
+
tuple[str, str]
|
|
334
391
|
A tuple of valid parameter keys for the ChemShell calculation.
|
|
335
392
|
"""
|
|
336
393
|
return ("gradients", "hessian")
|
|
@@ -379,7 +436,7 @@ class ChemShellCalculation(CalcJob):
|
|
|
379
436
|
|
|
380
437
|
Returns
|
|
381
438
|
-------
|
|
382
|
-
|
|
439
|
+
tuple[str]
|
|
383
440
|
A tuple of valid optimisation parameter keys for the ChemShell
|
|
384
441
|
calculation.
|
|
385
442
|
"""
|
|
@@ -437,12 +494,13 @@ class ChemShellCalculation(CalcJob):
|
|
|
437
494
|
@classmethod
|
|
438
495
|
def get_valid_qm_parameter_keys(cls) -> dict:
|
|
439
496
|
"""
|
|
440
|
-
Return a
|
|
497
|
+
Return a dictionary of valid parameter keys for the ChemShell calculation.
|
|
441
498
|
|
|
442
499
|
Returns
|
|
443
500
|
-------
|
|
444
|
-
|
|
445
|
-
A
|
|
501
|
+
dict[str, type]
|
|
502
|
+
A mapping of valid Theory parameter keys to their expected value
|
|
503
|
+
types for the ChemShell calculation.
|
|
446
504
|
"""
|
|
447
505
|
return {
|
|
448
506
|
"theory": str,
|
|
@@ -527,14 +585,21 @@ class ChemShellCalculation(CalcJob):
|
|
|
527
585
|
return None
|
|
528
586
|
|
|
529
587
|
@classmethod
|
|
530
|
-
def get_valid_mm_parameter_keys(cls, theory: str = "") -> dict[str
|
|
588
|
+
def get_valid_mm_parameter_keys(cls, theory: str = "") -> dict[str, type]:
|
|
531
589
|
"""
|
|
532
|
-
Return a
|
|
590
|
+
Return a dictionary of valid parameter keys for the ChemShell MM interface.
|
|
591
|
+
|
|
592
|
+
Parameters
|
|
593
|
+
----------
|
|
594
|
+
theory : str
|
|
595
|
+
The MM theory whose valid parameter keys should be returned (e.g.
|
|
596
|
+
'DL_POLY'). Defaults to an empty string.
|
|
533
597
|
|
|
534
598
|
Returns
|
|
535
599
|
-------
|
|
536
|
-
|
|
537
|
-
A
|
|
600
|
+
dict[str, type]
|
|
601
|
+
A mapping of valid MM parameter keys to their expected value types
|
|
602
|
+
for the ChemShell calculation.
|
|
538
603
|
"""
|
|
539
604
|
if theory == "DL_POLY":
|
|
540
605
|
valid_keys = {
|
|
@@ -754,6 +819,12 @@ class ChemShellCalculation(CalcJob):
|
|
|
754
819
|
Defines the process label to be associated with the created ProcessNode
|
|
755
820
|
stored in the AiiDA database.
|
|
756
821
|
|
|
822
|
+
Parameters
|
|
823
|
+
----------
|
|
824
|
+
node : ProcessNode
|
|
825
|
+
The process (or its ProcessNode) whose inputs are inspected to build
|
|
826
|
+
the label.
|
|
827
|
+
|
|
757
828
|
Returns
|
|
758
829
|
-------
|
|
759
830
|
str
|
|
@@ -771,6 +842,8 @@ class ChemShellCalculation(CalcJob):
|
|
|
771
842
|
if "optimisation_parameters" in node.inputs:
|
|
772
843
|
if node.inputs.optimisation_parameters.get("thermal", False):
|
|
773
844
|
return "ChemShell_Vibrational_Frequencies" + theory_key
|
|
845
|
+
if node.inputs.optimisation_parameters.get("neb", "no") != "no":
|
|
846
|
+
return "ChemShell_NEB" + theory_key
|
|
774
847
|
return "ChemShell_Geometry_Optimisation" + theory_key
|
|
775
848
|
|
|
776
849
|
return "ChemShell_Single_Point_Calculation" + theory_key
|
|
@@ -820,7 +893,6 @@ class ChemShellCalculation(CalcJob):
|
|
|
820
893
|
fname = self.inputs.structure2.filename
|
|
821
894
|
else:
|
|
822
895
|
fname = ChemShellCalculation.FILE_TMP_STRUCTURE2
|
|
823
|
-
print(fname)
|
|
824
896
|
script += f"frg2 = Fragment(coords='{fname:s}')\n"
|
|
825
897
|
|
|
826
898
|
## Setup Theory objects
|
|
@@ -933,12 +1005,31 @@ class ChemShellCalculation(CalcJob):
|
|
|
933
1005
|
|
|
934
1006
|
return script
|
|
935
1007
|
|
|
1008
|
+
def apply_default_input_tags(self) -> None:
|
|
1009
|
+
"""
|
|
1010
|
+
Apply default labels and descriptions to the calculation input nodes.
|
|
1011
|
+
|
|
1012
|
+
Iterates over the provided input nodes and, for any node defined in
|
|
1013
|
+
:attr:`DEFAULT_INPUT_TAGS` that does not already have a label or
|
|
1014
|
+
description set, assigns the corresponding default value. Existing
|
|
1015
|
+
labels and descriptions are left untouched.
|
|
1016
|
+
"""
|
|
1017
|
+
for port_name, (label, description) in self.DEFAULT_INPUT_TAGS.items():
|
|
1018
|
+
if port_name not in self.inputs:
|
|
1019
|
+
continue
|
|
1020
|
+
node = self.inputs[port_name]
|
|
1021
|
+
if not node.label:
|
|
1022
|
+
node.label = label
|
|
1023
|
+
if not node.description:
|
|
1024
|
+
node.description = description
|
|
1025
|
+
return
|
|
1026
|
+
|
|
936
1027
|
def prepare_for_submission(self, folder: Folder) -> CalcInfo:
|
|
937
1028
|
"""
|
|
938
1029
|
Prepare the ChemShell calculation for submission.
|
|
939
1030
|
|
|
940
|
-
|
|
941
|
-
|
|
1031
|
+
Parameters
|
|
1032
|
+
----------
|
|
942
1033
|
folder : Folder
|
|
943
1034
|
An `aiida.common.folders.Folder` specifying the temporary working
|
|
944
1035
|
directory for the calculation.
|
|
@@ -948,6 +1039,16 @@ class ChemShellCalculation(CalcJob):
|
|
|
948
1039
|
calcInfo : CalcInfo
|
|
949
1040
|
An `aiida.common.CalcInfo` instance.
|
|
950
1041
|
"""
|
|
1042
|
+
# Apply default labels/descriptions to input nodes lacking them
|
|
1043
|
+
self.apply_default_input_tags()
|
|
1044
|
+
|
|
1045
|
+
# Record the (non_db) output structure format choice as a node extra so
|
|
1046
|
+
# that the parser can determine whether to return the optimised structure
|
|
1047
|
+
# as a StructureData or SinglefileData node.
|
|
1048
|
+
self.node.base.extras.set(
|
|
1049
|
+
"output_structure_as_file", bool(self.inputs.output_structure_as_file)
|
|
1050
|
+
)
|
|
1051
|
+
|
|
951
1052
|
# Create the ChemShell input script
|
|
952
1053
|
input_script = self.chemsh_script_generator()
|
|
953
1054
|
with folder.open(ChemShellCalculation.FILE_SCRIPT, "w") as f:
|
|
@@ -1005,7 +1106,7 @@ class ChemShellCalculation(CalcJob):
|
|
|
1005
1106
|
# Copy data for second input fragment if required
|
|
1006
1107
|
if "structure2" in self.inputs:
|
|
1007
1108
|
if isinstance(self.inputs.structure2, StructureData):
|
|
1008
|
-
with folder.open(ChemShellCalculation.
|
|
1109
|
+
with folder.open(ChemShellCalculation.FILE_TMP_STRUCTURE2, "wb") as f:
|
|
1009
1110
|
f.write(self.inputs.structure2._prepare_xyz()[0])
|
|
1010
1111
|
else:
|
|
1011
1112
|
calc_info.local_copy_list.append(
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""A collection of smaller utility AiiDA CalcFunctions."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
|
|
6
|
+
from aiida.engine import calcfunction
|
|
7
|
+
from aiida.orm import (
|
|
8
|
+
ArrayData,
|
|
9
|
+
Dict,
|
|
10
|
+
Float,
|
|
11
|
+
SinglefileData,
|
|
12
|
+
StructureData,
|
|
13
|
+
TrajectoryData,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@calcfunction
|
|
18
|
+
def create_isolated_atom_structures(structure) -> dict[str, StructureData]:
|
|
19
|
+
"""
|
|
20
|
+
Create an isolated single-atom StructureData for each unique atom type.
|
|
21
|
+
|
|
22
|
+
The unique atom types present in ``structure`` are determined and, for each,
|
|
23
|
+
a non-periodic StructureData node containing a single atom at the origin is
|
|
24
|
+
created.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
structure : StructureData | SinglefileData
|
|
29
|
+
The input structure to extract isolated atomic species from. Either a
|
|
30
|
+
StructureData node or a SinglefileData node wrapping a structure file in
|
|
31
|
+
any format readable by ``ase.io.read``.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
dict[str, StructureData]
|
|
36
|
+
A mapping of atom type to its isolated single-atom StructureData node.
|
|
37
|
+
As a calcfunction return value, this is registered as a namespace of
|
|
38
|
+
output nodes, with the atom type used as the output link label.
|
|
39
|
+
|
|
40
|
+
Raises
|
|
41
|
+
------
|
|
42
|
+
Exception
|
|
43
|
+
If the structure file format is not supported by the ASE reader.
|
|
44
|
+
"""
|
|
45
|
+
unique_atoms = _determine_unique_atoms(structure)
|
|
46
|
+
structures = {}
|
|
47
|
+
for atom_symbol in unique_atoms:
|
|
48
|
+
atom = StructureData()
|
|
49
|
+
atom.append_atom(position=(0.0, 0.0, 0.0), symbols=atom_symbol)
|
|
50
|
+
atom.set_pbc((False, False, False))
|
|
51
|
+
atom.label = f"{atom_symbol} atom"
|
|
52
|
+
atom.description = (
|
|
53
|
+
f"Isolated {atom_symbol} atom extracted from Node: {structure.pk}"
|
|
54
|
+
)
|
|
55
|
+
structures[atom_symbol] = atom
|
|
56
|
+
return structures
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _determine_unique_atoms(structure: SinglefileData | StructureData) -> list[str]:
|
|
60
|
+
"""Determine all unique atom types within the given structure."""
|
|
61
|
+
if isinstance(structure, StructureData):
|
|
62
|
+
return _atom_types_from_structuredata(structure)
|
|
63
|
+
return _atom_types_from_file(structure)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _atom_types_from_structuredata(structure: StructureData) -> list[str]:
|
|
67
|
+
"""Determine the unique atom types from a StructureData object."""
|
|
68
|
+
unique_atoms: list[str] = []
|
|
69
|
+
for site in structure.sites:
|
|
70
|
+
if site.kind_name not in unique_atoms:
|
|
71
|
+
unique_atoms.append(site.kind_name)
|
|
72
|
+
return unique_atoms
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _atom_types_from_file(structure: SinglefileData) -> list[str]:
|
|
76
|
+
"""Determine the unique atom types from a SinglefileData object using ASE."""
|
|
77
|
+
import os
|
|
78
|
+
import tempfile
|
|
79
|
+
|
|
80
|
+
from ase.io import read
|
|
81
|
+
from ase.io.formats import UnknownFileTypeError
|
|
82
|
+
|
|
83
|
+
suffix = os.path.splitext(structure.filename)[1]
|
|
84
|
+
with tempfile.NamedTemporaryFile(suffix=suffix) as tmp:
|
|
85
|
+
with structure.open(mode="rb") as f:
|
|
86
|
+
tmp.write(f.read())
|
|
87
|
+
tmp.flush()
|
|
88
|
+
try:
|
|
89
|
+
atoms = read(tmp.name)
|
|
90
|
+
except UnknownFileTypeError as e:
|
|
91
|
+
raise Exception(
|
|
92
|
+
f"Unsupported structure file format for isolated atom extraction: "
|
|
93
|
+
f"{structure.filename}"
|
|
94
|
+
) from e
|
|
95
|
+
return list(dict.fromkeys(atoms.get_chemical_symbols()))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@calcfunction
|
|
99
|
+
def create_atomic_energy_dictionary(atoms, energies) -> Dict:
|
|
100
|
+
"""Collate a series of isolated atom energies into a dictionary output."""
|
|
101
|
+
if len(atoms) != len(energies):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Mismatched lengths: Got {len(atoms)} atoms and {len(energies)} energies."
|
|
104
|
+
)
|
|
105
|
+
return Dict(dict(zip(atoms, energies, strict=False)))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@calcfunction
|
|
109
|
+
def extract_structures_from_xyz(file: SinglefileData) -> dict[str, StructureData]:
|
|
110
|
+
"""
|
|
111
|
+
Split a multi-frame XYZ file into individual StructureData nodes.
|
|
112
|
+
|
|
113
|
+
Each frame in the (optionally extended) XYZ file is parsed into its own
|
|
114
|
+
StructureData node. Extended XYZ 'Lattice=' and 'pbc=' fields on the comment
|
|
115
|
+
line are honoured when present; frames without them are treated as
|
|
116
|
+
non-periodic.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
file : SinglefileData
|
|
121
|
+
A SinglefileData node wrapping an XYZ (or extended XYZ) file that may
|
|
122
|
+
contain one or more frames.
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
dict[str, StructureData]
|
|
127
|
+
A mapping of output link labels to StructureData nodes, one per frame.
|
|
128
|
+
Keys are of the form '<filename>_frame_<index>' (zero-indexed, with
|
|
129
|
+
spaces in the filename replaced by underscores). As a calcfunction
|
|
130
|
+
return value, this dictionary is registered as a namespace of output
|
|
131
|
+
nodes.
|
|
132
|
+
|
|
133
|
+
Raises
|
|
134
|
+
------
|
|
135
|
+
Exception
|
|
136
|
+
If the file is not valid XYZ, is truncated, or contains a malformed
|
|
137
|
+
atom line.
|
|
138
|
+
"""
|
|
139
|
+
with file.open(mode="r") as f:
|
|
140
|
+
lines = f.readlines()
|
|
141
|
+
|
|
142
|
+
structures = {}
|
|
143
|
+
line_count = len(lines)
|
|
144
|
+
i = 0
|
|
145
|
+
frame_idx = 0
|
|
146
|
+
|
|
147
|
+
while i < line_count:
|
|
148
|
+
line = lines[i].strip()
|
|
149
|
+
try:
|
|
150
|
+
natoms = int(line)
|
|
151
|
+
except ValueError as e:
|
|
152
|
+
raise Exception("Invalid XYZ format detected.") from e
|
|
153
|
+
if (i + 2 + natoms) > line_count:
|
|
154
|
+
raise Exception("XYZ file truncation detected.")
|
|
155
|
+
|
|
156
|
+
# Create the base StructureData object
|
|
157
|
+
structure = StructureData(pbc=(False, False, False))
|
|
158
|
+
|
|
159
|
+
# Read the comment line
|
|
160
|
+
i += 1
|
|
161
|
+
line = lines[i].strip()
|
|
162
|
+
cell = None
|
|
163
|
+
if "Lattice=" in line:
|
|
164
|
+
match = re.search(r'Lattice="([^"]+)"', line)
|
|
165
|
+
if match:
|
|
166
|
+
lat_vals = [float(x) for x in match.group(1).split()]
|
|
167
|
+
if len(lat_vals) == 9:
|
|
168
|
+
cell = [lat_vals[0:3], lat_vals[3:6], lat_vals[6:9]]
|
|
169
|
+
pbc = [True, True, True]
|
|
170
|
+
if "pbc=" in line:
|
|
171
|
+
match_pbc = re.search(r'pbc="([^"]+)"', line)
|
|
172
|
+
if match_pbc:
|
|
173
|
+
pbc_vals = match_pbc.group(1).split()
|
|
174
|
+
if len(pbc_vals) == 3:
|
|
175
|
+
# Robust check: converts 'T', 'True', or '1' to True
|
|
176
|
+
pbc = [val.upper() in ["T", "TRUE", "1"] for val in pbc_vals]
|
|
177
|
+
|
|
178
|
+
# Assign the parse cell parameters to the StructureData object
|
|
179
|
+
structure.cell = cell
|
|
180
|
+
structure.pbc = pbc
|
|
181
|
+
|
|
182
|
+
i += 1
|
|
183
|
+
for atmi in range(natoms):
|
|
184
|
+
atom_line = lines[i + atmi].strip().split()
|
|
185
|
+
if len(atom_line) < 4:
|
|
186
|
+
raise Exception(
|
|
187
|
+
f"Invalid atom entry in xyz file: {line[i + atmi].strip()}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
structure.append_atom(
|
|
191
|
+
position=[
|
|
192
|
+
float(atom_line[1]),
|
|
193
|
+
float(atom_line[2]),
|
|
194
|
+
float(atom_line[3]),
|
|
195
|
+
],
|
|
196
|
+
symbols=atom_line[0],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
structures[
|
|
200
|
+
f"{file.filename.replace(' ', '_').strip('.xyz')}_frame_{frame_idx}"
|
|
201
|
+
] = structure
|
|
202
|
+
frame_idx += 1
|
|
203
|
+
i += natoms
|
|
204
|
+
|
|
205
|
+
return structures
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@calcfunction
|
|
209
|
+
def combine_into_extended_xyz(
|
|
210
|
+
**kwargs: StructureData | TrajectoryData | Float | ArrayData,
|
|
211
|
+
) -> SinglefileData:
|
|
212
|
+
"""
|
|
213
|
+
Collate a batch of single-point results into one extended XYZ file.
|
|
214
|
+
|
|
215
|
+
The keyword nodes are sorted by type into three parallel lists: structures
|
|
216
|
+
(from StructureData, or each frame of a TrajectoryData), energies (Float)
|
|
217
|
+
and, optionally, force arrays (ArrayData). They are then written out as an
|
|
218
|
+
extended XYZ file with an 'Energy=' label per frame and, when forces are
|
|
219
|
+
supplied, a 'force' property column. The lists are paired positionally, so
|
|
220
|
+
the caller is responsible for passing matched sets of nodes (equal numbers
|
|
221
|
+
of structures, energies and, if used, arrays).
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
**kwargs : StructureData | TrajectoryData | Float | ArrayData
|
|
226
|
+
Arbitrary keyword arguments whose values are the result nodes to
|
|
227
|
+
combine. Only the values are used; the keys are ignored. Forces are read
|
|
228
|
+
from the 'gradients' array of any ArrayData node.
|
|
229
|
+
|
|
230
|
+
Returns
|
|
231
|
+
-------
|
|
232
|
+
SinglefileData
|
|
233
|
+
A SinglefileData node wrapping the combined
|
|
234
|
+
'chemshell_batch_workchain.extxyz' file.
|
|
235
|
+
|
|
236
|
+
Raises
|
|
237
|
+
------
|
|
238
|
+
ValueError
|
|
239
|
+
If an unsupported node type is passed, if no structures are provided, or
|
|
240
|
+
if the numbers of structures, energies and (when given) arrays do not
|
|
241
|
+
match.
|
|
242
|
+
"""
|
|
243
|
+
structures: list[StructureData] = []
|
|
244
|
+
energies: list[Float] = []
|
|
245
|
+
arrays: list[ArrayData] = []
|
|
246
|
+
|
|
247
|
+
# Extract individual structures and SP calculation results.
|
|
248
|
+
for _key, node in kwargs.items():
|
|
249
|
+
if isinstance(node, TrajectoryData):
|
|
250
|
+
for tindex in range(node.numsteps):
|
|
251
|
+
structures.append(node.get_step_structure(tindex))
|
|
252
|
+
elif isinstance(node, StructureData):
|
|
253
|
+
structures.append(node)
|
|
254
|
+
elif isinstance(node, Float):
|
|
255
|
+
energies.append(node)
|
|
256
|
+
elif isinstance(node, ArrayData): # Ensure after TrajectoryData !!
|
|
257
|
+
arrays.append(node)
|
|
258
|
+
else:
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"Invalid input of type {node} to 'combine_into_extended_xyz' "
|
|
261
|
+
"calcfunction."
|
|
262
|
+
)
|
|
263
|
+
if not structures:
|
|
264
|
+
raise ValueError("No StructureData nodes were provided.")
|
|
265
|
+
if len(structures) != len(energies):
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"Mismatched input lengths: received {len(structures)} structures and "
|
|
268
|
+
f"{len(energies)} energies."
|
|
269
|
+
)
|
|
270
|
+
if arrays:
|
|
271
|
+
if len(arrays) != len(energies):
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"Mismatched input lengths: received {len(arrays)} arrays and "
|
|
274
|
+
f"{len(energies)} energies."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
xyz_lines = []
|
|
278
|
+
|
|
279
|
+
for i in range(len(structures)):
|
|
280
|
+
structure = structures[i]
|
|
281
|
+
energy = energies[i]
|
|
282
|
+
|
|
283
|
+
xyz_lines.append(str(len(structure.sites)))
|
|
284
|
+
|
|
285
|
+
header_line = f"Energy={energy.value}"
|
|
286
|
+
if arrays:
|
|
287
|
+
forces = arrays[i].get_array("gradients")
|
|
288
|
+
header_line += " Properties=species:S:1:pos:R:3:force:R:3"
|
|
289
|
+
else:
|
|
290
|
+
forces = None
|
|
291
|
+
header_line += " Properties=species:S:1:pos:R:3"
|
|
292
|
+
xyz_lines.append(header_line)
|
|
293
|
+
|
|
294
|
+
for j, site in enumerate(structure.sites):
|
|
295
|
+
kind = structure.get_kind(site.kind_name)
|
|
296
|
+
symbol = kind.symbols[0]
|
|
297
|
+
x, y, z = site.position
|
|
298
|
+
site_line = f"{symbol:<4} {x:15.8f} {y:15.8f} {z:15.8f}"
|
|
299
|
+
if forces is not None:
|
|
300
|
+
fx, fy, fz = forces[j]
|
|
301
|
+
site_line += f" {fx:15.8f} {fy:15.8f} {fz:15.8f}"
|
|
302
|
+
xyz_lines.append(site_line)
|
|
303
|
+
|
|
304
|
+
content = "\n".join(xyz_lines)
|
|
305
|
+
stream = BytesIO(content.encode("utf-8"))
|
|
306
|
+
|
|
307
|
+
return SinglefileData(file=stream, filename="chemshell_batch_workchain.extxyz")
|