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.
- digichem/__init__.py +75 -0
- digichem/basis.py +116 -0
- digichem/config/README +3 -0
- digichem/config/__init__.py +5 -0
- digichem/config/base.py +321 -0
- digichem/config/locations.py +14 -0
- digichem/config/parse.py +90 -0
- digichem/config/util.py +117 -0
- digichem/data/README +4 -0
- digichem/data/batoms/COPYING +18 -0
- digichem/data/batoms/LICENSE +674 -0
- digichem/data/batoms/README +2 -0
- digichem/data/batoms/__init__.py +0 -0
- digichem/data/batoms/batoms-renderer.py +351 -0
- digichem/data/config/digichem.yaml +714 -0
- digichem/data/functionals.csv +15 -0
- digichem/data/solvents.csv +185 -0
- digichem/data/tachyon/COPYING.md +5 -0
- digichem/data/tachyon/LICENSE +30 -0
- digichem/data/tachyon/tachyon_LINUXAMD64 +0 -0
- digichem/data/vmd/common.tcl +468 -0
- digichem/data/vmd/generate_combined_orbital_images.tcl +70 -0
- digichem/data/vmd/generate_density_images.tcl +45 -0
- digichem/data/vmd/generate_dipole_images.tcl +68 -0
- digichem/data/vmd/generate_orbital_images.tcl +57 -0
- digichem/data/vmd/generate_spin_images.tcl +66 -0
- digichem/data/vmd/generate_structure_images.tcl +40 -0
- digichem/datas.py +14 -0
- digichem/exception/__init__.py +7 -0
- digichem/exception/base.py +133 -0
- digichem/exception/uncatchable.py +63 -0
- digichem/file/__init__.py +1 -0
- digichem/file/base.py +364 -0
- digichem/file/cube.py +284 -0
- digichem/file/fchk.py +94 -0
- digichem/file/prattle.py +277 -0
- digichem/file/types.py +97 -0
- digichem/image/__init__.py +6 -0
- digichem/image/base.py +113 -0
- digichem/image/excited_states.py +335 -0
- digichem/image/graph.py +293 -0
- digichem/image/orbitals.py +239 -0
- digichem/image/render.py +617 -0
- digichem/image/spectroscopy.py +797 -0
- digichem/image/structure.py +115 -0
- digichem/image/vmd.py +826 -0
- digichem/input/__init__.py +3 -0
- digichem/input/base.py +78 -0
- digichem/input/digichem_input.py +500 -0
- digichem/input/gaussian.py +140 -0
- digichem/log.py +179 -0
- digichem/memory.py +166 -0
- digichem/misc/__init__.py +4 -0
- digichem/misc/argparse.py +44 -0
- digichem/misc/base.py +61 -0
- digichem/misc/io.py +239 -0
- digichem/misc/layered_dict.py +285 -0
- digichem/misc/text.py +139 -0
- digichem/misc/time.py +73 -0
- digichem/parse/__init__.py +13 -0
- digichem/parse/base.py +220 -0
- digichem/parse/cclib.py +138 -0
- digichem/parse/dump.py +253 -0
- digichem/parse/gaussian.py +130 -0
- digichem/parse/orca.py +96 -0
- digichem/parse/turbomole.py +201 -0
- digichem/parse/util.py +523 -0
- digichem/result/__init__.py +6 -0
- digichem/result/alignment/AA.py +114 -0
- digichem/result/alignment/AAA.py +61 -0
- digichem/result/alignment/FAP.py +148 -0
- digichem/result/alignment/__init__.py +3 -0
- digichem/result/alignment/base.py +310 -0
- digichem/result/angle.py +153 -0
- digichem/result/atom.py +742 -0
- digichem/result/base.py +258 -0
- digichem/result/dipole_moment.py +332 -0
- digichem/result/emission.py +402 -0
- digichem/result/energy.py +323 -0
- digichem/result/excited_state.py +821 -0
- digichem/result/ground_state.py +94 -0
- digichem/result/metadata.py +644 -0
- digichem/result/multi.py +98 -0
- digichem/result/nmr.py +1086 -0
- digichem/result/orbital.py +647 -0
- digichem/result/result.py +244 -0
- digichem/result/soc.py +272 -0
- digichem/result/spectroscopy.py +514 -0
- digichem/result/tdm.py +267 -0
- digichem/result/vibration.py +167 -0
- digichem/test/__init__.py +6 -0
- digichem/test/conftest.py +4 -0
- digichem/test/test_basis.py +71 -0
- digichem/test/test_calculate.py +30 -0
- digichem/test/test_config.py +78 -0
- digichem/test/test_cube.py +369 -0
- digichem/test/test_exception.py +16 -0
- digichem/test/test_file.py +104 -0
- digichem/test/test_image.py +337 -0
- digichem/test/test_input.py +64 -0
- digichem/test/test_parsing.py +79 -0
- digichem/test/test_prattle.py +36 -0
- digichem/test/test_result.py +489 -0
- digichem/test/test_translate.py +112 -0
- digichem/test/util.py +207 -0
- digichem/translate.py +591 -0
- digichem_core-6.0.0rc1.dist-info/METADATA +96 -0
- digichem_core-6.0.0rc1.dist-info/RECORD +111 -0
- digichem_core-6.0.0rc1.dist-info/WHEEL +4 -0
- digichem_core-6.0.0rc1.dist-info/licenses/COPYING.md +10 -0
- digichem_core-6.0.0rc1.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
from matplotlib import ticker
|
|
2
|
+
from matplotlib.ticker import FuncFormatter
|
|
3
|
+
from itertools import chain
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
from digichem.image.graph import OneD_graph_image_maker
|
|
7
|
+
#from digichem.result.excited_state import Excited_state, Excited_state_list
|
|
8
|
+
import digichem.result.excited_state
|
|
9
|
+
|
|
10
|
+
class Excited_states_diagram_maker(OneD_graph_image_maker):
|
|
11
|
+
"""
|
|
12
|
+
Class for making orbital energy diagrams.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, output, excited_states, ground_state, y_limits = "auto", show_dest = True, **kwargs):
|
|
16
|
+
super().__init__(output, **kwargs)
|
|
17
|
+
self.excited_states = excited_states
|
|
18
|
+
self.ground_state = ground_state
|
|
19
|
+
|
|
20
|
+
# Each multiplicity will be plotted as a separate column, so first we need to organise our data.
|
|
21
|
+
states = chain(self.excited_states, [self.ground_state]) if self.ground_state is not None else self.excited_states
|
|
22
|
+
self.grouped_states = (type(self.excited_states)(sorted(states, key = lambda state: state.energy))).group()
|
|
23
|
+
# We'll also keep hold of just our excited states (so no ground).
|
|
24
|
+
self.grouped_excited_states = self.excited_states.group()
|
|
25
|
+
|
|
26
|
+
# Whether to display our dEst label, this is forced off if we don't have only singlets and triplets.
|
|
27
|
+
self.show_dest = show_dest if 1 in self.grouped_excited_states and 3 in self.grouped_excited_states and len(self.grouped_excited_states) == 2 else False
|
|
28
|
+
self.left_padding = 0
|
|
29
|
+
self.right_padding = 0
|
|
30
|
+
|
|
31
|
+
# Set our output dpi (higher values = bigger image/quality).
|
|
32
|
+
self.output_dpi = 140
|
|
33
|
+
|
|
34
|
+
# Initial image dimensions (we'll change these to suit).
|
|
35
|
+
self.init_image_width = 5.6
|
|
36
|
+
self.init_image_height = 5.6
|
|
37
|
+
|
|
38
|
+
# Amount of space to allocate per unit of each axis.
|
|
39
|
+
self.inch_per_x = 1.66
|
|
40
|
+
#self.inch_per_y = 0.94
|
|
41
|
+
self.inch_per_y = 1.1
|
|
42
|
+
|
|
43
|
+
# Y axis label (we don't have an x axis label).
|
|
44
|
+
self.y_label = "Energy /eV"
|
|
45
|
+
|
|
46
|
+
self.plot_options = {
|
|
47
|
+
"orientation": "vertical",
|
|
48
|
+
"lineoffsets": 1,
|
|
49
|
+
"linelengths": 0.9,
|
|
50
|
+
"linewidths": None,
|
|
51
|
+
"colors": None
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Save our scaling method.
|
|
55
|
+
self.limits_method = y_limits
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_options(self, output, *, excited_states, ground_state, options, **kwargs):
|
|
59
|
+
"""
|
|
60
|
+
Constructor that takes a dictionary of config like options.
|
|
61
|
+
"""
|
|
62
|
+
return self(
|
|
63
|
+
output,
|
|
64
|
+
excited_states = excited_states,
|
|
65
|
+
ground_state = ground_state,
|
|
66
|
+
show_dest = options['excited_states_diagram']['show_dest'],
|
|
67
|
+
y_limits = options['excited_states_diagram']['y_limits'],
|
|
68
|
+
enable_rendering = options['excited_states_diagram']['enable_rendering'],
|
|
69
|
+
**kwargs
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def plot_lines(self):
|
|
73
|
+
"""
|
|
74
|
+
Plot the lines that make up the body of the graph.
|
|
75
|
+
|
|
76
|
+
This method is called automatically as part of the make() method.
|
|
77
|
+
:return: Nothing.
|
|
78
|
+
"""
|
|
79
|
+
# Turn our dictionary of grouped excited states into an ordered list for matplotlib.
|
|
80
|
+
data = [
|
|
81
|
+
[excited_state.energy for excited_state in self.grouped_states[grouped_key]]
|
|
82
|
+
#self.grouped_states[grouped_key]
|
|
83
|
+
for grouped_key
|
|
84
|
+
in sorted(self.grouped_states)
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
# We can use an event plot which is good for 1D graphs like this.
|
|
88
|
+
self.axes.eventplot(data, linestyles = "solid", **self.plot_options)
|
|
89
|
+
|
|
90
|
+
def get_x_pos_from_multiplicity(self, multiplicity):
|
|
91
|
+
"""
|
|
92
|
+
Get the X coordinate of an energy state.
|
|
93
|
+
|
|
94
|
+
:raises ValueError: If the given multiplicity is not plotted.
|
|
95
|
+
:param multiplicity: The multiplicity of the energy state, which determines its x position.
|
|
96
|
+
:return: The X coord.
|
|
97
|
+
"""
|
|
98
|
+
# Get all the multiplicities that we're using.
|
|
99
|
+
mult = sorted(self.grouped_states)
|
|
100
|
+
|
|
101
|
+
# Our X position depends on the multiplicity of our GS and how many total multiplicities there are (as well as how spaced out each column on the graph is).
|
|
102
|
+
if len(self.grouped_states) > 1:
|
|
103
|
+
return mult.index(multiplicity) * self.plot_options['lineoffsets']
|
|
104
|
+
else:
|
|
105
|
+
# It appears matplotlib uses a different numbering if we only have 1 column, where x == 1 (normally the first column is x == 0). This makes me sad. Might be bug...
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
def annotate_ground_state(self):
|
|
109
|
+
"""
|
|
110
|
+
Add a label to our ground state.
|
|
111
|
+
|
|
112
|
+
This method is called automatically as part of the make() method.
|
|
113
|
+
:return: Nothing.
|
|
114
|
+
"""
|
|
115
|
+
# First work out where our GS is on the graph.
|
|
116
|
+
x_pos = self.get_x_pos_from_multiplicity(self.ground_state.multiplicity)
|
|
117
|
+
|
|
118
|
+
# Now plot our text.
|
|
119
|
+
self.axes.annotate(
|
|
120
|
+
r"$\bf{{{}}}_{{{}}}$".format(self.ground_state.multiplicity_symbol, self.ground_state.multiplicity_level),
|
|
121
|
+
(x_pos, self.ground_state.energy),
|
|
122
|
+
textcoords = "offset pixels",
|
|
123
|
+
xytext = (0, 7),
|
|
124
|
+
horizontalalignment = "center",
|
|
125
|
+
fontsize = 12
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def annotate_excited_state(self, state):
|
|
129
|
+
"""
|
|
130
|
+
Add a label to an excited state
|
|
131
|
+
|
|
132
|
+
This method is called automatically as part of the make() method.
|
|
133
|
+
:param multiplicity_symbol: The symbol (a string) to annotate (eg, S1, T1).
|
|
134
|
+
:return: Nothing.
|
|
135
|
+
"""
|
|
136
|
+
# First work out where our state is on the graph.
|
|
137
|
+
#state = self.excited_states.get_state(multiplicity_symbol)
|
|
138
|
+
x_pos = self.get_x_pos_from_multiplicity(state.multiplicity)
|
|
139
|
+
|
|
140
|
+
# Assemble our label.
|
|
141
|
+
label_string = r"$\bf{{{}}}_{{{}}}$: ".format(state.multiplicity_symbol, state.multiplicity_level) + "{:0.2f} eV".format(state.energy)
|
|
142
|
+
|
|
143
|
+
# Add oscillator strength (if we have it).
|
|
144
|
+
if state.oscillator_strength is not None:
|
|
145
|
+
label_string += "\n" + "f: {:0.2f}".format(state.oscillator_strength)
|
|
146
|
+
|
|
147
|
+
# Now plot our text.
|
|
148
|
+
self.axes.annotate(
|
|
149
|
+
label_string,
|
|
150
|
+
(x_pos, state.energy),
|
|
151
|
+
textcoords = "offset pixels",
|
|
152
|
+
xytext = (0, -7),
|
|
153
|
+
verticalalignment = "top",
|
|
154
|
+
horizontalalignment = "center",
|
|
155
|
+
fontsize = 12
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def annotate_dest(self, x, y):
|
|
159
|
+
"""
|
|
160
|
+
"""
|
|
161
|
+
# First the text label.
|
|
162
|
+
self.axes.annotate(
|
|
163
|
+
r"$\bf{{ΔE_{{ST}}}}$: {:0.2f} eV".format(self.excited_states.singlet_triplet_energy),
|
|
164
|
+
(x, y),
|
|
165
|
+
textcoords = "offset pixels",
|
|
166
|
+
xytext = (0,0),
|
|
167
|
+
verticalalignment = "center",
|
|
168
|
+
horizontalalignment = "left",
|
|
169
|
+
fontsize = 12
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def plot_labels(self):
|
|
173
|
+
"""
|
|
174
|
+
Plot the labels on our graph that identify various excited states and the singlet/triplet splitting energy (dEst) if available.
|
|
175
|
+
|
|
176
|
+
This method is called automatically as part of the make() method.
|
|
177
|
+
:return: Nothing.
|
|
178
|
+
"""
|
|
179
|
+
# First add a label for our ground state, unless there isn't room (this hack again).
|
|
180
|
+
lowest_state = self.excited_states[0]
|
|
181
|
+
# We need about 0.6 eV space.
|
|
182
|
+
if (lowest_state.energy - 0.6) >= 0:
|
|
183
|
+
self.annotate_ground_state()
|
|
184
|
+
|
|
185
|
+
# Now we'll add a label for the lowest state of each multiplicity.
|
|
186
|
+
for multiplicity in self.grouped_excited_states:
|
|
187
|
+
self.annotate_excited_state(self.grouped_excited_states[multiplicity][0])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# We'll also try and add a dEst label (obviously this is only possible if there is an S1 and T1 available).
|
|
191
|
+
if self.show_dest:
|
|
192
|
+
# First get our two states.
|
|
193
|
+
S1 = self.excited_states.get_state("S(1)")
|
|
194
|
+
T1 = self.excited_states.get_state("T(1)")
|
|
195
|
+
|
|
196
|
+
# Find the midpoint between them.
|
|
197
|
+
y_pos = min([S1.energy, T1.energy]) + (abs(S1.energy - T1.energy) /2)
|
|
198
|
+
|
|
199
|
+
# The label will be to the right of S1/T1.
|
|
200
|
+
x_pos = max(self.get_x_pos_from_multiplicity(S1.multiplicity), self.get_x_pos_from_multiplicity(T1.multiplicity)) + (self.plot_options['linelengths'] /2) + 0.2
|
|
201
|
+
# Add the label
|
|
202
|
+
self.annotate_dest(x_pos, y_pos)
|
|
203
|
+
|
|
204
|
+
# Now draw our vertical arrow.
|
|
205
|
+
self.axes.annotate(
|
|
206
|
+
"",
|
|
207
|
+
xy = (x_pos-0.05, S1.energy),
|
|
208
|
+
xytext = (x_pos-0.05, T1.energy),
|
|
209
|
+
arrowprops=dict(arrowstyle="<->")
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Now extend a line from S1 and T1 to meet the vertical arrow.
|
|
213
|
+
for state in (S1, T1):
|
|
214
|
+
self.axes.annotate(
|
|
215
|
+
"",
|
|
216
|
+
xy = (self.get_x_pos_from_multiplicity(state.multiplicity) + (self.plot_options['linelengths'] /2), state.energy),
|
|
217
|
+
xytext = (x_pos-0.05, state.energy),
|
|
218
|
+
arrowprops=dict(arrowstyle="-", linestyle="--")
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Pad to the right so our dest label is chopped off.
|
|
222
|
+
self.right_padding = 0.7
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def x_axis_formatter(self, x, pos):
|
|
226
|
+
"""
|
|
227
|
+
This method tells matplotlib how to label our X axis (with multiplicity labels).
|
|
228
|
+
"""
|
|
229
|
+
# The multiplicities that we're using.
|
|
230
|
+
mult = sorted(self.grouped_states)
|
|
231
|
+
|
|
232
|
+
if len(self.grouped_states) > 1:
|
|
233
|
+
# Check to see if our x is in our list of multiplicities.
|
|
234
|
+
if x % self.plot_options['lineoffsets'] == 0 and x >= 0:
|
|
235
|
+
try:
|
|
236
|
+
return digichem.result.excited_state.Excited_state.multiplicity_number_to_string((mult[int(x / self.plot_options['lineoffsets'])]))
|
|
237
|
+
|
|
238
|
+
except IndexError:
|
|
239
|
+
# Out of range.
|
|
240
|
+
return ""
|
|
241
|
+
else:
|
|
242
|
+
# Matplotlib uses different counting when there's only 1 column :(.
|
|
243
|
+
if x == 1:
|
|
244
|
+
return digichem.result.excited_state.Excited_state.multiplicity_number_to_string(mult[0])
|
|
245
|
+
else:
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
def all_limits(self):
|
|
249
|
+
"""
|
|
250
|
+
Limit the Y axis so all states are visible.
|
|
251
|
+
"""
|
|
252
|
+
# Use auto scaling.
|
|
253
|
+
self.axes.autoscale(enable = True, axis = 'y')
|
|
254
|
+
|
|
255
|
+
# We'll go a small amount below 0 so our S0 is off the bottom of the graph.
|
|
256
|
+
min_y = -0.1
|
|
257
|
+
# Use a sneaky hack so that if the max energy is close to an integer, we'll use the next highest integer.
|
|
258
|
+
#max_y = math.ceil(self.excited_states[-1].energy +0.1)
|
|
259
|
+
max_y = self.excited_states[-1].energy + 0.1
|
|
260
|
+
self.axes.set_ylim(min_y, max_y)
|
|
261
|
+
|
|
262
|
+
# Adjusty hack.
|
|
263
|
+
self._adjust_limits_for_zero()
|
|
264
|
+
|
|
265
|
+
def auto_limits(self):
|
|
266
|
+
"""
|
|
267
|
+
Limit the Y axis so S1, T1 ... N1 etc are all visible.
|
|
268
|
+
"""
|
|
269
|
+
self.all_limits()
|
|
270
|
+
|
|
271
|
+
# We'll show the lowest excited state of each mult.
|
|
272
|
+
highest_state = max([self.grouped_excited_states[multiplicity][0].energy for multiplicity in self.grouped_excited_states])
|
|
273
|
+
# Use a sneaky hack so that if the max energy is close to an integer, we'll use the next highest integer.
|
|
274
|
+
max_y = math.ceil(highest_state +0.1)
|
|
275
|
+
self.axes.set_ylim(None, max_y)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _adjust_limits_for_zero(self):
|
|
279
|
+
"""
|
|
280
|
+
This method is a hack which lowers the y axis limit in cases where S1/T1 is very close to S0
|
|
281
|
+
|
|
282
|
+
This is necessary because otherwise the state annotations will overflow the bottom of the diagram.
|
|
283
|
+
"""
|
|
284
|
+
# First get our lowest E excited state.
|
|
285
|
+
lowest_state = self.excited_states[0]
|
|
286
|
+
# We need about 0.7 eV space.
|
|
287
|
+
if (lowest_state.energy - 0.5) < 0:
|
|
288
|
+
# Add on a bit extra.
|
|
289
|
+
self.axes.set_ylim(bottom = lowest_state.energy - (0.5))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def simple_limits(self, y_min = -0.1, y_max = 5):
|
|
293
|
+
"""
|
|
294
|
+
Limit the Y axis between between two points.
|
|
295
|
+
|
|
296
|
+
:param y_min: The start of the y axis.
|
|
297
|
+
:param y_max: The end of the y axis.
|
|
298
|
+
"""
|
|
299
|
+
self.axes.set_ylim(y_min, y_max)
|
|
300
|
+
|
|
301
|
+
def adjust_axes(self):
|
|
302
|
+
"""
|
|
303
|
+
Adjust the axes of our graph.
|
|
304
|
+
|
|
305
|
+
This method is called automatically as part of the make() method.
|
|
306
|
+
:return: Nothing.
|
|
307
|
+
"""
|
|
308
|
+
super().adjust_axes()
|
|
309
|
+
|
|
310
|
+
# Adjust axis
|
|
311
|
+
# Add a bit o' padding (often we actually reduce padding from the default).
|
|
312
|
+
xlim = self.axes.get_xlim()
|
|
313
|
+
self.axes.set_xlim(xlim[0] - (self.left_padding -0.25), xlim[1] + (self.right_padding -0.25))
|
|
314
|
+
#self.axes.set_xlim(xlim[0] +0.25, xlim[1] -0.25)
|
|
315
|
+
|
|
316
|
+
# Now decide how big our y axis will be.
|
|
317
|
+
if self.limits_method == "all":
|
|
318
|
+
self.all_limits()
|
|
319
|
+
elif self.limits_method == "auto":
|
|
320
|
+
self.auto_limits()
|
|
321
|
+
elif isinstance(self.limits_method, tuple) or isinstance(self.limits_method, list):
|
|
322
|
+
self.simple_limits(self.limits_method[0], self.limits_method[1])
|
|
323
|
+
else:
|
|
324
|
+
raise ValueError("Unknown limits method '{}'".format(self.limits_method))
|
|
325
|
+
|
|
326
|
+
# Set custom text on our x axis.
|
|
327
|
+
self.axes.xaxis.set_major_formatter(FuncFormatter(self.x_axis_formatter))
|
|
328
|
+
#self.axes.xaxis.set_major_locator(ticker.MultipleLocator(1))
|
|
329
|
+
self.axes.xaxis.set_major_locator(ticker.FixedLocator([self.get_x_pos_from_multiplicity(multiplicity) for multiplicity in self.grouped_excited_states]))
|
|
330
|
+
|
|
331
|
+
# Both our dimensions can scale to fit more data.
|
|
332
|
+
self.figure.tight_layout()
|
|
333
|
+
self.constant_scale(0, self.inch_per_x)
|
|
334
|
+
self.constant_scale(1, self.inch_per_y)
|
|
335
|
+
|
digichem/image/graph.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from matplotlib import ticker
|
|
2
|
+
from matplotlib.transforms import Bbox
|
|
3
|
+
import matplotlib.figure
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
from digichem.image import Image_maker
|
|
7
|
+
|
|
8
|
+
class Graph_image_maker(Image_maker):
|
|
9
|
+
"""
|
|
10
|
+
A class used for creating graph images.
|
|
11
|
+
|
|
12
|
+
Most of the heavy lifting here is achieved by the matplotlib library.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, *args, max_width = None, max_height = None, **kwargs):
|
|
16
|
+
"""
|
|
17
|
+
Constructor for Graph_image_maker objects.
|
|
18
|
+
|
|
19
|
+
:param max_width: The maximum image width in pixels, None for no limit.
|
|
20
|
+
:param max_height: The maximum image height in pixels, None for no limit.
|
|
21
|
+
"""
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
|
|
24
|
+
# Set our output dpi (higher values = bigger image/quality).
|
|
25
|
+
self.output_dpi = 130
|
|
26
|
+
|
|
27
|
+
# The width and height (in inch) that we initially create our figure with. Note that the true figure size may change from this value.
|
|
28
|
+
self.init_image_width = 5
|
|
29
|
+
self.init_image_height = 5
|
|
30
|
+
|
|
31
|
+
# If true, the label and tick labels will be hidden for that axis.
|
|
32
|
+
self.hide_x = False
|
|
33
|
+
self.hide_y = False
|
|
34
|
+
|
|
35
|
+
# The label to use for the x and y axes.
|
|
36
|
+
self.x_label = ""
|
|
37
|
+
self.y_label = ""
|
|
38
|
+
|
|
39
|
+
# The matplotlib figure object that represents our graph.
|
|
40
|
+
self.figure = None
|
|
41
|
+
|
|
42
|
+
# The plotting area within our figure.
|
|
43
|
+
self.axes = None
|
|
44
|
+
|
|
45
|
+
# Image clamping.
|
|
46
|
+
self.max_width = max_width
|
|
47
|
+
self.max_height = max_height
|
|
48
|
+
|
|
49
|
+
def make_files(self):
|
|
50
|
+
"""
|
|
51
|
+
Make the image described by this object.
|
|
52
|
+
"""
|
|
53
|
+
# Change font and other defaults.
|
|
54
|
+
#pyplot.rcParams.update({'font.size': 14, 'font.family': 'DejaVu Sans', 'axes.linewidth': 2})
|
|
55
|
+
matplotlib.rcParams.update({'font.size': 14, 'font.family': 'DejaVu Sans', 'axes.linewidth': 2})
|
|
56
|
+
|
|
57
|
+
# Make our (empty) plot.
|
|
58
|
+
# pyplot uses a nasty interface, will switch to OO at some point.
|
|
59
|
+
#pyplot.figure(figsize = (self.init_image_width, self.init_image_height), tight_layout = True)
|
|
60
|
+
#pyplot.figure(figsize = (self.init_image_width, self.init_image_height), tight_layout = False)
|
|
61
|
+
|
|
62
|
+
# Plot our graph.
|
|
63
|
+
self.make_graph()
|
|
64
|
+
|
|
65
|
+
# And save to file.
|
|
66
|
+
self.figure.savefig(self.output, dpi = self.output_dpi)
|
|
67
|
+
#pyplot.savefig(self.output, dpi = self.output_dpi)
|
|
68
|
+
|
|
69
|
+
def make_graph(self):
|
|
70
|
+
"""
|
|
71
|
+
Make the graph data described by this object.
|
|
72
|
+
|
|
73
|
+
The matplotlib figure is available at self.figure (and is also returned by this method for convenience). The graph is not saved to file.
|
|
74
|
+
|
|
75
|
+
:return: A reference to the created matplotlib Figure object.
|
|
76
|
+
"""
|
|
77
|
+
# First make our figure object, which is the top-level matplotlib container.
|
|
78
|
+
self.figure = matplotlib.figure.Figure(figsize = (self.init_image_width, self.init_image_height))
|
|
79
|
+
|
|
80
|
+
# Also get our main axes (all the graph types we support so far contain a single plot/axes).
|
|
81
|
+
self.axes = self.figure.add_subplot(111)
|
|
82
|
+
|
|
83
|
+
# Call plot(), which is defined by sub-classes to actually draw the graph.
|
|
84
|
+
self.plot()
|
|
85
|
+
|
|
86
|
+
# Call adjust_axes(), which does what it suggests (also adds axis labels etc.)
|
|
87
|
+
self.adjust_axes()
|
|
88
|
+
|
|
89
|
+
# Finally, clamp our image size if we're going to be too big.
|
|
90
|
+
if self.max_width is not None:
|
|
91
|
+
self.clamp_dimension(0, self.max_width)
|
|
92
|
+
if self.max_height is not None:
|
|
93
|
+
self.clamp_dimension(1, self.max_height)
|
|
94
|
+
|
|
95
|
+
# Return a reference to the figure (for convenience).
|
|
96
|
+
return self.figure
|
|
97
|
+
|
|
98
|
+
def plot(self):
|
|
99
|
+
"""
|
|
100
|
+
Main workhorse of the make_files method. Inheriting classes should write their own implementation.
|
|
101
|
+
|
|
102
|
+
This is called automatically by make_graph().
|
|
103
|
+
"""
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def adjust_axes(self):
|
|
107
|
+
"""
|
|
108
|
+
Adjust the axes of our graph.
|
|
109
|
+
|
|
110
|
+
This method is called automatically as part of the make() method.
|
|
111
|
+
"""
|
|
112
|
+
if self.hide_x:
|
|
113
|
+
self.x_label = "Arbitrary units"
|
|
114
|
+
self.axes.xaxis.set_ticks([])
|
|
115
|
+
if self.hide_y:
|
|
116
|
+
self.y_label = "Arbitrary units"
|
|
117
|
+
self.axes.yaxis.set_ticks([])
|
|
118
|
+
|
|
119
|
+
# Add our axes labels.
|
|
120
|
+
if not self.x_label == "":
|
|
121
|
+
self.axes.set_xlabel(self.x_label, labelpad = 8, fontsize = 16, weight = 'bold')
|
|
122
|
+
if not self.y_label == "":
|
|
123
|
+
self.axes.set_ylabel(self.y_label, labelpad = 8, fontsize = 16, weight = 'bold')
|
|
124
|
+
|
|
125
|
+
def clamp_dimension(self, dim, maximum):
|
|
126
|
+
"""
|
|
127
|
+
Ensure the image does not exceed a certain number of pixels in a given dimension.
|
|
128
|
+
|
|
129
|
+
:param dim: The dimension to clamp; 0 for x, 1 for y.
|
|
130
|
+
:param maximum: The maximum allowed pixels.
|
|
131
|
+
"""
|
|
132
|
+
# Check to see if we've exceeded our max size.
|
|
133
|
+
fig_size = self.figure.get_size_inches()
|
|
134
|
+
if (fig_size[dim] * self.output_dpi) > maximum:
|
|
135
|
+
# We're too big, reduce to max.
|
|
136
|
+
fig_size[dim] = maximum / self.output_dpi
|
|
137
|
+
self.figure.set_size_inches(fig_size)
|
|
138
|
+
|
|
139
|
+
# Update layout. Calling this twice is necessary for some reason loool
|
|
140
|
+
self.figure.tight_layout()
|
|
141
|
+
self.figure.tight_layout()
|
|
142
|
+
|
|
143
|
+
def constant_scale(self, axis, inch_per_axis):
|
|
144
|
+
"""
|
|
145
|
+
Adjust the size of our figure so a given axis has a constant scale.
|
|
146
|
+
|
|
147
|
+
You should adjust the corresponding axis' limits before calling this method.
|
|
148
|
+
|
|
149
|
+
:param axis: The axis to adjust, 0 for the x axis, 1 for the y axis.
|
|
150
|
+
:param inch_per_axis: The number of inches per unit of the given axis to adjust to.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
# First determine how much space we need for our margins.
|
|
154
|
+
axes_position = self.axes.get_position(False).get_points()
|
|
155
|
+
figure_size = self.figure.get_size_inches()
|
|
156
|
+
# Axes_position is in fractions, so multiply by our current figsize.
|
|
157
|
+
start_margin = axes_position[0][axis] * figure_size[axis]
|
|
158
|
+
end_margin = (1 - axes_position[1][axis]) * figure_size[axis]
|
|
159
|
+
|
|
160
|
+
# Next determine how much space we need for our actual data.
|
|
161
|
+
axes_lim = (self.axes.get_xlim(), self.axes.get_ylim())
|
|
162
|
+
axis_min = axes_lim[axis][0]
|
|
163
|
+
axis_max = axes_lim[axis][1]
|
|
164
|
+
#axis_min = pyplot.axis()[axis *2]
|
|
165
|
+
#axis_max = pyplot.axis()[axis *2 +1]
|
|
166
|
+
|
|
167
|
+
# We take the absolute because min can actually be bigger than max if our axes are inverted.
|
|
168
|
+
axis_length = math.fabs(axis_max - axis_min)
|
|
169
|
+
axis_size = axis_length * inch_per_axis
|
|
170
|
+
|
|
171
|
+
# Update size.
|
|
172
|
+
figure_size[axis] = start_margin + end_margin + axis_size
|
|
173
|
+
self.figure.set_size_inches(figure_size)
|
|
174
|
+
# And update the position of our axes.
|
|
175
|
+
axes_position[0][axis] = start_margin / figure_size[axis]
|
|
176
|
+
axes_position[1][axis] = 1 - (end_margin / figure_size[axis])
|
|
177
|
+
self.axes.set_position(Bbox(axes_position))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class OneD_graph_image_maker(Graph_image_maker):
|
|
181
|
+
"""
|
|
182
|
+
Classes for creating '1D' graphs using matplotlib's event plot.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(self, *args, **kwargs):
|
|
186
|
+
super().__init__(*args, **kwargs)
|
|
187
|
+
|
|
188
|
+
# Options that we'll pass to matplotlib's event plot function.
|
|
189
|
+
self.plot_options = {
|
|
190
|
+
"orientation": "vertical",
|
|
191
|
+
"lineoffsets": 0.75,
|
|
192
|
+
"linelengths": 1,
|
|
193
|
+
"linewidths": None,
|
|
194
|
+
"colors": None
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Set our output width and height.
|
|
198
|
+
self.init_image_width = 4.1
|
|
199
|
+
self.init_image_height = 5.6
|
|
200
|
+
|
|
201
|
+
def plot(self):
|
|
202
|
+
"""
|
|
203
|
+
Make the image described by this object.
|
|
204
|
+
"""
|
|
205
|
+
# Plot our 1D data.
|
|
206
|
+
self.plot_lines()
|
|
207
|
+
|
|
208
|
+
# Annotate with data labels.
|
|
209
|
+
self.plot_labels()
|
|
210
|
+
|
|
211
|
+
def plot_lines(self):
|
|
212
|
+
"""
|
|
213
|
+
Plot the lines that make up our graph.
|
|
214
|
+
|
|
215
|
+
This default implementation does nothing, inheriting classes should write their own.
|
|
216
|
+
"""
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
def plot_labels(self):
|
|
220
|
+
"""
|
|
221
|
+
Plot annotation labels that explain the data in out graph.
|
|
222
|
+
|
|
223
|
+
This default implementation does nothing, inheriting classes should write their own.
|
|
224
|
+
"""
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
class Convergence_graph_maker(Graph_image_maker):
|
|
228
|
+
"""
|
|
229
|
+
A class for creating graphs that show the convergence of energy in optimisation calculations.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(self, output, energies, **kwargs):
|
|
233
|
+
"""
|
|
234
|
+
Constructor for graph makers.
|
|
235
|
+
|
|
236
|
+
:param energies: A list of energies to plot (probably an Energy_list object).
|
|
237
|
+
:param output: A path to an output file to write to. The extension of this path is used to determine the format of the file (eg, png, jpeg).
|
|
238
|
+
"""
|
|
239
|
+
super().__init__(output, **kwargs)
|
|
240
|
+
self.energies = energies
|
|
241
|
+
|
|
242
|
+
# Our output width and height (in inch).
|
|
243
|
+
self.init_image_width = 7
|
|
244
|
+
self.init_image_height = 5.265
|
|
245
|
+
|
|
246
|
+
# Axis titles.
|
|
247
|
+
self.x_label = "Step number"
|
|
248
|
+
self.y_label = "Energy /eV"
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def from_options(self, output, *, energies, options, **kwargs):
|
|
252
|
+
"""
|
|
253
|
+
Constructor that takes a dictionary of config like options.
|
|
254
|
+
"""
|
|
255
|
+
return self(
|
|
256
|
+
output,
|
|
257
|
+
energies = energies,
|
|
258
|
+
**kwargs
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def plot(self):
|
|
262
|
+
"""
|
|
263
|
+
Plot the contents of our graph.
|
|
264
|
+
"""
|
|
265
|
+
# Plot a simple 'scatter' style graph.
|
|
266
|
+
self.axes.plot(range(1, len(self.energies) +1), self.energies)
|
|
267
|
+
|
|
268
|
+
def adjust_axes(self):
|
|
269
|
+
"""
|
|
270
|
+
Adjust the axes of our graph.
|
|
271
|
+
|
|
272
|
+
This method is called automatically as part of the make() method.
|
|
273
|
+
"""
|
|
274
|
+
super().adjust_axes()
|
|
275
|
+
|
|
276
|
+
# Axes range, the format is [xmin, xmax, ymin, ymax].
|
|
277
|
+
# We'll add a bit on to the y axis so we can see better.
|
|
278
|
+
y_diff = max(self.energies) - min(self.energies)
|
|
279
|
+
y_fudge = y_diff * 0.05
|
|
280
|
+
# Also the x.
|
|
281
|
+
x_diff = len(self.energies) - 1
|
|
282
|
+
x_fudge = x_diff * 0.05
|
|
283
|
+
|
|
284
|
+
self.axes.set_xlim(1 - x_fudge, len(self.energies) + x_fudge)
|
|
285
|
+
self.axes.set_ylim(min(self.energies) - y_fudge, max(self.energies) + y_fudge)
|
|
286
|
+
|
|
287
|
+
# Change spacing.
|
|
288
|
+
self.axes.xaxis.set_major_locator(ticker.MaxNLocator(nbins = '12', steps = [1, 2, 2.5, 5, 10], integer = True))
|
|
289
|
+
|
|
290
|
+
# Use matplotlib to automatically layout our graph.
|
|
291
|
+
self.figure.tight_layout()
|
|
292
|
+
|
|
293
|
+
|