aiida-chemshell 0.3.0__tar.gz → 0.3.1__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 (36) hide show
  1. {aiida_chemshell-0.3.0/src/aiida_chemshell.egg-info → aiida_chemshell-0.3.1}/PKG-INFO +1 -1
  2. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/pyproject.toml +1 -1
  3. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/calculations/base.py +114 -18
  4. aiida_chemshell-0.3.1/src/aiida_chemshell/calculations/utils.py +226 -0
  5. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/parsers/base.py +84 -24
  6. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/units.py +2 -2
  7. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/utils.py +58 -0
  8. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/workflows/batch_calculation.py +23 -148
  9. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/workflows/isolated_atoms.py +19 -1
  10. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/workflows/optimisation.py +28 -2
  11. aiida_chemshell-0.3.1/src/aiida_chemshell/workflows/utils.py +34 -0
  12. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1/src/aiida_chemshell.egg-info}/PKG-INFO +1 -1
  13. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell.egg-info/SOURCES.txt +1 -0
  14. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/tests/test_batch_workcahin.py +28 -1
  15. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/tests/test_calculations.py +18 -5
  16. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/tests/test_inputs.py +63 -0
  17. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/tests/test_isolated_atoms_workchain.py +23 -0
  18. aiida_chemshell-0.3.1/tests/test_workflows.py +95 -0
  19. aiida_chemshell-0.3.0/src/aiida_chemshell/calculations/utils.py +0 -14
  20. aiida_chemshell-0.3.0/tests/test_workflows.py +0 -37
  21. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/LICENSE +0 -0
  22. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/README.md +0 -0
  23. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/setup.cfg +0 -0
  24. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/__init__.py +0 -0
  25. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/calculations/__init__.py +0 -0
  26. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/calculations/file_conversion.py +0 -0
  27. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/calculations/solvant_removal.py +0 -0
  28. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/parsers/__init__.py +0 -0
  29. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/parsers/file_conversion.py +0 -0
  30. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/periodic_table.py +0 -0
  31. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell/workflows/__init__.py +0 -0
  32. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell.egg-info/dependency_links.txt +0 -0
  33. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell.egg-info/entry_points.txt +0 -0
  34. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell.egg-info/requires.txt +0 -0
  35. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/src/aiida_chemshell.egg-info/top_level.txt +0 -0
  36. {aiida_chemshell-0.3.0 → aiida_chemshell-0.3.1}/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.1
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
@@ -3,7 +3,7 @@ requires = ["setuptools"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
- version = "0.3.0"
6
+ version = "0.3.1"
7
7
  name = "aiida-chemshell"
8
8
  description = "AiiDA workflow plugin for the ChemShell chemical modelling software package"
9
9
  authors = [
@@ -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(
@@ -209,11 +263,11 @@ class ChemShellCalculation(CalcJob):
209
263
  )
210
264
  spec.output(
211
265
  "trajectory_force",
212
- valid_type=SinglefileData,
266
+ valid_type=ArrayData,
213
267
  required=False,
214
268
  help=(
215
- "XYZ style trajectory file containing forces at each step of a geometry"
216
- " optimisation."
269
+ "Per-atom forces (natoms, 3) at each step of a geometry optimisation, "
270
+ "stored one array per frame labelled 'Frame {i}'."
217
271
  ),
218
272
  )
219
273
 
@@ -324,13 +378,13 @@ class ChemShellCalculation(CalcJob):
324
378
  return None
325
379
 
326
380
  @classmethod
327
- def get_valid_calculation_parameter_keys(cls) -> tuple[str]:
381
+ def get_valid_calculation_parameter_keys(cls) -> tuple[str, str]:
328
382
  """
329
383
  Return the valid parameter keys for the ChemShell Single Point calculation.
330
384
 
331
385
  Returns
332
386
  -------
333
- : tuple[str]
387
+ tuple[str, str]
334
388
  A tuple of valid parameter keys for the ChemShell calculation.
335
389
  """
336
390
  return ("gradients", "hessian")
@@ -379,7 +433,7 @@ class ChemShellCalculation(CalcJob):
379
433
 
380
434
  Returns
381
435
  -------
382
- : tuple[str]
436
+ tuple[str]
383
437
  A tuple of valid optimisation parameter keys for the ChemShell
384
438
  calculation.
385
439
  """
@@ -437,12 +491,13 @@ class ChemShellCalculation(CalcJob):
437
491
  @classmethod
438
492
  def get_valid_qm_parameter_keys(cls) -> dict:
439
493
  """
440
- Return a tuple of valid parameter keys for the ChemShell calculation.
494
+ Return a dictionary of valid parameter keys for the ChemShell calculation.
441
495
 
442
496
  Returns
443
497
  -------
444
- validKeys : dict[str: type]
445
- A tuple of valid Theory parameter keys for the ChemShell calculation.
498
+ dict[str, type]
499
+ A mapping of valid Theory parameter keys to their expected value
500
+ types for the ChemShell calculation.
446
501
  """
447
502
  return {
448
503
  "theory": str,
@@ -527,14 +582,21 @@ class ChemShellCalculation(CalcJob):
527
582
  return None
528
583
 
529
584
  @classmethod
530
- def get_valid_mm_parameter_keys(cls, theory: str = "") -> dict[str:type]:
585
+ def get_valid_mm_parameter_keys(cls, theory: str = "") -> dict[str, type]:
531
586
  """
532
- Return a tuple of valid parameter keys for the ChemShell MM interface.
587
+ Return a dictionary of valid parameter keys for the ChemShell MM interface.
588
+
589
+ Parameters
590
+ ----------
591
+ theory : str
592
+ The MM theory whose valid parameter keys should be returned (e.g.
593
+ 'DL_POLY'). Defaults to an empty string.
533
594
 
534
595
  Returns
535
596
  -------
536
- validKeys : dict[str: type]
537
- A tuple of valid MM parameter keys for the ChemShell calculation.
597
+ dict[str, type]
598
+ A mapping of valid MM parameter keys to their expected value types
599
+ for the ChemShell calculation.
538
600
  """
539
601
  if theory == "DL_POLY":
540
602
  valid_keys = {
@@ -754,6 +816,12 @@ class ChemShellCalculation(CalcJob):
754
816
  Defines the process label to be associated with the created ProcessNode
755
817
  stored in the AiiDA database.
756
818
 
819
+ Parameters
820
+ ----------
821
+ node : ProcessNode
822
+ The process (or its ProcessNode) whose inputs are inspected to build
823
+ the label.
824
+
757
825
  Returns
758
826
  -------
759
827
  str
@@ -933,12 +1001,30 @@ class ChemShellCalculation(CalcJob):
933
1001
 
934
1002
  return script
935
1003
 
1004
+ def apply_default_input_tags(self) -> None:
1005
+ """
1006
+ Apply default labels and descriptions to the calculation input nodes.
1007
+
1008
+ Iterates over the provided input nodes and, for any node defined in
1009
+ :attr:`DEFAULT_INPUT_TAGS` that does not already have a label or
1010
+ description set, assigns the corresponding default value. Existing
1011
+ labels and descriptions are left untouched.
1012
+ """
1013
+ for port_name, (label, description) in self.DEFAULT_INPUT_TAGS.items():
1014
+ if port_name not in self.inputs:
1015
+ continue
1016
+ node = self.inputs[port_name]
1017
+ if not node.label:
1018
+ node.label = label
1019
+ if not node.description:
1020
+ node.description = description
1021
+
936
1022
  def prepare_for_submission(self, folder: Folder) -> CalcInfo:
937
1023
  """
938
1024
  Prepare the ChemShell calculation for submission.
939
1025
 
940
- Params
941
- ------
1026
+ Parameters
1027
+ ----------
942
1028
  folder : Folder
943
1029
  An `aiida.common.folders.Folder` specifying the temporary working
944
1030
  directory for the calculation.
@@ -948,6 +1034,16 @@ class ChemShellCalculation(CalcJob):
948
1034
  calcInfo : CalcInfo
949
1035
  An `aiida.common.CalcInfo` instance.
950
1036
  """
1037
+ # Apply default labels/descriptions to input nodes lacking them
1038
+ self.apply_default_input_tags()
1039
+
1040
+ # Record the (non_db) output structure format choice as a node extra so
1041
+ # that the parser can determine whether to return the optimised structure
1042
+ # as a StructureData or SinglefileData node.
1043
+ self.node.base.extras.set(
1044
+ "output_structure_as_file", bool(self.inputs.output_structure_as_file)
1045
+ )
1046
+
951
1047
  # Create the ChemShell input script
952
1048
  input_script = self.chemsh_script_generator()
953
1049
  with folder.open(ChemShellCalculation.FILE_SCRIPT, "w") as f:
@@ -0,0 +1,226 @@
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_dictionary(atoms, energies) -> Dict:
19
+ """Collate a series of isolated atom energies into a dictionary output."""
20
+ if len(atoms) != len(energies):
21
+ raise ValueError(
22
+ f"Mismatched lengths: Got {len(atoms)} atoms and {len(energies)} energies."
23
+ )
24
+ return Dict(dict(zip(atoms, energies, strict=False)))
25
+
26
+
27
+ @calcfunction
28
+ def extract_structures_from_xyz(file: SinglefileData) -> dict[str, StructureData]:
29
+ """
30
+ Split a multi-frame XYZ file into individual StructureData nodes.
31
+
32
+ Each frame in the (optionally extended) XYZ file is parsed into its own
33
+ StructureData node. Extended XYZ 'Lattice=' and 'pbc=' fields on the comment
34
+ line are honoured when present; frames without them are treated as
35
+ non-periodic.
36
+
37
+ Parameters
38
+ ----------
39
+ file : SinglefileData
40
+ A SinglefileData node wrapping an XYZ (or extended XYZ) file that may
41
+ contain one or more frames.
42
+
43
+ Returns
44
+ -------
45
+ dict[str, StructureData]
46
+ A mapping of output link labels to StructureData nodes, one per frame.
47
+ Keys are of the form '<filename>_frame_<index>' (zero-indexed, with
48
+ spaces in the filename replaced by underscores). As a calcfunction
49
+ return value, this dictionary is registered as a namespace of output
50
+ nodes.
51
+
52
+ Raises
53
+ ------
54
+ Exception
55
+ If the file is not valid XYZ, is truncated, or contains a malformed
56
+ atom line.
57
+ """
58
+ with file.open(mode="r") as f:
59
+ lines = f.readlines()
60
+
61
+ structures = {}
62
+ line_count = len(lines)
63
+ i = 0
64
+ frame_idx = 0
65
+
66
+ while i < line_count:
67
+ line = lines[i].strip()
68
+ try:
69
+ natoms = int(line)
70
+ except ValueError as e:
71
+ raise Exception("Invalid XYZ format detected.") from e
72
+ if (i + 2 + natoms) > line_count:
73
+ raise Exception("XYZ file truncation detected.")
74
+
75
+ # Create the base StructureData object
76
+ structure = StructureData(pbc=(False, False, False))
77
+
78
+ # Read the comment line
79
+ i += 1
80
+ line = lines[i].strip()
81
+ cell = None
82
+ if "Lattice=" in line:
83
+ match = re.search(r'Lattice="([^"]+)"', line)
84
+ if match:
85
+ lat_vals = [float(x) for x in match.group(1).split()]
86
+ if len(lat_vals) == 9:
87
+ cell = [lat_vals[0:3], lat_vals[3:6], lat_vals[6:9]]
88
+ pbc = [True, True, True]
89
+ if "pbc=" in line:
90
+ match_pbc = re.search(r'pbc="([^"]+)"', line)
91
+ if match_pbc:
92
+ pbc_vals = match_pbc.group(1).split()
93
+ if len(pbc_vals) == 3:
94
+ # Robust check: converts 'T', 'True', or '1' to True
95
+ pbc = [val.upper() in ["T", "TRUE", "1"] for val in pbc_vals]
96
+
97
+ # Assign the parse cell parameters to the StructureData object
98
+ structure.cell = cell
99
+ structure.pbc = pbc
100
+
101
+ i += 1
102
+ for atmi in range(natoms):
103
+ atom_line = lines[i + atmi].strip().split()
104
+ if len(atom_line) < 4:
105
+ raise Exception(
106
+ f"Invalid atom entry in xyz file: {line[i + atmi].strip()}"
107
+ )
108
+
109
+ structure.append_atom(
110
+ position=[
111
+ float(atom_line[1]),
112
+ float(atom_line[2]),
113
+ float(atom_line[3]),
114
+ ],
115
+ symbols=atom_line[0],
116
+ )
117
+
118
+ structures[
119
+ f"{file.filename.replace(' ', '_').strip('.xyz')}_frame_{frame_idx}"
120
+ ] = structure
121
+ frame_idx += 1
122
+ i += natoms
123
+
124
+ return structures
125
+
126
+
127
+ @calcfunction
128
+ def combine_into_extended_xyz(
129
+ **kwargs: StructureData | TrajectoryData | Float | ArrayData,
130
+ ) -> SinglefileData:
131
+ """
132
+ Collate a batch of single-point results into one extended XYZ file.
133
+
134
+ The keyword nodes are sorted by type into three parallel lists: structures
135
+ (from StructureData, or each frame of a TrajectoryData), energies (Float)
136
+ and, optionally, force arrays (ArrayData). They are then written out as an
137
+ extended XYZ file with an 'Energy=' label per frame and, when forces are
138
+ supplied, a 'force' property column. The lists are paired positionally, so
139
+ the caller is responsible for passing matched sets of nodes (equal numbers
140
+ of structures, energies and, if used, arrays).
141
+
142
+ Parameters
143
+ ----------
144
+ **kwargs : StructureData | TrajectoryData | Float | ArrayData
145
+ Arbitrary keyword arguments whose values are the result nodes to
146
+ combine. Only the values are used; the keys are ignored. Forces are read
147
+ from the 'gradients' array of any ArrayData node.
148
+
149
+ Returns
150
+ -------
151
+ SinglefileData
152
+ A SinglefileData node wrapping the combined
153
+ 'chemshell_batch_workchain.extxyz' file.
154
+
155
+ Raises
156
+ ------
157
+ ValueError
158
+ If an unsupported node type is passed, if no structures are provided, or
159
+ if the numbers of structures, energies and (when given) arrays do not
160
+ match.
161
+ """
162
+ structures: list[StructureData] = []
163
+ energies: list[Float] = []
164
+ arrays: list[ArrayData] = []
165
+
166
+ # Extract individual structures and SP calculation results.
167
+ for _key, node in kwargs.items():
168
+ if isinstance(node, TrajectoryData):
169
+ for tindex in range(node.numsteps):
170
+ structures.append(node.get_step_structure(tindex))
171
+ elif isinstance(node, StructureData):
172
+ structures.append(node)
173
+ elif isinstance(node, Float):
174
+ energies.append(node)
175
+ elif isinstance(node, ArrayData): # Ensure after TrajectoryData !!
176
+ arrays.append(node)
177
+ else:
178
+ raise ValueError(
179
+ f"Invalid input of type {node} to 'combine_into_extended_xyz' "
180
+ "calcfunction."
181
+ )
182
+ if not structures:
183
+ raise ValueError("No StructureData nodes were provided.")
184
+ if len(structures) != len(energies):
185
+ raise ValueError(
186
+ f"Mismatched input lengths: received {len(structures)} structures and "
187
+ f"{len(energies)} energies."
188
+ )
189
+ if arrays:
190
+ if len(arrays) != len(energies):
191
+ raise ValueError(
192
+ f"Mismatched input lengths: received {len(arrays)} arrays and "
193
+ f"{len(energies)} energies."
194
+ )
195
+
196
+ xyz_lines = []
197
+
198
+ for i in range(len(structures)):
199
+ structure = structures[i]
200
+ energy = energies[i]
201
+
202
+ xyz_lines.append(str(len(structure.sites)))
203
+
204
+ header_line = f"Energy={energy.value}"
205
+ if arrays:
206
+ forces = arrays[i].get_array("gradients")
207
+ header_line += " Properties=species:S:1:pos:R:3:force:R:3"
208
+ else:
209
+ forces = None
210
+ header_line += " Properties=species:S:1:pos:R:3"
211
+ xyz_lines.append(header_line)
212
+
213
+ for j, site in enumerate(structure.sites):
214
+ kind = structure.get_kind(site.kind_name)
215
+ symbol = kind.symbols[0]
216
+ x, y, z = site.position
217
+ site_line = f"{symbol:<4} {x:15.8f} {y:15.8f} {z:15.8f}"
218
+ if forces is not None:
219
+ fx, fy, fz = forces[j]
220
+ site_line += f" {fx:15.8f} {fy:15.8f} {fz:15.8f}"
221
+ xyz_lines.append(site_line)
222
+
223
+ content = "\n".join(xyz_lines)
224
+ stream = BytesIO(content.encode("utf-8"))
225
+
226
+ return SinglefileData(file=stream, filename="chemshell_batch_workchain.extxyz")
@@ -10,6 +10,7 @@ from aiida.orm import ArrayData, Dict, Float, SinglefileData, TrajectoryData
10
10
  from aiida.parsers.parser import Parser
11
11
 
12
12
  from aiida_chemshell.calculations.base import ChemShellCalculation
13
+ from aiida_chemshell.utils import chemsh_cjson_to_structure_data
13
14
 
14
15
 
15
16
  class ChemShellParser(Parser):
@@ -31,7 +32,17 @@ class ChemShellParser(Parser):
31
32
 
32
33
  # Extract the final energy
33
34
  try:
34
- self.out("energy", Float(results["energy"][0], label="Final SCF Energy"))
35
+ self.out(
36
+ "energy",
37
+ Float(
38
+ results["energy"][0],
39
+ label="Final SCF Energy",
40
+ description=(
41
+ "The total (final SCF) energy of the system calculated by "
42
+ "ChemShell."
43
+ ),
44
+ ),
45
+ )
35
46
  except (KeyError, ValueError):
36
47
  return self.exit_codes.ERROR_MISSING_FINAL_ENERGY
37
48
  except ModificationNotAllowed as e:
@@ -91,17 +102,25 @@ class ChemShellParser(Parser):
91
102
  if isinstance(self.node.inputs.structure, SinglefileData):
92
103
  input_fname = self.node.inputs.structure.filename
93
104
  descrip += f" ({input_fname})"
94
- # Store the optimised structure file
95
- with open(dl_find_path, "rb") as f:
96
- self.out(
97
- "optimised_structure",
98
- SinglefileData(
99
- file=f,
100
- filename=ChemShellCalculation.FILE_DLFIND,
101
- label="CJSON Structure File",
102
- description=descrip,
103
- ),
104
- )
105
+ # Store the optimised structure either as a SinglefileData '.cjson'
106
+ # file or (by default) as an AiiDA StructureData node.
107
+ if self.node.base.extras.get("output_structure_as_file", False):
108
+ with open(dl_find_path, "rb") as f:
109
+ self.out(
110
+ "optimised_structure",
111
+ SinglefileData(
112
+ file=f,
113
+ filename=ChemShellCalculation.FILE_DLFIND,
114
+ label="CJSON Structure File",
115
+ description=descrip,
116
+ ),
117
+ )
118
+ else:
119
+ with open(dl_find_path, "rb") as f:
120
+ structure = chemsh_cjson_to_structure_data(f.read())
121
+ structure.label = "Optimised Structure"
122
+ structure.description = descrip
123
+ self.out("optimised_structure", structure)
105
124
  self.parse_optimisation_path(
106
125
  self.retrieved.get_object_content(
107
126
  ChemShellCalculation.FILE_STDOUT, "r"
@@ -115,17 +134,7 @@ class ChemShellParser(Parser):
115
134
  trj_frc_path = retrieved_tmp_folder / ChemShellCalculation.FILE_TRJFRC
116
135
  if trj_path.exists():
117
136
  self.parse_xyz_path(trj_path, "trajectory_path")
118
- with open(trj_frc_path, "rb") as f:
119
- self.out(
120
- "trajectory_force",
121
- SinglefileData(
122
- file=f,
123
- filename=ChemShellCalculation.FILE_TRJFRC.replace(
124
- "/", "_"
125
- ),
126
- label="ChemShell optimisation trajectory.",
127
- ),
128
- )
137
+ self.parse_xyz_forces(trj_frc_path, "trajectory_force")
129
138
  else:
130
139
  return self.exit_codes.ERROR_MISSING_OPTIMISED_STRUCTURE_FILE
131
140
 
@@ -161,7 +170,17 @@ class ChemShellParser(Parser):
161
170
  read = True
162
171
  elif "total S vib" in line:
163
172
  read = False
164
- self.out("vibrational_energies", Dict(energies))
173
+ self.out(
174
+ "vibrational_energies",
175
+ Dict(
176
+ energies,
177
+ label="Thermochemical Properties",
178
+ description=(
179
+ "Thermochemical properties (ZPE, enthalpy, entropy, etc.) "
180
+ "calculated by ChemShell."
181
+ ),
182
+ ),
183
+ )
165
184
  modes = numpy.asarray(modes)
166
185
  modes_data_node = ArrayData(
167
186
  label="Vibrational Modes",
@@ -214,6 +233,47 @@ class ChemShellParser(Parser):
214
233
  self.out(output_link, path)
215
234
  return
216
235
 
236
+ def parse_xyz_forces(self, file_path: Path, output_link: str) -> None:
237
+ """
238
+ Parse an XYZ style forces trajectory into an AiiDA ArrayData node.
239
+
240
+ Each frame is stored as a separate '(natoms, 3)' array labelled
241
+ 'Frame_{i}' (zero-indexed). An underscore is used in place of a space as
242
+ AiiDA array names may only contain digits, letters and underscores.
243
+
244
+ Parameters
245
+ ----------
246
+ file_path : Path
247
+ Path to the XYZ style forces trajectory file to parse.
248
+ output_link : str
249
+ The output link label under which the resulting ArrayData node is
250
+ attached to the calculation.
251
+ """
252
+ with open(file_path) as f:
253
+ lines = f.readlines()
254
+ natoms = int(lines[0])
255
+ forces = ArrayData(
256
+ label="Optimisation Path Forces",
257
+ description=(
258
+ "Per-atom forces at each step of a ChemShell geometry optimisation, "
259
+ "stored as one (natoms, 3) array per frame."
260
+ ),
261
+ )
262
+ frame = 0
263
+ i = 0
264
+ while i < len(lines):
265
+ frame_forces = numpy.zeros((natoms, 3), dtype=float)
266
+ for atm_index, atm_line in enumerate(lines[i + 2 : i + 2 + natoms]):
267
+ line = atm_line.split()
268
+ frame_forces[atm_index][0] = float(line[1])
269
+ frame_forces[atm_index][1] = float(line[2])
270
+ frame_forces[atm_index][2] = float(line[3])
271
+ forces.set_array(f"Frame_{frame}", frame_forces)
272
+ frame += 1
273
+ i += natoms + 2
274
+ self.out(output_link, forces)
275
+ return
276
+
217
277
  def parse_neb_info(self, file_path: Path) -> None:
218
278
  """Parse the NEB info file into an AiiDA ArrayData noe."""
219
279
  output = ArrayData(
@@ -4,13 +4,13 @@
4
4
  class UnitsConverter:
5
5
  """Unit conversion utility."""
6
6
 
7
- ANGSTROM = 0.529177
7
+ ANGSTROM = 0.529177210903
8
8
  EV = 27.2114
9
9
 
10
10
  @classmethod
11
11
  def angstrom_to_bohr(cls, val: float) -> float:
12
12
  """Convert Angstrom to Bohr (a.u.)."""
13
- return val * 1.8897259886
13
+ return val / UnitsConverter.ANGSTROM
14
14
 
15
15
  @classmethod
16
16
  def bohr_to_angstrom(cls, val: float) -> float: