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.
Files changed (37) hide show
  1. {aiida_chemshell-0.3.0/src/aiida_chemshell.egg-info → aiida_chemshell-0.3.2}/PKG-INFO +2 -1
  2. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/pyproject.toml +2 -2
  3. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/calculations/base.py +122 -21
  4. aiida_chemshell-0.3.2/src/aiida_chemshell/calculations/utils.py +307 -0
  5. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/parsers/base.py +95 -26
  6. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/units.py +2 -2
  7. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/utils.py +58 -0
  8. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/workflows/batch_calculation.py +23 -157
  9. aiida_chemshell-0.3.2/src/aiida_chemshell/workflows/isolated_atoms.py +106 -0
  10. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/workflows/optimisation.py +32 -128
  11. aiida_chemshell-0.3.2/src/aiida_chemshell/workflows/utils.py +34 -0
  12. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2/src/aiida_chemshell.egg-info}/PKG-INFO +2 -1
  13. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/SOURCES.txt +1 -3
  14. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/requires.txt +1 -0
  15. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/tests/test_batch_workcahin.py +28 -1
  16. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/tests/test_calculations.py +18 -5
  17. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/tests/test_inputs.py +109 -0
  18. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/tests/test_isolated_atoms_workchain.py +65 -6
  19. aiida_chemshell-0.3.2/tests/test_workflows.py +95 -0
  20. aiida_chemshell-0.3.0/src/aiida_chemshell/calculations/file_conversion.py +0 -206
  21. aiida_chemshell-0.3.0/src/aiida_chemshell/calculations/solvant_removal.py +0 -115
  22. aiida_chemshell-0.3.0/src/aiida_chemshell/calculations/utils.py +0 -14
  23. aiida_chemshell-0.3.0/src/aiida_chemshell/parsers/file_conversion.py +0 -54
  24. aiida_chemshell-0.3.0/src/aiida_chemshell/workflows/isolated_atoms.py +0 -139
  25. aiida_chemshell-0.3.0/tests/test_workflows.py +0 -37
  26. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/LICENSE +0 -0
  27. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/README.md +0 -0
  28. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/setup.cfg +0 -0
  29. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/__init__.py +0 -0
  30. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/calculations/__init__.py +0 -0
  31. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/parsers/__init__.py +0 -0
  32. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/periodic_table.py +0 -0
  33. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell/workflows/__init__.py +0 -0
  34. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/dependency_links.txt +0 -0
  35. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/entry_points.txt +0 -0
  36. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.2}/src/aiida_chemshell.egg-info/top_level.txt +0 -0
  37. {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.0
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.0"
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. The "
164
- "structure is contained within a ChemShell '.pun' file."
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="The calculated vibrational modes of the system.",
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=SinglefileData,
269
+ valid_type=ArrayData,
213
270
  required=False,
214
271
  help=(
215
- "XYZ style trajectory file containing forces at each step of a geometry"
216
- " optimisation."
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
- : tuple[str]
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
- : tuple[str]
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 tuple of valid parameter keys for the ChemShell calculation.
497
+ Return a dictionary of valid parameter keys for the ChemShell calculation.
441
498
 
442
499
  Returns
443
500
  -------
444
- validKeys : dict[str: type]
445
- A tuple of valid Theory parameter keys for the ChemShell calculation.
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:type]:
588
+ def get_valid_mm_parameter_keys(cls, theory: str = "") -> dict[str, type]:
531
589
  """
532
- Return a tuple of valid parameter keys for the ChemShell MM interface.
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
- validKeys : dict[str: type]
537
- A tuple of valid MM parameter keys for the ChemShell calculation.
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
- Params
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.FILE_TMP_STRUCTURE, "wb") as f:
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")