TCMU 0.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. tcmu/__init__.py +85 -0
  2. tcmu/analysis/__init__.py +0 -0
  3. tcmu/analysis/pyfrag.py +175 -0
  4. tcmu/analysis/task_specific/__init__.py +0 -0
  5. tcmu/analysis/task_specific/irc.py +96 -0
  6. tcmu/analysis/vdd/__init__.py +0 -0
  7. tcmu/analysis/vdd/charge.py +12 -0
  8. tcmu/analysis/vdd/manager.py +221 -0
  9. tcmu/analysis/vibration/__init__.py +0 -0
  10. tcmu/analysis/vibration/ts_vibration.py +149 -0
  11. tcmu/cache.py +188 -0
  12. tcmu/cite.py +326 -0
  13. tcmu/cli_scripts/__init__.py +0 -0
  14. tcmu/cli_scripts/cite.py +308 -0
  15. tcmu/cli_scripts/concatenate_irc.py +44 -0
  16. tcmu/cli_scripts/geo.py +101 -0
  17. tcmu/cli_scripts/job_script.py +57 -0
  18. tcmu/cli_scripts/read.py +34 -0
  19. tcmu/cli_scripts/resize_figures.py +58 -0
  20. tcmu/cli_scripts/tcparser.py +23 -0
  21. tcmu/cli_scripts/workflow.py +207 -0
  22. tcmu/connect.py +492 -0
  23. tcmu/constants.py +6 -0
  24. tcmu/data/__init__.py +0 -0
  25. tcmu/data/_atom_data_info/__init__.py +0 -0
  26. tcmu/data/_convert_to_json.py +8 -0
  27. tcmu/data/_read_BS_size.py +46 -0
  28. tcmu/data/atom.py +124 -0
  29. tcmu/data/basis_sets.py +85 -0
  30. tcmu/data/cosmo.py +99 -0
  31. tcmu/data/functionals.py +269 -0
  32. tcmu/data/molecules.py +27 -0
  33. tcmu/environment.py +28 -0
  34. tcmu/errors.py +79 -0
  35. tcmu/formula.py +111 -0
  36. tcmu/geometry.py +699 -0
  37. tcmu/job/__init__.py +0 -0
  38. tcmu/job/adf.py +1071 -0
  39. tcmu/job/ams.py +359 -0
  40. tcmu/job/crest.py +392 -0
  41. tcmu/job/dftb.py +82 -0
  42. tcmu/job/generic.py +502 -0
  43. tcmu/job/models.py +156 -0
  44. tcmu/job/nmr.py +95 -0
  45. tcmu/job/orca.py +289 -0
  46. tcmu/job/postscripts/__init__.py +0 -0
  47. tcmu/job/postscripts/clean_workdir.py +19 -0
  48. tcmu/job/postscripts/split_crest_xyz.py +15 -0
  49. tcmu/job/postscripts/write_converged_geoms.py +34 -0
  50. tcmu/job/workflow.py +481 -0
  51. tcmu/job/workflow3.py +441 -0
  52. tcmu/job/workflow_db.py +179 -0
  53. tcmu/job/workflow_status.py +23 -0
  54. tcmu/job/xtb.py +159 -0
  55. tcmu/log.py +527 -0
  56. tcmu/molecule.py +404 -0
  57. tcmu/pathfunc.py +214 -0
  58. tcmu/report/__init__.py +0 -0
  59. tcmu/report/_generate_font_widths.py +45 -0
  60. tcmu/report/character.py +97 -0
  61. tcmu/report/figure_resizer.py +174 -0
  62. tcmu/report/formatters/__init__.py +0 -0
  63. tcmu/report/formatters/generic.py +9 -0
  64. tcmu/report/formatters/xyz.py +124 -0
  65. tcmu/report/report.py +667 -0
  66. tcmu/results/__init__.py +0 -0
  67. tcmu/results/adf.py +453 -0
  68. tcmu/results/ams.py +690 -0
  69. tcmu/results/cache.py +79 -0
  70. tcmu/results/crest.py +330 -0
  71. tcmu/results/dftb.py +90 -0
  72. tcmu/results/orca.py +559 -0
  73. tcmu/results/read.py +186 -0
  74. tcmu/results/result.py +219 -0
  75. tcmu/results/xtb.py +441 -0
  76. tcmu/slurm.py +162 -0
  77. tcmu/spell_check.py +176 -0
  78. tcmu/timer.py +187 -0
  79. tcmu/typing_utilities.py +25 -0
  80. tcmu-0.17.0.dist-info/METADATA +111 -0
  81. tcmu-0.17.0.dist-info/RECORD +85 -0
  82. tcmu-0.17.0.dist-info/WHEEL +5 -0
  83. tcmu-0.17.0.dist-info/entry_points.txt +2 -0
  84. tcmu-0.17.0.dist-info/licenses/LICENSE +21 -0
  85. tcmu-0.17.0.dist-info/top_level.txt +1 -0
