digichem-core 6.0.0rc1__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.
Files changed (111) hide show
  1. digichem/__init__.py +75 -0
  2. digichem/basis.py +116 -0
  3. digichem/config/README +3 -0
  4. digichem/config/__init__.py +5 -0
  5. digichem/config/base.py +321 -0
  6. digichem/config/locations.py +14 -0
  7. digichem/config/parse.py +90 -0
  8. digichem/config/util.py +117 -0
  9. digichem/data/README +4 -0
  10. digichem/data/batoms/COPYING +18 -0
  11. digichem/data/batoms/LICENSE +674 -0
  12. digichem/data/batoms/README +2 -0
  13. digichem/data/batoms/__init__.py +0 -0
  14. digichem/data/batoms/batoms-renderer.py +351 -0
  15. digichem/data/config/digichem.yaml +714 -0
  16. digichem/data/functionals.csv +15 -0
  17. digichem/data/solvents.csv +185 -0
  18. digichem/data/tachyon/COPYING.md +5 -0
  19. digichem/data/tachyon/LICENSE +30 -0
  20. digichem/data/tachyon/tachyon_LINUXAMD64 +0 -0
  21. digichem/data/vmd/common.tcl +468 -0
  22. digichem/data/vmd/generate_combined_orbital_images.tcl +70 -0
  23. digichem/data/vmd/generate_density_images.tcl +45 -0
  24. digichem/data/vmd/generate_dipole_images.tcl +68 -0
  25. digichem/data/vmd/generate_orbital_images.tcl +57 -0
  26. digichem/data/vmd/generate_spin_images.tcl +66 -0
  27. digichem/data/vmd/generate_structure_images.tcl +40 -0
  28. digichem/datas.py +14 -0
  29. digichem/exception/__init__.py +7 -0
  30. digichem/exception/base.py +133 -0
  31. digichem/exception/uncatchable.py +63 -0
  32. digichem/file/__init__.py +1 -0
  33. digichem/file/base.py +364 -0
  34. digichem/file/cube.py +284 -0
  35. digichem/file/fchk.py +94 -0
  36. digichem/file/prattle.py +277 -0
  37. digichem/file/types.py +97 -0
  38. digichem/image/__init__.py +6 -0
  39. digichem/image/base.py +113 -0
  40. digichem/image/excited_states.py +335 -0
  41. digichem/image/graph.py +293 -0
  42. digichem/image/orbitals.py +239 -0
  43. digichem/image/render.py +617 -0
  44. digichem/image/spectroscopy.py +797 -0
  45. digichem/image/structure.py +115 -0
  46. digichem/image/vmd.py +826 -0
  47. digichem/input/__init__.py +3 -0
  48. digichem/input/base.py +78 -0
  49. digichem/input/digichem_input.py +500 -0
  50. digichem/input/gaussian.py +140 -0
  51. digichem/log.py +179 -0
  52. digichem/memory.py +166 -0
  53. digichem/misc/__init__.py +4 -0
  54. digichem/misc/argparse.py +44 -0
  55. digichem/misc/base.py +61 -0
  56. digichem/misc/io.py +239 -0
  57. digichem/misc/layered_dict.py +285 -0
  58. digichem/misc/text.py +139 -0
  59. digichem/misc/time.py +73 -0
  60. digichem/parse/__init__.py +13 -0
  61. digichem/parse/base.py +220 -0
  62. digichem/parse/cclib.py +138 -0
  63. digichem/parse/dump.py +253 -0
  64. digichem/parse/gaussian.py +130 -0
  65. digichem/parse/orca.py +96 -0
  66. digichem/parse/turbomole.py +201 -0
  67. digichem/parse/util.py +523 -0
  68. digichem/result/__init__.py +6 -0
  69. digichem/result/alignment/AA.py +114 -0
  70. digichem/result/alignment/AAA.py +61 -0
  71. digichem/result/alignment/FAP.py +148 -0
  72. digichem/result/alignment/__init__.py +3 -0
  73. digichem/result/alignment/base.py +310 -0
  74. digichem/result/angle.py +153 -0
  75. digichem/result/atom.py +742 -0
  76. digichem/result/base.py +258 -0
  77. digichem/result/dipole_moment.py +332 -0
  78. digichem/result/emission.py +402 -0
  79. digichem/result/energy.py +323 -0
  80. digichem/result/excited_state.py +821 -0
  81. digichem/result/ground_state.py +94 -0
  82. digichem/result/metadata.py +644 -0
  83. digichem/result/multi.py +98 -0
  84. digichem/result/nmr.py +1086 -0
  85. digichem/result/orbital.py +647 -0
  86. digichem/result/result.py +244 -0
  87. digichem/result/soc.py +272 -0
  88. digichem/result/spectroscopy.py +514 -0
  89. digichem/result/tdm.py +267 -0
  90. digichem/result/vibration.py +167 -0
  91. digichem/test/__init__.py +6 -0
  92. digichem/test/conftest.py +4 -0
  93. digichem/test/test_basis.py +71 -0
  94. digichem/test/test_calculate.py +30 -0
  95. digichem/test/test_config.py +78 -0
  96. digichem/test/test_cube.py +369 -0
  97. digichem/test/test_exception.py +16 -0
  98. digichem/test/test_file.py +104 -0
  99. digichem/test/test_image.py +337 -0
  100. digichem/test/test_input.py +64 -0
  101. digichem/test/test_parsing.py +79 -0
  102. digichem/test/test_prattle.py +36 -0
  103. digichem/test/test_result.py +489 -0
  104. digichem/test/test_translate.py +112 -0
  105. digichem/test/util.py +207 -0
  106. digichem/translate.py +591 -0
  107. digichem_core-6.0.0rc1.dist-info/METADATA +96 -0
  108. digichem_core-6.0.0rc1.dist-info/RECORD +111 -0
  109. digichem_core-6.0.0rc1.dist-info/WHEEL +4 -0
  110. digichem_core-6.0.0rc1.dist-info/licenses/COPYING.md +10 -0
  111. digichem_core-6.0.0rc1.dist-info/licenses/LICENSE +11 -0