tcmu/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ # Job imports
2
+ from tcmu.cache import cache, cache_file, timed_cache
3
+ from tcmu import log
4
+ from tcmu.analysis.pyfrag import PyFragResult, get_pyfrag_results
5
+ from tcmu.analysis.task_specific.irc import concatenate_irc_trajectories
6
+ from tcmu.analysis.vdd.charge import VDDCharge
7
+
8
+ # from tcmu.analysis.vdd.manager import VDDChargeManager, create_vdd_charge_manager # Don't load in vdd as it has an annoying pandas dependency that cannot be avoided upon importing
9
+ from tcmu.analysis.vibration.ts_vibration import avg_relative_bond_length_delta, determine_ts_reactioncoordinate, validate_transitionstate
10
+ from tcmu.cite import cite, _get_doi_data, _get_doi_data_from_title, _get_doi_data_from_query, _get_publisher_city, _get_journal_abbreviation
11
+ from tcmu.connect import Connection, Local, Server, ServerFile
12
+ from tcmu.data.functionals import categories, functional_name_from_path_safe_name, functionals, get_available_functionals, get_functional
13
+ from tcmu.environment import requires_optional_package
14
+ from tcmu.geometry import KabschTransform, MolTransform, Transform, apply_rotmat, get_rotmat, rotate, rotmat_to_angles, vector_align_rotmat
15
+ from tcmu.results.read import get_info, quick_status, read, get_timing
16
+ from tcmu.results.result import Result
17
+ from tcmu.job.workflow import WorkFlow
18
+ from tcmu.job import workflow_db, workflow_status
19
+ from tcmu.job.adf import ADFFragmentJob, ADFJob, DensfJob
20
+ from tcmu.job.ams import AMSJob
21
+ from tcmu.job.crest import CRESTJob, QCGJob
22
+ from tcmu.job.dftb import DFTBJob
23
+ from tcmu.job.nmr import NMRJob
24
+ from tcmu.job.orca import ORCAJob, GOATJob
25
+ from tcmu.job.xtb import XTBJob
26
+ from tcmu.molecule import from_string, guess_fragments, load, number_of_electrons, save, write_mol_to_amv_file, write_mol_to_xyz_file
27
+ # from tcmu.report.report import SI
28
+ from tcmu.timer import timer
29
+
30
+ __all__ = [
31
+ "ADFFragmentJob",
32
+ "ADFJob",
33
+ "DensfJob",
34
+ "AMSJob",
35
+ "CRESTJob",
36
+ "QCGJob",
37
+ "DFTBJob",
38
+ "NMRJob",
39
+ "ORCAJob",
40
+ "GOATJob",
41
+ "XTBJob",
42
+ "log",
43
+ "from_string",
44
+ "guess_fragments",
45
+ "load",
46
+ "number_of_electrons",
47
+ "save",
48
+ "write_mol_to_amv_file",
49
+ "write_mol_to_xyz_file",
50
+ "get_info",
51
+ "quick_status",
52
+ "read",
53
+ "Result",
54
+ "timer",
55
+ "Connection",
56
+ "Local",
57
+ "Server",
58
+ "ServerFile",
59
+ "cache",
60
+ "cache_file",
61
+ "cite",
62
+ "requires_optional_package",
63
+ "Transform",
64
+ "KabschTransform",
65
+ "MolTransform",
66
+ "get_rotmat",
67
+ "rotmat_to_angles",
68
+ "apply_rotmat",
69
+ "rotate",
70
+ "vector_align_rotmat",
71
+ "PyFragResult",
72
+ "get_pyfrag_results",
73
+ "concatenate_irc_trajectories",
74
+ "VDDCharge",
75
+ "avg_relative_bond_length_delta",
76
+ "determine_ts_reactioncoordinate",
77
+ "validate_transitionstate",
78
+ "categories",
79
+ "functionals",
80
+ "functional_name_from_path_safe_name",
81
+ "get_functional",
82
+ "get_available_functionals",
83
+ # "SI",
84
+ "timed_cache",
85
+ ]
File without changes
@@ -0,0 +1,175 @@
1
+ import os
2
+ from typing import List, Union
3
+
4
+ import numpy as np
5
+ from scm import plams
6
+
7
+ import tcmu
8
+ from tcmu.cache import cache
9
+ from tcmu.environment import requires_optional_package
10
+ from tcmu.geometry import parameter
11
+ from tcmu.pathfunc import match
12
+
13
+
14
+ def get_pyfrag_results(path):
15
+ return PyFragResult(path)
16
+
17
+
18
+ class PyFragResult:
19
+ def __init__(self, path, step_prefix: str = "Step."):
20
+ self.path = path
21
+
22
+ self._frag_results = {}
23
+ self._step_results = []
24
+ self._order = []
25
+ self._mask = []
26
+ self._properties = {}
27
+
28
+ self.step_prefix = step_prefix
29
+
30
+ self._load()
31
+
32
+ def _load(self):
33
+ for frag_path, info in tcmu.log.loadbar(match(self.path, "frag_{frag}").items()):
34
+ self._frag_results[info.frag] = tcmu.read(frag_path)
35
+
36
+ for step_path, info in tcmu.log.loadbar(match(self.path, self.step_prefix + "{step}", sort_by="step").items()):
37
+ self._step_results.append({})
38
+ for dir_name in os.listdir(step_path):
39
+ p = os.path.join(step_path, dir_name)
40
+ res = tcmu.read(p)
41
+ self._step_results[-1][dir_name] = res
42
+
43
+ self._order.append(int(info.step.removeprefix("0")))
44
+ self._mask.append(True)
45
+
46
+ self._mask = np.array(self._mask)
47
+
48
+ def get_property(self, key: str, calc: str = "complex"):
49
+ if key in self._properties:
50
+ return self._properties[key][self._order][self._mask[self._order]]
51
+ p = np.array([res[calc].properties.get_multi_key(key) for res in self._step_results])
52
+ return p[self._order][self._mask[self._order]]
53
+
54
+ @property
55
+ def fragments(self):
56
+ return list(self._frag_results.keys())
57
+
58
+ def __len__(self):
59
+ return sum(self._mask)
60
+
61
+ def get_geometry(self, *args, **kwargs):
62
+ calc = kwargs.pop("calc", "complex")
63
+ g = np.array([parameter(res[calc].molecule.input, *args, **kwargs) for res in self._step_results])
64
+ return g[self._order][self._mask[self._order]]
65
+
66
+ def get_molecules(self, calc: str = "complex") -> List[plams.Molecule]:
67
+ mols = [res[calc].molecule.input for res in self._step_results]
68
+ return mols
69
+ return [mols[int(i)] for i in np.array(self._order)[self._mask[self._order]]]
70
+
71
+ def sort_by(self, val: Union[str, List], calc: str = "complex"):
72
+ if isinstance(val, str):
73
+ val = self.get_property(val, calc=calc)
74
+ self._order = np.argsort(val)
75
+
76
+ def set_mask(self, mask: list):
77
+ self._mask = mask
78
+
79
+ def set_coord(self, coord: list):
80
+ self._coord = coord
81
+
82
+ def coord(self):
83
+ return self._coord[self._order][self._mask[self._order]]
84
+
85
+ def set_property(self, name: str, vals):
86
+ self._properties[name] = vals
87
+
88
+ @property
89
+ def ts_idx(self):
90
+ energies = self.total_energy()
91
+ for i in range(len(self) - 2, 1, -1):
92
+ if energies[i - 1] < energies[i] and energies[i] < energies[i + 1]:
93
+ return i + 1
94
+
95
+ def total_energy(self):
96
+ return self.interaction_energy() + self.strain_energy()
97
+
98
+ def interaction_energy(self):
99
+ return self.get_property("energy.bond", "complex")
100
+
101
+ def strain_energy(self, fragment: str = ""):
102
+ if fragment is None:
103
+ t = 0
104
+ for frag in self.fragments:
105
+ t += self.strain_energy(frag)
106
+ return t
107
+
108
+ return self.get_property("energy.bond", f"frag_{fragment}") - self._frag_results[fragment].properties.energy.bond
109
+
110
+ @requires_optional_package("pyfmo")
111
+ def overlap(self, orb1, orb2, calc: str = "complex", absolute: bool = True):
112
+ S = np.array([orb.sfos[orb1] @ orb.sfos[orb2] for orb in self.orbs(calc)])
113
+ if absolute:
114
+ S = abs(S)
115
+ return S[self._order][self._mask[self._order]]
116
+
117
+ @requires_optional_package("pyfmo")
118
+ def orbital_energy_gap(self, orb1, orb2, calc: str = "complex"):
119
+ dE = np.array([abs(orb.sfos[orb1].energy - orb.sfos[orb2].energy) for orb in self.orbs(calc)])
120
+ return dE[self._order][self._mask[self._order]]
121
+
122
+ @requires_optional_package("pyfmo")
123
+ def sfo_coefficient(self, sfo, mo, calc: str = "complex"):
124
+ C = np.array([orb.sfos[sfo].coefficient(orb.mos[mo]) for orb in self.orbs(calc)])
125
+ return C[self._order][self._mask[self._order]]
126
+
127
+ @cache
128
+ @requires_optional_package("pyfmo")
129
+ def orbs(self, calc: str = "complex"):
130
+ import pyfmo
131
+
132
+ return [pyfmo.Orbitals(res[calc].files["adf.rkf"]) for res in self._step_results]
133
+
134
+
135
+ if __name__ == "__main__":
136
+ import matplotlib.pyplot as plt
137
+
138
+ res_C = get_pyfrag_results("/Users/yumanhordijk/PhD/Projects/RadicalAdditionASMEDA/data/DFT/TS_C_O/PyFrag_OLYP_TZ2P")
139
+ res_X = get_pyfrag_results("/Users/yumanhordijk/PhD/Projects/RadicalAdditionASMEDA/data/DFT/TS_X_O/PyFrag_OLYP_TZ2P")
140
+
141
+ res_C.set_coord(res_C.get_geometry(0, 1))
142
+ res_X.set_coord(res_X.get_geometry(1, 2))
143
+
144
+ res_C.sort_by(res_C.coord())
145
+ res_X.sort_by(res_X.coord())
146
+
147
+ plt.figure()
148
+ plt.plot(res_C.coord(), res_C.overlap("Methyl(SOMO)_A", "Substrate(LUMO)_A"), label="C-addition")
149
+ plt.plot(res_X.coord(), res_X.overlap("Methyl(SOMO)_A", "Substrate(LUMO)_A"), label="X-addition")
150
+ plt.ylabel(r"$\langle SOMO | LUMO \rangle$")
151
+ plt.legend()
152
+ plt.xlim(3, 2.2)
153
+
154
+ plt.figure()
155
+ plt.plot(res_C.coord(), res_C.orbital_energy_gap("Methyl(SOMO)_A", "Substrate(LUMO)_A"), label="C-addition")
156
+ plt.plot(res_X.coord(), res_X.orbital_energy_gap("Methyl(SOMO)_A", "Substrate(LUMO)_A"), label="X-addition")
157
+ plt.ylabel(r"$|\epsilon_{SOMO} - \epsilon_{LUMO}|$")
158
+ plt.legend()
159
+ plt.xlim(3, 2.2)
160
+
161
+ plt.figure()
162
+ plt.plot(res_C.coord(), res_C.overlap("Methyl(LUMO)_B", "Substrate(7A_B)_B"), label="C-addition")
163
+ plt.plot(res_X.coord(), res_X.overlap("Methyl(LUMO)_B", "Substrate(7A_B)_B"), label="X-addition")
164
+ plt.ylabel(r"$\langle SUMO | HOMO \rangle$")
165
+ plt.legend()
166
+ plt.xlim(3, 2.2)
167
+
168
+ plt.figure()
169
+ plt.plot(res_C.coord(), res_C.orbital_energy_gap("Methyl(LUMO)_B", "Substrate(7A_B)_B"), label="C-addition")
170
+ plt.plot(res_X.coord(), res_X.orbital_energy_gap("Methyl(LUMO)_B", "Substrate(7A_B)_B"), label="X-addition")
171
+ plt.ylabel(r"$|\epsilon_{SUMO} - \epsilon_{HOMO}|$")
172
+ plt.legend()
173
+ plt.xlim(3, 2.2)
174
+
175
+ plt.show()
File without changes
@@ -0,0 +1,96 @@
1
+ import pathlib as pl
2
+ from typing import List, Tuple, Union
3
+
4
+ import numpy as np
5
+ from scm.plams import Molecule
6
+
7
+ from tcmu.log import log
8
+ from tcmu.results.read import read
9
+ from tcmu.results.result import Result
10
+
11
+
12
+ def create_result_objects(job_dirs: Union[List[str], List[pl.Path]]) -> List[Result]:
13
+ """Creates a list of Result objects from a list of directories."""
14
+ return [read(pl.Path(file)) for file in job_dirs]
15
+
16
+
17
+ def _get_converged_energies(res: Result) -> List[float]:
18
+ """Returns a list of energies of the converged geometries."""
19
+ return [energy for converged, energy in zip(res.history.converged, res.history.energy) if converged] # type: ignore
20
+
21
+
22
+ def _get_converged_molecules(res: Result) -> List[Molecule]:
23
+ """Returns a list of molecules of the converged geometries."""
24
+ return [mol for converged, mol in zip(res.history.converged, res.history.molecule) if converged] # type: ignore
25
+
26
+
27
+ def _concatenate_irc_trajectories_by_rmsd(irc_trajectories: List[List[Molecule]], energies: Union[List[List[float]], None]) -> Tuple[List[Molecule], List[float]]:
28
+ """
29
+ Concatenates lists of molecules by comparing the RMSD values of the end and beginnings of the trajectory.
30
+ The entries that are closest to each other are used to concatenate the trajectories.
31
+
32
+ Parameters:
33
+ irc_trajectories: A list of lists of Molecule objects representing the trajectories.
34
+ energies: A list of lists of float values representing the energies.
35
+
36
+ Returns:
37
+ A tuple containing a list of Molecule objects and a list of energies.
38
+
39
+ Raises:
40
+ ValueError: If the RMSD values are not as expected.
41
+ """
42
+ concatenated_mols: List[Molecule] = irc_trajectories[0][::-1]
43
+ concatenated_energies: List[float] = energies[0][::-1] if energies is not None else []
44
+
45
+ for traj_index in range(len(irc_trajectories) - 1):
46
+ # Calculate RMSD values of two connected trajectories to compare the connection points / molecules
47
+ rmsd_matrix = np.array([[Molecule.rmsd(irc_trajectories[traj_index][i], irc_trajectories[traj_index + 1][j]) for j in [0, -1]] for i in [0, -1]])
48
+
49
+ # Flatten the matrix and find the index of the minimum value
50
+ lowest_index = np.argmin(rmsd_matrix.flatten())
51
+
52
+ log(f"Lowest RMSD values: {rmsd_matrix.flatten()}", 10)
53
+
54
+ # Starting points are connected
55
+ if lowest_index == 0:
56
+ concatenated_mols += irc_trajectories[traj_index + 1][1:]
57
+ concatenated_energies += energies[traj_index + 1][1:] if energies is not None else []
58
+ # Ending points are connected
59
+ elif lowest_index == 1:
60
+ concatenated_mols += irc_trajectories[traj_index + 1][::-1]
61
+ concatenated_energies += energies[traj_index + 1][::-1] if energies is not None else []
62
+ # Something went wrong
63
+ else:
64
+ raise ValueError(f"The RMSD values are not as expected: {rmsd_matrix.flatten()} with {lowest_index=}.")
65
+
66
+ return concatenated_mols, concatenated_energies
67
+
68
+
69
+ def concatenate_irc_trajectories(result_objects: List[Result], reverse: bool = False) -> Tuple[List[Molecule], List[float]]:
70
+ """
71
+ Concatenates trajectories from irc calculations, often being forward and backward, through the RMSD values.
72
+
73
+ Parameters:
74
+ job_dirs: A list of directories containing the ams.rkf files.
75
+ user_log_level: The log level set by the user.
76
+ reverse: A boolean indicating whether to reverse the trajectory. Default is False.
77
+
78
+ Returns:
79
+ A tuple containing a list of Molecule objects and a list of energies.
80
+
81
+ Raises:
82
+ Exception: If an exception is raised in the try block, it is caught and printed.
83
+ """
84
+ traj_geometries: List[List[Molecule]] = [[] for _ in result_objects]
85
+ traj_energies: List[List[float]] = [[] for _ in result_objects]
86
+
87
+ for i, res_obj in enumerate(result_objects):
88
+ traj_geometries[i] = _get_converged_molecules(res_obj)
89
+ traj_energies[i] = _get_converged_energies(res_obj)
90
+
91
+ concatenated_mols, concatenated_energies = _concatenate_irc_trajectories_by_rmsd(traj_geometries, traj_energies)
92
+
93
+ if reverse:
94
+ concatenated_mols = concatenated_mols[::-1]
95
+ concatenated_energies = concatenated_energies[::-1]
96
+ return concatenated_mols, concatenated_energies
File without changes
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class VDDCharge:
6
+ atom_index: int # index of the atom in the molecules
7
+ atom_symbol: str # symbol of the atom
8
+ charge: float # mili-electrons
9
+ frag_index: int # index of the fragment in the fragments list
10
+
11
+ def change_unit(self, ratio: float):
12
+ self.charge *= ratio
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.util import find_spec
4
+
5
+ from tcmu import environment, errors
6
+
7
+ if find_spec("pandas") is None:
8
+ raise errors.TCMUMissingOptionalPackageError("pandas")
9
+
10
+ import copy
11
+ import pathlib as pl
12
+ from itertools import zip_longest
13
+ from typing import Dict, List, Literal, Optional, Sequence, Set, Union
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from tcmu.analysis.vdd import charge
19
+ from tcmu.constants import VDD_UNITS
20
+ from tcmu.results import result
21
+
22
+ PRINT_FORMAT = {"me": "%+.0f", "e": "%+.3f"}
23
+
24
+ __all__ = ["VDDChargeManager", "create_vdd_charge_manager"]
25
+
26
+
27
+ def create_vdd_charge_manager(results: result.Result) -> VDDChargeManager:
28
+ """Create a VDDChargeManager from a Result object."""
29
+ vdd_charges: Dict[str, List[charge.VDDCharge]] = {}
30
+ atom_symbols = results.molecule.atom_symbols # type: ignore
31
+ frag_indices = results.molecule.frag_indices # type: ignore
32
+ calc_dir = pl.Path(results.files["root"]) # type: ignore
33
+ is_fragment_calculation = results.adf.used_regions # type: ignore
34
+ mol_charge = results.molecule.mol_charge # type: ignore
35
+
36
+ # Convert the VDD charges to VDDCharge objects
37
+ for irrep, charge_array in results.properties.vdd.items(): # type: ignore
38
+ irrep = "vdd" if irrep == "charges" else irrep
39
+ vdd_charges[irrep] = []
40
+ for atom_index, (frag_index, vdd_charge) in enumerate(zip(frag_indices, charge_array)):
41
+ atom_symbol = atom_symbols[atom_index]
42
+ vdd_charges[irrep].append(charge.VDDCharge(atom_index + 1, atom_symbol, vdd_charge, frag_index))
43
+
44
+ return VDDChargeManager(vdd_charges, is_fragment_calculation, calc_dir, mol_charge)
45
+
46
+
47
+ class VDDChargeManager:
48
+ """Class to manage the VDD charges. It can be used to print the VDD charges in a nice table and write them to a text file or excel file."""
49
+
50
+ def __init__(self, vdd_charges: Dict[str, List[charge.VDDCharge]], is_fragment_calculation: bool, calc_dir: pl.Path, mol_charge: int):
51
+ self.vdd_charges = vdd_charges
52
+ self.is_fragment_calculation = is_fragment_calculation
53
+ self.calc_dir = calc_dir
54
+ self.mol_charge = mol_charge
55
+ self.name = self.calc_dir.name if self.calc_dir is not None else ""
56
+ self.unit = "e" # unit of the VDD charges. Available units are "me" (mili-electrons) and "e" (electrons)
57
+ self.irreps: Set[str] = set(self.vdd_charges.keys())
58
+ self.change_unit("me")
59
+
60
+ def __str__(self) -> str:
61
+ """Prints the VDD charges in a nice table. Checks if the calculation is a fragment calculation and prints the summed VDD charges if it is."""
62
+ individual_charges_table = self.get_vdd_charges_table()
63
+ summed_charges_table = self.get_summed_vdd_charges_table()
64
+
65
+ ret_str = f"{self.name}\nVDD charges (in unit {self.unit}):\n{individual_charges_table}\n\n"
66
+ if self.is_fragment_calculation:
67
+ ret_str += f"Summed VDD charges (in unit {self.unit}):\n{summed_charges_table}\n"
68
+
69
+ return ret_str
70
+
71
+ def charge_is_conserved(self) -> bool:
72
+ """Check if the total charge of the molecule is conserved. The total charge is the sum of the VDD charges."""
73
+ tolerance = 1e-4 if self.unit == "e" else 1e-1
74
+ is_conserved = np.isclose(self.mol_charge, sum([charge.charge for charge in self.vdd_charges["vdd"]]), atol=tolerance)
75
+ return is_conserved # type: ignore since numpy _bool is not recognized as bool
76
+
77
+ def change_unit(self, new_unit: str) -> None:
78
+ """Change the unit of the VDD charges. Available units are "me" (mili-electrons) and "e" (electrons)."""
79
+ if new_unit not in VDD_UNITS:
80
+ raise ValueError(f"Unit {new_unit} is not available. Choose from {VDD_UNITS.keys()}")
81
+
82
+ if new_unit == self.unit:
83
+ return
84
+
85
+ ratio = VDD_UNITS[new_unit] / VDD_UNITS[self.unit]
86
+ [charge.change_unit(ratio) for charges in self.vdd_charges.values() for charge in charges]
87
+ self.unit = new_unit
88
+
89
+ def get_vdd_charges(self, unit: Literal["e", "me"] = "me") -> Dict[str, List[charge.VDDCharge]]:
90
+ """Get the VDD charges in the specified unit ([me] or [e])."""
91
+ self.change_unit(unit)
92
+ return copy.deepcopy(self.vdd_charges)
93
+
94
+ def get_summed_vdd_charges(self, irreps: Optional[Sequence[str]] = None, unit: Literal["e", "me"] = "me") -> Dict[str, Dict[str, float]]:
95
+ """Get the summed VDD charges per fragment for the specified unit ([me] or [e])."""
96
+ self.change_unit(unit)
97
+ irreps = irreps if irreps is not None else list(self.irreps)
98
+ summed_vdd_charges: Dict[str, Dict[str, float]] = {}
99
+
100
+ for irrep in irreps:
101
+ summed_vdd_charges[irrep] = {}
102
+ for vdd_charge in self.vdd_charges[irrep]:
103
+ frag_index = str(vdd_charge.frag_index)
104
+ summed_vdd_charges[irrep].setdefault(frag_index, 0.0)
105
+ summed_vdd_charges[irrep][frag_index] += vdd_charge.charge
106
+
107
+ return copy.deepcopy(summed_vdd_charges)
108
+
109
+ def get_vdd_charges_dataframe(self) -> pd.DataFrame:
110
+ """Get the VDD charges as a pandas DataFrame in a specified unit ([me] or [e])."""
111
+
112
+ frag_indices = [charge.frag_index for charge in self.vdd_charges["vdd"]]
113
+ atom_symbols = [f"{charge.atom_index}{charge.atom_symbol}" for charge in self.vdd_charges["vdd"]]
114
+ charges = [[charge.charge for charge in charges] for _, charges in self.vdd_charges.items()]
115
+ headers = ["Frag", "Atom"] + [irrep for irrep, _ in self.vdd_charges.items()]
116
+ combined_table = list(zip_longest(frag_indices, atom_symbols, *charges, fillvalue=""))
117
+ df = pd.DataFrame(combined_table, columns=headers).rename(columns={"vdd": "Total"})
118
+ return df
119
+
120
+ def get_summed_vdd_charges_dataframe(self) -> pd.DataFrame:
121
+ """Get the summed VDD charges as a pandas DataFrame in a specified unit ([me] or [e])."""
122
+ summed_data = self.get_summed_vdd_charges()
123
+ summed_data["Frag"] = {str(key): int(key) for key in summed_data["vdd"].keys()}
124
+ df = pd.DataFrame(summed_data).pipe(lambda df: df[df.columns.tolist()[-1:] + df.columns.tolist()[:-1]]) # move the "Frag" column to the front
125
+ return df.rename(columns={"vdd": "Total"})
126
+
127
+ def get_vdd_charges_table(self) -> str:
128
+ df = self.get_vdd_charges_dataframe()
129
+ return df.to_string(float_format=lambda x: PRINT_FORMAT[self.unit] % x, justify="center", index=False, col_space=6)
130
+
131
+ def get_summed_vdd_charges_table(self) -> str:
132
+ df = self.get_summed_vdd_charges_dataframe()
133
+ return df.to_string(float_format=lambda x: PRINT_FORMAT[self.unit] % x, justify="center", col_space=6, index=False)
134
+
135
+ @staticmethod
136
+ def write_to_txt(output_dir: Union[str, pl.Path], managers: Union[VDDChargeManager, Sequence[VDDChargeManager]], unit: str = "me") -> None:
137
+ """Write the VDD charges to a text file. It is a static method because multiple managers can be written to the same file."""
138
+ out_dir = pl.Path(output_dir) if not isinstance(output_dir, pl.Path) else output_dir
139
+ files = [out_dir / "VDD_charges_per_atom.txt", out_dir / "VDD_charges_per_fragment.txt"]
140
+ managers = managers if isinstance(managers, Sequence) else [managers]
141
+
142
+ # Print charges per atom and per fragment in seperate files
143
+ for i, file in enumerate(files):
144
+ with open(file, "w") as f:
145
+ f.write("VDD charges:\n")
146
+ for manager in managers:
147
+ f.write(f"{manager.name} (unit = {manager.unit})\n")
148
+ if i == 0:
149
+ f.write(manager.get_vdd_charges_table())
150
+ else:
151
+ f.write(manager.get_summed_vdd_charges_table()) if manager.is_fragment_calculation else f.write("No fragment calculation\n")
152
+ f.write("\n\n")
153
+
154
+ @environment.requires_optional_package("openpyxl")
155
+ def write_to_excel(self, output_file: Optional[Union[str, pl.Path]] = None) -> None:
156
+ """Write the VDD charges to an excel file. Results are written to two sheets: "VDD charges" and "Summed VDD charges"."""
157
+ file = pl.Path(output_file) if output_file is not None else self.calc_dir / f"vdd_charges_{self.name}.xlsx"
158
+ file = file.with_suffix(".xlsx") if not file.suffix == ".xlsx" else file
159
+
160
+ df = self.get_vdd_charges_dataframe()
161
+
162
+ with pd.ExcelWriter(file) as writer:
163
+ df.to_excel(writer, sheet_name=f"VDD charges (in {self.unit})", index=False, float_format=PRINT_FORMAT[self.unit])
164
+ if self.is_fragment_calculation:
165
+ df_summed = self.get_summed_vdd_charges_dataframe()
166
+ df_summed.to_excel(writer, sheet_name=f"Summed VDD charges (in {self.unit})", index=False, float_format=PRINT_FORMAT[self.unit])
167
+
168
+ @environment.requires_optional_package("matplotlib")
169
+ def plot_vdd_charges_per_atom(self, output_file: Optional[Union[str, pl.Path]] = None, unit: str = "me") -> None:
170
+ """Plot the VDD charges as a bar graph for each irrep."""
171
+ import matplotlib.pyplot as plt
172
+
173
+ file = pl.Path(output_file) if output_file is not None else self.calc_dir / f"vdd_charges_{self.name}.png"
174
+ file = file.with_suffix(".png") if not file.suffix == ".png" else file
175
+ self.change_unit(unit)
176
+
177
+ # Increase the global font size
178
+ plt.rcParams.update({"font.size": 14})
179
+
180
+ num_irreps = len(self.vdd_charges)
181
+ n_max_charges = max([len(charges) for charges in self.vdd_charges.values()])
182
+ _, axs = plt.subplots(num_irreps, 1, figsize=(n_max_charges * 1.15, 5 * num_irreps), sharey=True)
183
+ axs = [axs] if num_irreps == 1 else axs
184
+
185
+ # Initialize a variable to keep track of the most positive/negative values
186
+ adjusted_abs_max_values = []
187
+
188
+ counter = 1
189
+ for ax, (irrep, charges) in zip(axs, self.vdd_charges.items()):
190
+ atom_symbols = [f"{charge.atom_index}{charge.atom_symbol} ({charge.frag_index})" for charge in charges]
191
+ charge_values = [charge.charge for charge in charges]
192
+
193
+ bars = ax.bar(atom_symbols, charge_values, color="sandybrown", edgecolor="black")
194
+ if counter == len(axs):
195
+ ax.set_xlabel("Atom (#Fragment Number)")
196
+ ax.set_ylabel(f"Charge ({unit})")
197
+ ax.set_title(f"VDD Charges {self.name} - {irrep}")
198
+ ax.yaxis.grid(True) # Only display vertical grid lines
199
+
200
+ # Add the charge value on top of each bar. If the value is negative, place the text below the bar
201
+ for bar in bars:
202
+ yval = bar.get_height()
203
+ x_pos = bar.get_x() + bar.get_width() / 2
204
+
205
+ if yval <= 0:
206
+ ax.text(x_pos, yval + yval * 0.1, int(yval), va="top", ha="center")
207
+ else:
208
+ ax.text(x_pos, yval + yval * 0.1, int(yval), va="bottom", ha="center")
209
+ adjusted_abs_max_values.append(yval + yval * 0.1)
210
+ counter += 1
211
+
212
+ # Making sure the values do not extend outside the plot. Also determines the y-axis limits for all subplotss
213
+ min_value, max_value = min(adjusted_abs_max_values), max(adjusted_abs_max_values)
214
+
215
+ # Axes adjustments
216
+ # Set the y-axis limits for all subplots based on the most positive/negative values
217
+ for ax in axs:
218
+ ax.set_ylim([min_value - abs(min_value) * 0.2, max_value + abs(max_value) * 0.2])
219
+
220
+ # plt.tight_layout()
221
+ plt.savefig(file, dpi=300)
File without changes