@@ -0,0 +1,742 @@
1
+ # General imports
2
+ import math
3
+ import periodictable
4
+ from itertools import zip_longest
5
+ import re
6
+
7
+ import digichem.log
8
+ from digichem.misc.base import dict_list_index
9
+ from digichem.exception.base import Result_unavailable_error, Digichem_exception
10
+ from digichem.result import Result_container
11
+ from digichem.result import Result_object
12
+ from digichem.result import Unmergeable_container_mixin
13
+ from digichem.file.prattle import Openprattle_converter
14
+
15
+ # Hidden import.
16
+
17
+
18
+ def get_chemical_group_mapping(rdkit_molecule):
19
+ """
20
+ Determine chemically equivalent atoms in this atom list.
21
+
22
+ :return: A mapping between each group number and the atoms it contains.
23
+ """
24
+ from rdkit import Chem
25
+
26
+ molecule = rdkit_molecule
27
+
28
+ groupings = list(Chem.rdmolfiles.CanonicalRankAtoms(molecule, breakTies = False, includeChirality = False))
29
+
30
+ atoms = list(molecule.GetAtoms())
31
+
32
+ groups = {}
33
+ for atom_index, atom in enumerate(atoms):
34
+ # For most atoms, just use the assigned group number.
35
+ group_num = groupings[atom_index]
36
+
37
+ # If this atom is a hydrogen, and it has a single bond to a carbon, use that carbon's group number instead.
38
+ if atom.GetSymbol() == "H":
39
+ bonds = list(atom.GetBonds())
40
+ if len(bonds) == 1 and bonds[0].GetOtherAtom(atom).GetSymbol() == "C":
41
+ # This is an implicit H.
42
+ other_atom = bonds[0].GetOtherAtom(atom)
43
+ group_num = groupings[other_atom.GetIdx()]
44
+
45
+ try:
46
+ groups[(group_num, atom.GetSymbol())].append(atom_index +1)
47
+
48
+ except KeyError:
49
+ groups[(group_num, atom.GetSymbol())] = [atom_index +1]
50
+
51
+ # We now need to re-assign group numbers to ensure they are:
52
+ # 1) Sparse (no missing values, possible because of the hydrogens we removed).
53
+ # 2) Consecutive in terms of the molecule's skeleton (so adjacent numbers are ideally placed
54
+ # adjacent to each other).
55
+ #
56
+ # 2) is challenging to ensure, so for now we simply use the original atom position (because
57
+ # molecules are normally written fairly consecutively).
58
+ # TODO: Find a better way to ensure atom numbering is both deterministic and consecutive.
59
+
60
+ order = []
61
+
62
+ for atom_index, atom in enumerate(atoms):
63
+ # Find the current group num of the group this atom is a part of.
64
+ cur_group = dict_list_index(groups, atom_index +1)
65
+
66
+ # If we've already processed this group, ignore.
67
+ if cur_group[0] not in order:
68
+ # Else, add it next.
69
+ order.append(cur_group[0])
70
+
71
+ new_groups = {(order.index(group_key[0]) +1, group_key[1]): group for group_key, group in groups.items()}
72
+
73
+ return new_groups
74
+
75
+
76
+ class Nucleus():
77
+ """
78
+ A class that identifies a specific nucleus (having an element and isotope).
79
+ """
80
+
81
+ # A regex that can be used to identify a nucleus.
82
+ regex = r'(\d+|\*)?([A-z][A-z]?)'
83
+
84
+ def __init__(self, element):
85
+ self.isotope = None
86
+
87
+ if isinstance(element, type(self)):
88
+ self.element = element.element
89
+ self.isotope = element.isotope
90
+
91
+ elif isinstance(element, list) or isinstance(element, tuple):
92
+ # A list, first item is the element, second (if present) is the isotope).
93
+ self.element = element[0]
94
+ if len(element) > 1:
95
+ self.isotope = element[1]
96
+
97
+ elif isinstance(element, str):
98
+ self.element, self.isotope = self.split_string(element)
99
+
100
+ elif isinstance(element, int):
101
+ self.element = periodictable.elements[element]
102
+
103
+ else:
104
+ raise TypeError("Unknown nucleus type '{}' {}".format(element, type(element)))
105
+
106
+ @classmethod
107
+ def split_string(self, string):
108
+ """
109
+ """
110
+ match = re.search(self.regex, string)
111
+
112
+ if match is None:
113
+ raise ValueError("Unable to process nucleus string '{}'".format(string))
114
+
115
+ match_groups = match.groups()
116
+ isotope = int(match_groups[0]) if match_groups[0] != "*" else None
117
+
118
+ if isinstance(match_groups[1], int):
119
+ element = periodictable.elements[match_groups[1]]
120
+
121
+ else:
122
+ element = periodictable.elements.symbol(match_groups[1])
123
+
124
+ return (element, isotope)
125
+
126
+ def __str__(self):
127
+ if self.isotope is not None:
128
+ return "{}{}".format(self.isotope, self.element.symbol)
129
+
130
+ else:
131
+ return self.element.symbol
132
+
133
+
134
+ # Molecule_mixin is used for digichem.result.atom.Atom_list and digichem.input.digichem.Digichem_coords_ABC
135
+ class Molecule_mixin():
136
+ """
137
+ Mixin for classes that represent molecules, compounds, or other molecular-like collections of atoms.
138
+
139
+ Classes that inherit from this mixin must define 'element_dict' (as either an attribute or property).
140
+ See the implementation in Atom_list for what this does.
141
+ """
142
+
143
+ # TODO: lots of opportunity to move stuff out of Atom_list and into this mixin.
144
+
145
+
146
+ @property
147
+ def formula(self):
148
+ """
149
+ Get a formula representation of this atom list.
150
+
151
+ :return: The formula as a periodictable.formula object (which can be safely cast to string).
152
+ """
153
+ # A dictionary where each key is a type of atom (N, C, H etc) and the value is the number of that atom.
154
+ atoms = self.element_dict
155
+ # Build a string rep.
156
+ form_string = ""
157
+ for atom in atoms:
158
+ form_string += "{}{}".format(atom, atoms[atom])
159
+
160
+ # Get and return the formula object.
161
+ return periodictable.formula(form_string)
162
+
163
+ @property
164
+ def formula_string(self):
165
+ """
166
+ Get a formula representation of this atom list as a string, including optional charge.
167
+ """
168
+ # Get the base formula
169
+ #formula_string = str(self.formula)
170
+ atoms = self.element_dict
171
+ # Build a string rep.
172
+ formula_string = ""
173
+ for atom in atoms:
174
+ formula_string += "{}{}".format(atom, atoms[atom])
175
+
176
+ # Add charge, if we have one.
177
+ if self.charge == 1:
178
+ formula_string += " +"
179
+ elif self.charge == -1:
180
+ formula_string += " -"
181
+ elif self.charge != 0:
182
+ formula_string += " {}{}".format(abs(self.charge), "-" if self.charge < 0 else "+")
183
+
184
+ return formula_string
185
+
186
+ @property
187
+ def molar_mass(self):
188
+ """
189
+ The molar mass of the molecule (takes into account different isotopes and relative isotope abundances, unlike the mass attribute).
190
+
191
+ :return: The mass (in Daltons / gmol-1).
192
+ """
193
+ return self.formula.mass
194
+
195
+
196
+ class Atom_list(Result_container, Unmergeable_container_mixin, Molecule_mixin):
197
+ """
198
+ Class for representing a group of atoms.
199
+ """
200
+
201
+ # A warning issued when attempting to merge non-equivalent atom lists.
202
+ MERGE_WARNING = "Attempting to merge lists of atoms that are not identical; non-equivalent atoms will be ignored"
203
+
204
+ def __init__(self, *args, charge = None, assign_groups = True, **kwargs):
205
+ """
206
+ :param charge: The electronic charge of the system.
207
+ :param assign_groups: If True (the default), assign groupings to all the atoms of this list. Call assign_groups() to re-calculate these groupings whenever atoms are added/removed.
208
+ """
209
+ super().__init__(*args, **kwargs)
210
+ self.charge = charge if charge is not None else 0
211
+ self._groups = {}
212
+
213
+ if assign_groups:
214
+ self.groups
215
+
216
+ @property
217
+ def mass(self):
218
+ """
219
+ The total mass of all the atoms in this set.
220
+
221
+ :return: The mass (in Daltons).
222
+ """
223
+ try:
224
+ return sum([atom.mass for atom in self])
225
+ except TypeError:
226
+ # Exact mass not available.
227
+ raise Result_unavailable_error("Exact mass") from None
228
+
229
+ @property
230
+ def element_dict(self):
231
+ """
232
+ Get a dictionary where each key is one of the elements in the atom list (C, H, N etc) and the value is the number of that element that appears in the atom list.
233
+
234
+ :return: The element dictionary.
235
+ """
236
+ atoms = {}
237
+ for atom in self:
238
+ # Try and increment the count of the atom.
239
+ try:
240
+ atoms[atom.element.symbol] += 1
241
+ except KeyError:
242
+ # Add the new atom.
243
+ atoms[atom.element.symbol] = 1
244
+ return atoms
245
+
246
+ @property
247
+ def groups(self):
248
+ """
249
+ Get a list of the groups of atoms in this list.
250
+
251
+ Atom groups combine chemically equivalent atom positions into a single group.
252
+ """
253
+ if self._groups == {} and len(self) > 0:
254
+ self.assign_groups()
255
+
256
+ return self._groups
257
+
258
+ def find(self, criteria = None, *, label = None, index = None):
259
+ """
260
+ Find an atom that matches a given criteria
261
+
262
+ :raises ValueError: If the requested atom could not be found.
263
+ :param criteria: Automatically determine which criteria to search by.
264
+ :param label: The label (a string, such as C1), to find.
265
+ :param index: The index (an int or string that looks like an int) of the atom
266
+ :return: The requested atom.
267
+ """
268
+ if criteria is not None:
269
+ if criteria.isdigit() or isinstance(criteria, int):
270
+ index = int(criteria)
271
+ else:
272
+ label = criteria
273
+
274
+ if index:
275
+ # Fetch by index.
276
+ try:
277
+ return self[int(index)]
278
+
279
+ except IndexError:
280
+ raise Result_unavailable_error("Atom", "could not find atom with index {}".format(index)) from None
281
+
282
+ else:
283
+ # Search by label.
284
+ try:
285
+ return [atom for atom in self if atom.label == label][0]
286
+
287
+ except IndexError:
288
+ raise Result_unavailable_error("Atom", "could not find atom with label {}".format(label)) from None
289
+
290
+ def assign_groups(self):
291
+ """
292
+ Assign groupings to all the atoms of this system.
293
+
294
+ Atom groups combine chemically equivalent atom positions into a single group.
295
+ """
296
+ group_mappings = get_chemical_group_mapping(self.to_rdkit_molecule())
297
+ self._groups = {group_id: Atom_group(group_id[0], [self[atom_index -1] for atom_index in atom_indices]) for group_id, atom_indices in group_mappings.items()}
298
+
299
+ for atom in self:
300
+ group = [group for group in self.groups.values() if atom in group.atoms][0]
301
+
302
+ atom.group = group
303
+
304
+ @property
305
+ def smiles(self):
306
+ """
307
+ Get this geometry in (canonical) SMILES format.
308
+ """
309
+ try:
310
+ return self._smiles
311
+
312
+ except AttributeError:
313
+ # Cache miss, go do some work.
314
+
315
+ from rdkit.Chem import MolToSmiles
316
+ from rdkit.Chem.rdmolops import RemoveHs
317
+
318
+ mol = self.to_rdkit_molecule()
319
+ mol = RemoveHs(mol)
320
+ self._smiles = MolToSmiles(mol)
321
+ return self._smiles
322
+
323
+ # # TODO: Handle cases where obabel isn't available
324
+ # conv = Openprattle_converter.get_cls("xyz")(input_file = self.to_xyz(), input_file_type = "xyz")
325
+ # # Cache the result in case we need it again.
326
+ # self._smiles = conv.convert("can").strip()
327
+ # return self._smiles
328
+
329
+ @property
330
+ def X_length(self):
331
+ return self.get_axis_length(0)
332
+
333
+ @property
334
+ def Y_length(self):
335
+ return self.get_axis_length(1)
336
+
337
+ @property
338
+ def Z_length(self):
339
+ return self.get_axis_length(2)
340
+
341
+ def get_axis_length(self, axis):
342
+ """
343
+ Calculate the length of an axis, defined as the distance required in that axis to contain all the atoms of the set.
344
+
345
+ :param axis: The axis to calculate for as an integer (0: X-axis, 1: Y-axis, 2: Z-axis).
346
+ :return The length (in angstroms).
347
+ """
348
+ if not 0 <= axis <= 2:
349
+ # Axis is invalid.
350
+ raise ValueError("Axis '{}' is out of bounds. Possible values are 0 (X), 1 (Y) or 2 (Z)")
351
+
352
+ # First sort our list of atoms in terms of x, y or z coord.
353
+ sorted_atoms = sorted(self, key = lambda atom: atom.coords[axis])
354
+
355
+ # Now the axis length is simply the difference between the greatest and the smallest.
356
+ try:
357
+ return sorted_atoms[-1].coords[axis] - sorted_atoms[0].coords[axis]
358
+
359
+ except IndexError:
360
+ if len(sorted_atoms) == 0:
361
+ # There are not atoms.
362
+ return 0.0
363
+
364
+ else:
365
+ raise
366
+
367
+ def get_linear_ratio(self):
368
+ """
369
+ Get the linear ratio of the molecule.
370
+
371
+ The linear ratio is defined as 1 - (Y_length / X_length).
372
+
373
+ :return: The ratio, from 0 (non-linear) to 1 (linear).
374
+ """
375
+ try:
376
+ return 1- (self.Y_length / self.X_length)
377
+
378
+ except (FloatingPointError, ZeroDivisionError):
379
+ return 0
380
+
381
+ def get_planar_ratio(self):
382
+ """
383
+ Get the planar ratio of the molecule.
384
+
385
+ The planar ratio is defined as 1 - (Z_length / Y_length).
386
+
387
+ :return: The ratio, from 0 (non-planar) to 1 (planar).
388
+ """
389
+ try:
390
+ return 1- (self.Z_length / self.Y_length)
391
+
392
+ except (FloatingPointError, ZeroDivisionError):
393
+ return 0
394
+
395
+ def get_X_axis_angle(self, start_coord, end_coord):
396
+ """
397
+ Get the angle between a line and the X axis.
398
+
399
+ :param start_coord: A (X, Y, Z) tuple of coordinates of the start of the line.
400
+ :param end_coord: A (X, Y, Z) tuple of coordinates of the end of the line.
401
+ :return: The angle (in radians).
402
+ """
403
+ return self.get_theta(math.sqrt( (end_coord[2] - start_coord[2])**2 + (end_coord[1] - start_coord[1])**2 ), end_coord[0] - start_coord[0])
404
+
405
+ def get_XY_plane_angle(self, start_coord, end_coord):
406
+ """
407
+ Get the angle between a line and the XY plane.
408
+
409
+ :param start_coord: A (X, Y, Z) tuple of coordinates of the start of the line.
410
+ :param end_coord: A (X, Y, Z) tuple of coordinates of the end of the line.
411
+ :return: The angle (in radians).
412
+ """
413
+ # The 'secondary' axis is the opposite side of our triangle.
414
+ secondary_axis = end_coord[2] - start_coord[2]
415
+ # The 'primary' axis is the adjacent side of our triangle, which we can get with pythagoras.
416
+ primary_axis = math.sqrt( (end_coord[0] - start_coord[0])**2 + (end_coord[1] - start_coord[1])**2 )
417
+ return self.get_theta(secondary_axis, primary_axis)
418
+
419
+ @classmethod
420
+ def from_parser(self, parser):
421
+ """
422
+ Get an Atom_list object from an output file parser.
423
+
424
+ :param parser: An output file parser.
425
+ :param charge: Charge of the system.
426
+ :return: A list of TDM objects.
427
+ """
428
+ return self(Atom.list_from_parser(parser), charge = parser.results.metadata.charge)
429
+
430
+ @classmethod
431
+ def from_dump(self, data, result_set, options):
432
+ """
433
+ Get an instance of this class from its dumped representation.
434
+
435
+ :param data: The data to parse.
436
+ :param result_set: The partially constructed result set which is being populated.
437
+ """
438
+ return self(Atom.list_from_dump(data['values'], result_set, options), charge = data['charge'])
439
+
440
+ @classmethod
441
+ def from_coords(self, coords):
442
+ """
443
+ Get an instance of this class from a Digichem input coordinates object.
444
+
445
+ :param coords: Digichem input coords.
446
+ """
447
+ return self(Atom.list_from_coords(coords), charge = coords.charge)
448
+
449
+ def dump(self, Digichem_options):
450
+ """
451
+ Get a representation of this result object in primitive format.
452
+ """
453
+ dump_dict = {
454
+ "formula": self.formula_string,
455
+ "charge": self.charge,
456
+ "smiles": self.smiles,
457
+ "exact_mass": {
458
+ "value": float(self.mass) if self.safe_get("mass") is not None else None,
459
+ "units": "g mol^-1"
460
+ },
461
+ "molar_mass": {
462
+ "value": self.molar_mass,
463
+ "units": "g mol^-1",
464
+ },
465
+ "num_atoms": len(self),
466
+ "x-extension": {
467
+ "value": float(self.X_length),
468
+ "units": "Å"
469
+ },
470
+ "y-extension": {
471
+ "value": float(self.Y_length),
472
+ "units": "Å"
473
+ },
474
+ "z-extension": {
475
+ "value": float(self.Z_length),
476
+ "units": "Å"
477
+ },
478
+ "linearity_ratio": float(self.get_linear_ratio()),
479
+ "planarity_ratio": float(self.get_planar_ratio()),
480
+ "values": super().dump(Digichem_options),
481
+ }
482
+ return dump_dict
483
+
484
+ @classmethod
485
+ def merge(self, *multiple_lists, charge):
486
+ """
487
+ Merge multiple lists of atoms into a single list.
488
+
489
+ Note that it does not make logical sense to combine different list of atoms into one; hence the method only ensures that all given lists (which are not empty) are the same and then returns the first (non empty) given.
490
+ If the atom lists are not equivalent, a warning will be issued.
491
+ """
492
+ return super().merge(*multiple_lists, charge = charge)
493
+
494
+ def to_xyz(self):
495
+ """
496
+ Convert this list of atoms to xyz format.
497
+ """
498
+ # First, the number of atoms.
499
+ xyz = "{}\n\n".format(len(self))
500
+
501
+ # Then coordinates.
502
+ # No effort is made here to truncate coordinates to a certain precision.
503
+ for atom in self:
504
+ xyz += "{} {:f} {:f} {:f}\n".format(atom.element.symbol, atom.coords[0], atom.coords[1], atom.coords[2])
505
+
506
+ return xyz
507
+
508
+ def to_mol(self):
509
+ """
510
+ Convert this list of atoms to mol format (useful for reading with rdkit).
511
+ """
512
+ return Openprattle_converter.get_cls("xyz")(input_file = self.to_xyz(), input_file_path = "internal atoms object", input_file_type = "xyz").convert("mol", charge = self.charge)
513
+
514
+ def to_rdkit_molecule(self):
515
+ """
516
+ Convert this list of atoms to an rdkit molecule object.
517
+ """
518
+ from rdkit import Chem
519
+ from rdkit.Chem import rdDetermineBonds
520
+
521
+ # RDKit has a lot of problems loading molecule information from other formats.
522
+ # Loading from Mol specifies atom and bonding types, but rdkit is very fragile when it comes to
523
+ # which bonds it considers allowed (tetravelent nitrogen is not allowed for example). This can
524
+ # be 'fixed'/ignored by specifying sanitize = False, but then lots of other rdkit functions don't
525
+ # work properly, and rdkit does not detect symmetry correctly.
526
+ # Loading from SMILES is a bad idea because hydrogens are lost.
527
+ # Loading from xyz is much more robust and maintains Hs, but no bond information is read
528
+ # (because there isn't any). RDkit can try and determine bonding itself with DetermineBonds(),
529
+ # but this function is also fragile.
530
+
531
+ # NOTE: rdkit can fail silently and return None, best to check.
532
+ # mol = Chem.MolFromMolBlock(self.to_mol(), removeHs = False, sanitize = False)
533
+
534
+
535
+ # parse_settings = Chem.rdmolfiles.SmilesParserParams()
536
+ # parse_settings.removeHs = False
537
+ # mol = Chem.MolFromSmiles(self.smiles, parse_settings)
538
+ #
539
+ mol = Chem.MolFromXYZBlock(self.to_xyz())
540
+ if mol is None:
541
+ raise Exception("Failed to parse coordinates with rdkit")
542
+
543
+ mol.UpdatePropertyCache()
544
+ rdDetermineBonds.DetermineConnectivity(mol, charge = self.charge)
545
+ try:
546
+ rdDetermineBonds.DetermineBonds(mol, charge = self.charge)
547
+
548
+ except Exception:
549
+ # This function is not implemented for some atoms (eg, Se).
550
+ digichem.log.get_logger().warning(
551
+ "Unable to determine bond ordering for molecule; all bonds will be represented as single bonds only".format(self.formula_string)
552
+ , exc_info = True
553
+ )
554
+
555
+ return mol
556
+
557
+
558
+ class Atom_ABC(Result_object):
559
+ """
560
+ ABC for atom-like result objects.
561
+ """
562
+
563
+ @property
564
+ def label(self):
565
+ return "{}{}".format(self.element, self.index)
566
+
567
+ def __hash__(self):
568
+ return hash(self.index)
569
+
570
+
571
+ class Atom_group(Atom_ABC):
572
+ """
573
+ A class that represents a group of atoms that are chemically equivalent.
574
+ """
575
+
576
+ def __init__(self, index, atoms):
577
+ self.index = index
578
+ self.atoms = atoms
579
+ # Check all elements are the same.
580
+ self.element
581
+
582
+ @property
583
+ def element(self):
584
+ elements = list(set(atom.element for atom in self.atoms))
585
+
586
+ if len(elements) > 1:
587
+ raise Digichem_exception("Multiple element types found in atom group '{}'".format(elements))
588
+
589
+ return elements[0]
590
+
591
+ @property
592
+ def id(self):
593
+ return (self.index, self.element.symbol)
594
+
595
+ @property
596
+ def label(self):
597
+ return "{}{}".format(self.element, self.index)
598
+
599
+ def __str__(self):
600
+ """
601
+ Stringify this atom group.
602
+ """
603
+ return "G:{}".format(self.label)
604
+
605
+ def __eq__(self, other):
606
+ """
607
+ Is this atom group equal to another?
608
+ """
609
+ return self.atoms == other.atoms
610
+
611
+ def __hash__(self):
612
+ return super().__hash__()
613
+
614
+
615
+ class Atom(Atom_ABC):
616
+ """
617
+ Class that represents an atom.
618
+ """
619
+
620
+ def __init__(self, index, atomic_number, coords, mass = None):
621
+ """
622
+ Construct an Atom class.
623
+
624
+ :param index: The numerical index of this atom, starting from 1.
625
+ :param atomic_number: The atomic/proton number of the atom. Conventional wisdom suggests this has to be an integer, but we make no check here.
626
+ :param mass: The mass of the atom (in Daltons). This isn't always available for some reason (eg, Gaussian Freq calculations), but when it is it identifies the isotope of the given atom.
627
+ :param coords: The coords of the atom, as an (x, y, z) tuple.
628
+ """
629
+ # Just save each of our attributes.
630
+ self.index = index
631
+ self.mass = mass
632
+ self.coords = coords
633
+ # Get our element class.
634
+ self.element = periodictable.elements[atomic_number]
635
+ # Atom groups are assigned by Atom_list objects.
636
+ self.group = None
637
+
638
+ def __str__(self):
639
+ """
640
+ Stringify this atom.
641
+ """
642
+ return "'{}' at '{}'".format(self.label, self.coords)
643
+
644
+ def __eq__(self, other):
645
+ """
646
+ Is this atom equal to another?
647
+ """
648
+ # Atoms are considered equal if they are the same element in the same position.
649
+ #return self.element == other.element and self.coords == other.coords
650
+ return self.element == other.element and self.index == other.index
651
+
652
+ def __hash__(self):
653
+ return super().__hash__()
654
+
655
+ def distance(self, foreign_atom):
656
+ """
657
+ Get the distance between this atom and another atom.
658
+
659
+ :return: The distance. The units depend on the units of the atoms' coordinates. If the two atoms have coordinates of different units, then you will obviously get bizarre results.
660
+ """
661
+ return math.sqrt( (self.coords[0] - foreign_atom.coords[0])**2 + (self.coords[1] - foreign_atom.coords[1])**2 + (self.coords[2] - foreign_atom.coords[2])**2)
662
+
663
+ def dump(self, Digichem_options):
664
+ """
665
+ Get a representation of this result object in primitive format.
666
+ """
667
+ dump_dict = {
668
+ "index": self.index,
669
+ "element": self.element.number,
670
+ "label": self.label,
671
+ "group": self.group.label if self.group else None,
672
+ "coords": {
673
+ "x": {
674
+ "value": float(self.coords[0]),
675
+ "units": "Å",
676
+ },
677
+ "y": {
678
+ "value": float(self.coords[1]),
679
+ "units": "Å",
680
+ },
681
+ "z": {
682
+ "value": float(self.coords[2]),
683
+ "units": "Å",
684
+ }
685
+ },
686
+ "mass": {
687
+ "value": float(self.mass) if self.mass is not None else None,
688
+ "units": "g mol^-1"
689
+ }
690
+ }
691
+ return dump_dict
692
+
693
+ @classmethod
694
+ def list_from_dump(self, data, result_set, options):
695
+ """
696
+ Get a list of instances of this class from its dumped representation.
697
+
698
+ :param data: The data to parse.
699
+ :param result_set: The partially constructed result set which is being populated.
700
+ """
701
+ return [self(atom_dict.get('index', index +1), atom_dict['element'], (atom_dict['coords']['x']['value'], atom_dict['coords']['y']['value'], atom_dict['coords']['z']['value']), atom_dict['mass']['value']) for index, atom_dict in enumerate(data)]
702
+
703
+ @classmethod
704
+ def list_from_parser(self, parser):
705
+ """
706
+ Get a list of Atom objects from an output file parser.
707
+
708
+ :param parser: An output file parser.
709
+ :result: A list of Atom objects. An empty list is returned if no atom data is available.
710
+ """
711
+ # First pack our data together to make is easier to loop through.
712
+ try:
713
+ atomnos = parser.data.atomnos
714
+ # Atom coords contains a list for each iteration, we only want the last.
715
+ atomcoords = parser.data.atomcoords[-1]
716
+ atommasses = getattr(parser.data, 'atommasses', [])
717
+
718
+ # Atommasses sometimes is longer than atomcoords or atomnos.
719
+ # This might be a cclib bug, it is not clear.
720
+ if len(atommasses) > len(atomnos):
721
+ # Take the last values only.
722
+ atommasses = atommasses[-len(atomnos):]
723
+
724
+ except AttributeError:
725
+ # No atom data available.
726
+ return []
727
+
728
+ # Zip.
729
+ zip_data = zip_longest(atomnos, atomcoords, atommasses, fillvalue = None)
730
+
731
+ # Loop through and rebuild our objects.
732
+ return [self(index+1, atomic_number, tuple(coords), mass) for index, (atomic_number, coords, mass) in enumerate(zip_data)]
733
+
734
+ @classmethod
735
+ def list_from_coords(self, coords):
736
+ """
737
+ Get a list of Atom objects from a Digichem input coordinates object.
738
+
739
+ :param coords: Digichem input coords.
740
+ :result: A list of Atom objects. An empty list is returned if no atom data is available.
741
+ """
742
+ return [self(index+1, getattr(periodictable.elements, atom['atom']).number, (atom["x"], atom["y"], atom["z"])) for index, atom in enumerate(coords.atoms)]