qrotor 4.0.0a1__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.
Potentially problematic release.
This version of qrotor might be problematic. Click here for more details.
- qrotor/__init__.py +14 -0
- qrotor/_version.py +14 -0
- qrotor/constants.py +72 -0
- qrotor/plot.py +337 -0
- qrotor/potential.py +473 -0
- qrotor/rotate.py +202 -0
- qrotor/solve.py +271 -0
- qrotor/system.py +275 -0
- qrotor/systems.py +245 -0
- qrotor-4.0.0a1.dist-info/METADATA +167 -0
- qrotor-4.0.0a1.dist-info/RECORD +16 -0
- qrotor-4.0.0a1.dist-info/WHEEL +5 -0
- qrotor-4.0.0a1.dist-info/licenses/LICENSE +661 -0
- qrotor-4.0.0a1.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_qrotor.py +101 -0
qrotor/potential.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
# Description
|
|
3
|
+
|
|
4
|
+
This module contains functions to calculate the actual `potential_values` of the system.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Index
|
|
8
|
+
|
|
9
|
+
User functions:
|
|
10
|
+
|
|
11
|
+
| | |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `save()` | Save the potential from a System to a data file |
|
|
14
|
+
| `load()` | Load a System with a custom potential from a potential data file |
|
|
15
|
+
| `from_qe()` | Creates a potential data file from Quantum ESPRESSO outputs |
|
|
16
|
+
| `merge()` | Add and subtract potentials from systems |
|
|
17
|
+
| `scale()` | Scale potential values by a given factor |
|
|
18
|
+
|
|
19
|
+
To solve the system, optionally interpolating to a new gridsize, use the `System.solve(gridsize)` method.
|
|
20
|
+
However, if you just want to quickly solve or interpolate the potential, check the `System.solve_potential(gridsize)` method.
|
|
21
|
+
This will run several checks before applying the following functions automatically:
|
|
22
|
+
|
|
23
|
+
| | |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `interpolate()` | Interpolates the current `System.potential_values` to a new `System.gridsize` |
|
|
26
|
+
| `solve()` | Solve the potential values based on the potential name |
|
|
27
|
+
|
|
28
|
+
A synthetic potential can be created by specifying its name in `System.potential_name`,
|
|
29
|
+
along with the corresponding `System.potential_constants` if required.
|
|
30
|
+
Available potentials are:
|
|
31
|
+
|
|
32
|
+
| | |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `zero()` | Zero potential |
|
|
35
|
+
| `sine()` | Sine potential |
|
|
36
|
+
| `cosine()` | Cosine potential |
|
|
37
|
+
| `titov2023()` | Potential of the hindered methyl rotor, as in titov2023. |
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
from .system import System
|
|
44
|
+
from . import constants
|
|
45
|
+
from . import systems
|
|
46
|
+
import numpy as np
|
|
47
|
+
import os
|
|
48
|
+
from copy import deepcopy
|
|
49
|
+
from scipy.interpolate import CubicSpline
|
|
50
|
+
import aton.alias as alias
|
|
51
|
+
import aton.file as file
|
|
52
|
+
import aton.api.qe as qe
|
|
53
|
+
import aton.phys as phys
|
|
54
|
+
from ._version import __version__
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save(
|
|
58
|
+
system:System,
|
|
59
|
+
comment:str='',
|
|
60
|
+
filepath:str='potential.csv',
|
|
61
|
+
angle:str='deg',
|
|
62
|
+
energy:str='meV',
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Save the rotational potential from a `system` to a CSV file.
|
|
65
|
+
|
|
66
|
+
The output `filepath` contains angle and energy columns,
|
|
67
|
+
in degrees and meVs by default.
|
|
68
|
+
The units can be changed with `angle` and `energy`,
|
|
69
|
+
but only change these defaults if you know what you are doing.
|
|
70
|
+
"""
|
|
71
|
+
print('Saving potential data file...')
|
|
72
|
+
# Check if a previous potential.dat file exists, and ask to overwrite it
|
|
73
|
+
previous_potential_file = file.get(filepath, return_anyway=True)
|
|
74
|
+
if previous_potential_file:
|
|
75
|
+
print(f"WARNING: Previous '{filepath}' file will be overwritten, proceed anyway?")
|
|
76
|
+
answer = input("(y/n): ")
|
|
77
|
+
if not answer.lower() in alias.boolean[True]:
|
|
78
|
+
print("Aborted.")
|
|
79
|
+
return None
|
|
80
|
+
# Set header
|
|
81
|
+
potential_data = f'# {comment}\n' if comment else f'# {system.comment}\n' if system.comment else ''
|
|
82
|
+
potential_data += '# Rotational potential dataset\n'
|
|
83
|
+
potential_data += f'# Saved with QRotor {__version__}\n'
|
|
84
|
+
potential_data += '# https://pablogila.github.io/qrotor\n'
|
|
85
|
+
potential_data += '#\n'
|
|
86
|
+
# Check that grid and potential values are the same size
|
|
87
|
+
if len(system.grid) != len(system.potential_values):
|
|
88
|
+
raise ValueError('len(system.grid) != len(system.potential_values)')
|
|
89
|
+
grid = system.grid
|
|
90
|
+
potential_values = system.potential_values
|
|
91
|
+
# Convert angle units
|
|
92
|
+
if angle.lower() in alias.units['rad']:
|
|
93
|
+
potential_data += '# Angle/rad, '
|
|
94
|
+
else:
|
|
95
|
+
grid = np.degrees(grid)
|
|
96
|
+
potential_data += '# Angle/deg, '
|
|
97
|
+
if not angle.lower() in alias.units['deg']:
|
|
98
|
+
print(f"WARNING: Unrecognised '{angle}' angle units, using degrees instead")
|
|
99
|
+
# Convert energy units
|
|
100
|
+
if energy.lower() in alias.units['meV']:
|
|
101
|
+
potential_data += 'Potential/meV\n'
|
|
102
|
+
elif energy.lower() in alias.units['eV']:
|
|
103
|
+
potential_values = potential_values * phys.meV_to_eV
|
|
104
|
+
potential_data += 'Potential/eV\n'
|
|
105
|
+
elif energy.lower() in alias.units['Ry']:
|
|
106
|
+
potential_values = potential_values * phys.meV_to_Ry
|
|
107
|
+
potential_data += 'Potential/Ry\n'
|
|
108
|
+
else:
|
|
109
|
+
print(f"WARNING: Unrecognised '{energy}' energy units, using meV instead")
|
|
110
|
+
potential_data += 'Potential/meV\n'
|
|
111
|
+
potential_data += '#\n'
|
|
112
|
+
# Save all values
|
|
113
|
+
for angle_value, energy_value in zip(grid, potential_values):
|
|
114
|
+
potential_data += f'{angle_value}, {energy_value}\n'
|
|
115
|
+
with open(filepath, 'w') as f:
|
|
116
|
+
f.write(potential_data)
|
|
117
|
+
print(f'Saved to {filepath}')
|
|
118
|
+
# Warn the user if not in default units
|
|
119
|
+
if angle.lower() not in alias.units['deg']:
|
|
120
|
+
print(f"WARNING: You saved the potential in '{angle}' angle units! Remember that QRotor works in degrees!")
|
|
121
|
+
if energy.lower() not in alias.units['meV']:
|
|
122
|
+
print(f"WARNING: You saved the potential in '{energy}' energy units! Remember that QRotor works in meVs!")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load(
|
|
126
|
+
filepath:str='potential.csv',
|
|
127
|
+
comment:str=None,
|
|
128
|
+
system:System=None,
|
|
129
|
+
angle:str='deg',
|
|
130
|
+
energy:str='meV',
|
|
131
|
+
) -> System:
|
|
132
|
+
"""Read a rotational potential energy datafile.
|
|
133
|
+
|
|
134
|
+
The input file in `filepath` should contain two columns with angle and potential energy values.
|
|
135
|
+
Degrees and meV are assumed as default units unless stated in `angle` and `energy`.
|
|
136
|
+
Units will be converted automatically to radians and meV.
|
|
137
|
+
|
|
138
|
+
An optional `comment` can be included in the output System.
|
|
139
|
+
Set to the parent folder name by default.
|
|
140
|
+
|
|
141
|
+
A previous System object can be provided through `system` to update its potential values.
|
|
142
|
+
"""
|
|
143
|
+
file_path = file.get(filepath)
|
|
144
|
+
system = System() if system is None else system
|
|
145
|
+
with open(file_path, 'r') as f:
|
|
146
|
+
lines = f.readlines()
|
|
147
|
+
positions = []
|
|
148
|
+
potentials = []
|
|
149
|
+
for line in lines:
|
|
150
|
+
if line.startswith('#'):
|
|
151
|
+
continue
|
|
152
|
+
position, potential = line.split()
|
|
153
|
+
positions.append(float(position.strip().strip(',').strip()))
|
|
154
|
+
potentials.append(float(potential.strip()))
|
|
155
|
+
# Save angles to numpy arrays
|
|
156
|
+
if angle.lower() in alias.units['deg']:
|
|
157
|
+
positions = np.radians(positions)
|
|
158
|
+
elif angle.lower() in alias.units['rad']:
|
|
159
|
+
positions = np.array(positions)
|
|
160
|
+
else:
|
|
161
|
+
raise ValueError(f"Angle unit '{angle}' not recognized.")
|
|
162
|
+
# Save energies to numpy arrays
|
|
163
|
+
if energy.lower() in alias.units['eV']:
|
|
164
|
+
potentials = np.array(potentials) * phys.eV_to_meV
|
|
165
|
+
elif energy.lower() in alias.units['meV']:
|
|
166
|
+
potentials = np.array(potentials)
|
|
167
|
+
elif energy.lower() in alias.units['Ry']:
|
|
168
|
+
potentials = np.array(potentials) * phys.Ry_to_meV
|
|
169
|
+
else:
|
|
170
|
+
raise ValueError(f"Energy unit '{energy}' not recognized.")
|
|
171
|
+
# Set the system
|
|
172
|
+
system.grid = np.array(positions)
|
|
173
|
+
system.gridsize = len(positions)
|
|
174
|
+
system.potential_values = np.array(potentials)
|
|
175
|
+
# System comment as the parent folder name
|
|
176
|
+
system.comment = os.path.basename(os.path.dirname(file_path)) if comment==None else comment
|
|
177
|
+
print(f"Loaded {filepath}")
|
|
178
|
+
return system
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def from_qe(
|
|
182
|
+
folder=None,
|
|
183
|
+
filepath:str='potential.csv',
|
|
184
|
+
include:list=['.out'],
|
|
185
|
+
exclude:list=['slurm-'],
|
|
186
|
+
energy:str='meV',
|
|
187
|
+
comment:str=None,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Compiles a rotational potential CSV file from Quantum ESPRESSO outputs,
|
|
190
|
+
created with `qrotor.rotate.structure_qe()`.
|
|
191
|
+
|
|
192
|
+
The angle in degrees is extracted from the output filenames,
|
|
193
|
+
which must follow `whatever_ANGLE.out`.
|
|
194
|
+
|
|
195
|
+
Outputs from SCF calculations must be located in the provided `folder` (CWD if None).
|
|
196
|
+
Files can be filtered by those containing the specified `include` filters,
|
|
197
|
+
excluding those containing any string from the `exclude` list.
|
|
198
|
+
The output `filepath` name is `'potential.dat'` by default.
|
|
199
|
+
|
|
200
|
+
Energy values are saved to meV by dafault, unless specified in `energy`.
|
|
201
|
+
Only change the energy units if you know what you are doing;
|
|
202
|
+
remember that default energy units in QRotor are meV!
|
|
203
|
+
"""
|
|
204
|
+
folder = file.get_dir(folder)
|
|
205
|
+
# Check if a previous potential.dat file exists, and ask to overwrite it
|
|
206
|
+
previous_potential_file = file.get(filepath, return_anyway=True)
|
|
207
|
+
if previous_potential_file:
|
|
208
|
+
print(f"WARNING: Previous '{filepath}' file will be overwritten, proceed anyway?")
|
|
209
|
+
answer = input("(y/n): ")
|
|
210
|
+
if not answer.lower() in alias.boolean[True]:
|
|
211
|
+
print("Aborted.")
|
|
212
|
+
return None
|
|
213
|
+
# Get the files to read
|
|
214
|
+
files = file.get_list(folder=folder, include=include, exclude=exclude, abspath=True)
|
|
215
|
+
folder_name = os.path.basename(folder)
|
|
216
|
+
# Set header
|
|
217
|
+
potential_data = f'# {comment}\n' if comment else f'# {folder_name}\n'
|
|
218
|
+
potential_data += '# Rotational potential dataset\n'
|
|
219
|
+
potential_data += f'# Calculated with QE using QRotor {__version__}\n'
|
|
220
|
+
potential_data += '# https://pablogila.github.io/qrotor\n'
|
|
221
|
+
potential_data += '#\n'
|
|
222
|
+
if energy.lower() in alias.units['eV']:
|
|
223
|
+
potential_data += '# Angle/deg, Potential/eV\n'
|
|
224
|
+
elif energy.lower() in alias.units['meV']:
|
|
225
|
+
potential_data += '# Angle/deg, Potential/meV\n'
|
|
226
|
+
elif energy.lower() in alias.units['Ry']:
|
|
227
|
+
potential_data += '# Angle/deg, Potential/Ry\n'
|
|
228
|
+
else:
|
|
229
|
+
potential_data += '# Angle/deg, Potential/meV\n'
|
|
230
|
+
potential_data += '#\n'
|
|
231
|
+
potential_data_list = []
|
|
232
|
+
print('Extracting the potential as a function of the angle...')
|
|
233
|
+
print('----------------------------------')
|
|
234
|
+
counter_success = 0
|
|
235
|
+
counter_errors = 0
|
|
236
|
+
for file_path in files:
|
|
237
|
+
filename = os.path.basename(file_path)
|
|
238
|
+
file_path = file.get(filepath=file_path, include='.out', return_anyway=True)
|
|
239
|
+
if not file_path: # Not an output file, skip it
|
|
240
|
+
continue
|
|
241
|
+
content = qe.read_out(file_path)
|
|
242
|
+
if not content['Success']: # Ignore unsuccessful calculations
|
|
243
|
+
print(f'x {filename}')
|
|
244
|
+
counter_errors += 1
|
|
245
|
+
continue
|
|
246
|
+
if energy.lower() in alias.units['eV']:
|
|
247
|
+
energy_value = content['Energy'] * phys.Ry_to_eV
|
|
248
|
+
elif energy.lower() in alias.units['meV']:
|
|
249
|
+
energy_value = content['Energy'] * phys.Ry_to_meV
|
|
250
|
+
elif energy.lower() in alias.units['Ry']:
|
|
251
|
+
energy_value = content['Energy']
|
|
252
|
+
else:
|
|
253
|
+
print(f"WARNING: Energy unit '{energy}' not recognized, using meV instead.")
|
|
254
|
+
energy = 'meV'
|
|
255
|
+
energy_value = content['Energy'] * phys.Ry_to_meV
|
|
256
|
+
splits = filename.split('_')
|
|
257
|
+
angle_value = splits[-1].replace('.out', '')
|
|
258
|
+
angle_value = float(angle_value)
|
|
259
|
+
potential_data_list.append((angle_value, energy_value))
|
|
260
|
+
print(f'OK {filename}')
|
|
261
|
+
counter_success += 1
|
|
262
|
+
# Sort by angle
|
|
263
|
+
potential_data_list_sorted = sorted(potential_data_list, key=lambda x: x[0])
|
|
264
|
+
# Append the sorted values as a string
|
|
265
|
+
for angle_value, energy_value in potential_data_list_sorted:
|
|
266
|
+
potential_data += f'{angle_value}, {energy_value}\n'
|
|
267
|
+
with open(filepath, 'w') as f:
|
|
268
|
+
f.write(potential_data)
|
|
269
|
+
print('----------------------------------')
|
|
270
|
+
print(f'Succesful calculations (OK): {counter_success}')
|
|
271
|
+
print(f'Faulty calculations (x): {counter_errors}')
|
|
272
|
+
print('----------------------------------')
|
|
273
|
+
print(f'Saved angles and potential values at {filepath}')
|
|
274
|
+
# Warn the user if not in default units
|
|
275
|
+
if energy.lower() not in alias.units['meV']:
|
|
276
|
+
print(f"WARNING: You saved the potential in '{energy}' units! Remember that QRotor works in meVs!")
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def merge(
|
|
281
|
+
add=[],
|
|
282
|
+
subtract=[],
|
|
283
|
+
comment:str=None
|
|
284
|
+
) -> System:
|
|
285
|
+
"""Add or subtract potentials from different systems.
|
|
286
|
+
|
|
287
|
+
Adds the potential values from the systems in `add`,
|
|
288
|
+
removes the ones from `subtract`.
|
|
289
|
+
All systems will be interpolated to the bigger gridsize if needed.
|
|
290
|
+
|
|
291
|
+
A copy of the first System will be returned with the resulting potential values,
|
|
292
|
+
with an optional `comment` if indicated.
|
|
293
|
+
"""
|
|
294
|
+
add = systems.as_list(add)
|
|
295
|
+
subtract = systems.as_list(subtract)
|
|
296
|
+
gridsizes = systems.get_gridsizes(add)
|
|
297
|
+
gridsizes.extend(systems.get_gridsizes(subtract))
|
|
298
|
+
max_gridsize = max(gridsizes)
|
|
299
|
+
# All gridsizes should be max_gridsize
|
|
300
|
+
for s in add:
|
|
301
|
+
if s.gridsize != max_gridsize:
|
|
302
|
+
s.gridsize = max_gridsize
|
|
303
|
+
s = interpolate(s)
|
|
304
|
+
for s in subtract:
|
|
305
|
+
if s.gridsize != max_gridsize:
|
|
306
|
+
s.gridsize = max_gridsize
|
|
307
|
+
s = interpolate(s)
|
|
308
|
+
|
|
309
|
+
if len(add) == 0:
|
|
310
|
+
if len(subtract) == 0:
|
|
311
|
+
raise ValueError('No systems were provided!')
|
|
312
|
+
result = deepcopy(subtract[0])
|
|
313
|
+
result.potential_values = -result.potential_values
|
|
314
|
+
subtract.pop(0)
|
|
315
|
+
else:
|
|
316
|
+
result = deepcopy(add[0])
|
|
317
|
+
add.pop(0)
|
|
318
|
+
|
|
319
|
+
for system in add:
|
|
320
|
+
result.potential_values = np.sum([result.potential_values, system.potential_values], axis=0)
|
|
321
|
+
for system in subtract:
|
|
322
|
+
result.potential_values = np.sum([result.potential_values, -system.potential_values], axis=0)
|
|
323
|
+
if comment != None:
|
|
324
|
+
result.comment = comment
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def scale(
|
|
329
|
+
system:System,
|
|
330
|
+
factor:float,
|
|
331
|
+
comment:str=None
|
|
332
|
+
) -> System:
|
|
333
|
+
"""Returns a copy of `system` with potential values scaled by a `factor`.
|
|
334
|
+
|
|
335
|
+
An optional `comment` can be included.
|
|
336
|
+
"""
|
|
337
|
+
result = deepcopy(system)
|
|
338
|
+
if factor != 0:
|
|
339
|
+
result.potential_values = system.potential_values * factor
|
|
340
|
+
else:
|
|
341
|
+
result.potential_values = np.zeros(system.gridsize)
|
|
342
|
+
if comment != None:
|
|
343
|
+
result.comment = comment
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def interpolate(system:System) -> System:
|
|
348
|
+
"""Interpolates the current `System.potential_values`
|
|
349
|
+
to a new grid of size `System.gridsize`.
|
|
350
|
+
|
|
351
|
+
This basic function is called by `qrotor.solve.potential()`,
|
|
352
|
+
which is the recommended way to interpolate potentials.
|
|
353
|
+
"""
|
|
354
|
+
print(f"Interpolating potential to a grid of size {system.gridsize}...")
|
|
355
|
+
V = system.potential_values
|
|
356
|
+
grid = system.grid
|
|
357
|
+
gridsize = system.gridsize
|
|
358
|
+
new_grid = np.linspace(0, 2*np.pi, gridsize)
|
|
359
|
+
cubic_spline = CubicSpline(grid, V)
|
|
360
|
+
new_V = cubic_spline(new_grid)
|
|
361
|
+
system.grid = new_grid
|
|
362
|
+
system.potential_values = new_V
|
|
363
|
+
return system
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def solve(system:System):
|
|
367
|
+
"""Solves `System.potential_values`
|
|
368
|
+
according to the `System.potential_name`,
|
|
369
|
+
returning the new `potential_values`.
|
|
370
|
+
Avaliable potential names are `zero`, `sine` and `titov2023`.
|
|
371
|
+
|
|
372
|
+
If `System.potential_name` is not present or not recognised,
|
|
373
|
+
the current `System.potential_values` are used.
|
|
374
|
+
|
|
375
|
+
This basic function is called by `qrotor.solve.potential()`,
|
|
376
|
+
which is the recommended way to solve potentials.
|
|
377
|
+
"""
|
|
378
|
+
data = deepcopy(system)
|
|
379
|
+
# Is there a potential_name?
|
|
380
|
+
if not data.potential_name:
|
|
381
|
+
if data.potential_values is None or len(data.potential_values) == 0:
|
|
382
|
+
raise ValueError(f'No potential_name and no potential_values found in the system!')
|
|
383
|
+
elif data.potential_name.lower() == 'titov2023':
|
|
384
|
+
data.potential_values = titov2023(data)
|
|
385
|
+
elif data.potential_name.lower() in alias.math['0']:
|
|
386
|
+
data.potential_values = zero(data)
|
|
387
|
+
elif data.potential_name.lower() in alias.math['sin']:
|
|
388
|
+
data.potential_values = sine(data)
|
|
389
|
+
elif data.potential_name.lower() in alias.math['cos']:
|
|
390
|
+
data.potential_values = cosine(data)
|
|
391
|
+
# At least there should be potential_values
|
|
392
|
+
#elif not any(data.potential_values):
|
|
393
|
+
elif data.potential_values is None or len(data.potential_values) == 0:
|
|
394
|
+
raise ValueError("Unrecognised potential_name '{data.potential_name}' and no potential_values found")
|
|
395
|
+
return data.potential_values
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def zero(system:System):
|
|
399
|
+
"""Zero potential.
|
|
400
|
+
|
|
401
|
+
$V(x) = 0$
|
|
402
|
+
"""
|
|
403
|
+
x = system.grid
|
|
404
|
+
return 0 * np.array(x)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def sine(system:System):
|
|
408
|
+
"""Sine potential.
|
|
409
|
+
|
|
410
|
+
$V(x) = C_0 + \\frac{C_1}{2} sin(x C_2 + C_3)$
|
|
411
|
+
With $C_0$ as the potential offset,
|
|
412
|
+
$C_1$ as the max potential value (without considering the offset),
|
|
413
|
+
$C_2$ as the frequency, and $C_3$ as the phase.
|
|
414
|
+
If no `System.potential_constants` are provided, defaults to $sin(3x)$
|
|
415
|
+
"""
|
|
416
|
+
x = system.grid
|
|
417
|
+
C = system.potential_constants
|
|
418
|
+
C0 = 0
|
|
419
|
+
C1 = 1
|
|
420
|
+
C2 = 3
|
|
421
|
+
C3 = 0
|
|
422
|
+
if C:
|
|
423
|
+
if len(C) > 0:
|
|
424
|
+
C0 = C[0]
|
|
425
|
+
if len(C) > 1:
|
|
426
|
+
C1 = C[1]
|
|
427
|
+
if len(C) > 2:
|
|
428
|
+
C2 = C[2]
|
|
429
|
+
if len(C) > 3:
|
|
430
|
+
C3 = C[3]
|
|
431
|
+
return C0 + (C1 / 2) * np.sin(np.array(x) * C2 + C3)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def cosine(system:System):
|
|
435
|
+
"""Cosine potential.
|
|
436
|
+
|
|
437
|
+
$V(x) = C_0 + \\frac{C_1}{2} cos(x C_2 + C_3)$
|
|
438
|
+
With $C_0$ as the potential offset,
|
|
439
|
+
$C_1$ as the max potential value (without considering the offset),
|
|
440
|
+
$C_2$ as the frequency, and $C_3$ as the phase.
|
|
441
|
+
If no `System.potential_constants` are provided, defaults to $cos(3x)$
|
|
442
|
+
"""
|
|
443
|
+
x = system.grid
|
|
444
|
+
C = system.potential_constants
|
|
445
|
+
C0 = 0
|
|
446
|
+
C1 = 1
|
|
447
|
+
C2 = 3
|
|
448
|
+
C3 = 0
|
|
449
|
+
if C:
|
|
450
|
+
if len(C) > 0:
|
|
451
|
+
C0 = C[0]
|
|
452
|
+
if len(C) > 1:
|
|
453
|
+
C1 = C[1]
|
|
454
|
+
if len(C) > 2:
|
|
455
|
+
C2 = C[2]
|
|
456
|
+
if len(C) > 3:
|
|
457
|
+
C3 = C[3]
|
|
458
|
+
return C0 + (C1 / 2) * np.cos(np.array(x) * C2 + C3)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def titov2023(system:System):
|
|
462
|
+
"""Potential energy function of the hindered methyl rotor, from
|
|
463
|
+
[K. Titov et al., Phys. Rev. Mater. 7, 073402 (2023)](https://link.aps.org/doi/10.1103/PhysRevMaterials.7.073402).
|
|
464
|
+
|
|
465
|
+
$V(x) = C_0 + C_1 sin(3x) + C_2 cos(3x) + C_3 sin(6x) + C_4 cos(6x)$
|
|
466
|
+
Default constants are `qrotor.constants.constants_titov2023`[0].
|
|
467
|
+
"""
|
|
468
|
+
x = system.grid
|
|
469
|
+
C = system.potential_constants
|
|
470
|
+
if C is None:
|
|
471
|
+
C = constants.constants_titov2023[0]
|
|
472
|
+
return C[0] + C[1] * np.sin(3*x) + C[2] * np.cos(3*x) + C[3] * np.sin(6*x) + C[4] * np.cos(6*x)
|
|
473
|
+
|
qrotor/rotate.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
# Description
|
|
3
|
+
|
|
4
|
+
This submodule contains tools to rotate molecular structures.
|
|
5
|
+
Works with Quantum ESPRESSO input files.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Index
|
|
9
|
+
|
|
10
|
+
| | |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `structure_qe()` | Rotate specific atoms from a Quantum ESPRESSO input file |
|
|
13
|
+
| `rotate_coords()` | Rotate a specific list of coordinates |
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
from scipy.spatial.transform import Rotation
|
|
23
|
+
from .constants import *
|
|
24
|
+
import aton.api as api
|
|
25
|
+
import aton.txt.extract as extract
|
|
26
|
+
import aton.txt.edit as edit
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def structure_qe(
|
|
30
|
+
filepath:str,
|
|
31
|
+
positions:list,
|
|
32
|
+
angle:float,
|
|
33
|
+
repeat:bool=False,
|
|
34
|
+
precision:int=3,
|
|
35
|
+
use_centroid:bool=True,
|
|
36
|
+
show_axis:bool=False,
|
|
37
|
+
) -> list:
|
|
38
|
+
"""Rotates atoms from a Quantum ESPRESSO input file.
|
|
39
|
+
|
|
40
|
+
Takes a `filepath` with a molecular structure, and three or more atomic `positions` (list).
|
|
41
|
+
These input positions can be approximate, and are used to identify the target atoms.
|
|
42
|
+
The decimal precision in the search for these positions is controlled by `precision`.
|
|
43
|
+
|
|
44
|
+
It rotates the atoms by a specific `angle` in degrees.
|
|
45
|
+
Additionally, if `repeat = True` it repeats the same rotation over the whole circunference.
|
|
46
|
+
Finally, it writes the rotated structure(s) to a new structural file(s).
|
|
47
|
+
Returns a list with the output filename(s).
|
|
48
|
+
|
|
49
|
+
By default, the rotation axis is defined by the perpendicular vector
|
|
50
|
+
passing through the geometrical center of the first three points.
|
|
51
|
+
To override this and instead use the vector between the first two atoms
|
|
52
|
+
as the rotation axis, set `use_centroid = False`.
|
|
53
|
+
|
|
54
|
+
**WARNING: The `positions` list is order-sensitive**.
|
|
55
|
+
If you rotate more than one chemical group in a structure,
|
|
56
|
+
be sure to follow the same direction for each group (e.g. all clockwise)
|
|
57
|
+
to ensure that all axes of rotation point in the same direction.
|
|
58
|
+
|
|
59
|
+
To debug, `show_axis = True` adds two additional helium atoms as the rotation vector.
|
|
60
|
+
|
|
61
|
+
The resulting rotational potential can be compiled to a CSV file with `qrotor.potential.from_qe()`.
|
|
62
|
+
"""
|
|
63
|
+
print('Rotating Quantum ESPRESSO input structure with QRotor...')
|
|
64
|
+
if len(positions) < 3:
|
|
65
|
+
raise ValueError("At least three positions are required to define the rotation axis.")
|
|
66
|
+
lines = []
|
|
67
|
+
full_positions = []
|
|
68
|
+
for position in positions:
|
|
69
|
+
line = api.qe.get_atom(filepath, position, precision)
|
|
70
|
+
lines.append(line)
|
|
71
|
+
pos = extract.coords(line)
|
|
72
|
+
if len(pos) > 3: # Keep only the first three coordinates
|
|
73
|
+
pos = pos[:3]
|
|
74
|
+
# Convert to cartesian
|
|
75
|
+
pos_cartesian = api.qe.to_cartesian(filepath, pos)
|
|
76
|
+
full_positions.append(pos_cartesian)
|
|
77
|
+
print(f'Found atom: "{line}"')
|
|
78
|
+
# Set the angles to rotate
|
|
79
|
+
if not repeat:
|
|
80
|
+
angles = [angle]
|
|
81
|
+
else:
|
|
82
|
+
angles = range(0, 360, angle)
|
|
83
|
+
# Rotate and save the structure
|
|
84
|
+
outputs = []
|
|
85
|
+
path = os.path.dirname(filepath)
|
|
86
|
+
basename = os.path.basename(filepath)
|
|
87
|
+
name, ext = os.path.splitext(basename)
|
|
88
|
+
print('Rotating the structure...')
|
|
89
|
+
for angle in angles:
|
|
90
|
+
output_name = name + f'_{angle}' + ext
|
|
91
|
+
output = os.path.join(path, output_name)
|
|
92
|
+
rotated_positions_cartesian = rotate_coords(full_positions, angle, use_centroid, show_axis)
|
|
93
|
+
rotated_positions = []
|
|
94
|
+
for coord in rotated_positions_cartesian:
|
|
95
|
+
pos = api.qe.from_cartesian(filepath, coord)
|
|
96
|
+
rotated_positions.append(pos)
|
|
97
|
+
_save_qe(filepath, output, lines, rotated_positions)
|
|
98
|
+
outputs.append(output)
|
|
99
|
+
print(output)
|
|
100
|
+
return outputs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def rotate_coords(
|
|
104
|
+
positions:list,
|
|
105
|
+
angle:float,
|
|
106
|
+
use_centroid:bool=True,
|
|
107
|
+
show_axis:bool=False,
|
|
108
|
+
) -> list:
|
|
109
|
+
"""Rotates geometrical coordinates.
|
|
110
|
+
|
|
111
|
+
Takes a list of atomic `positions` in cartesian coordinates, as
|
|
112
|
+
`[[x1,y1,z1], [x2,y2,z2], [x3,y3,z3], [etc]`.
|
|
113
|
+
Then rotates said coordinates by a given `angle` in degrees.
|
|
114
|
+
Returns a list with the updated positions.
|
|
115
|
+
|
|
116
|
+
By default, the rotation vector is defined by the perpendicular
|
|
117
|
+
passing through the geometrical center of the first three points.
|
|
118
|
+
To override this and use the vector between the first two atoms
|
|
119
|
+
as the rotation axis, set `use_centroid = False`.
|
|
120
|
+
|
|
121
|
+
**WARNING: The `positions` list is order-sensitive**.
|
|
122
|
+
If you rotate more than one chemical group in a structure,
|
|
123
|
+
be sure to follow the same direction for each group (e.g. all clockwise)
|
|
124
|
+
to ensure that all rotation vectors point in the same direction.
|
|
125
|
+
|
|
126
|
+
If `show_axis = True` it returns two additional coordinates at the end of the list,
|
|
127
|
+
with the centroid and the rotation vector. Only works with `use_centroid = True`.
|
|
128
|
+
|
|
129
|
+
The rotation uses Rodrigues' rotation formula,
|
|
130
|
+
powered by [`scipy.spatial.transform.Rotation.from_rotvec`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.from_rotvec.html#scipy.spatial.transform.Rotation.from_rotvec).
|
|
131
|
+
"""
|
|
132
|
+
if len(positions) < 3:
|
|
133
|
+
raise ValueError("At least three atoms must be rotated.")
|
|
134
|
+
if not isinstance(positions[0], list):
|
|
135
|
+
raise ValueError(f"Atomic positions must have the form: [[x1,y1,z1], [x2,y2,z2], [x3,y3,z3], etc]. Yours were:\n{positions}")
|
|
136
|
+
positions = np.array(positions)
|
|
137
|
+
#print(f'POSITIONS: {positions}') # DEBUG
|
|
138
|
+
# Define the geometrical center
|
|
139
|
+
center_atoms = positions[:2]
|
|
140
|
+
if use_centroid:
|
|
141
|
+
center_atoms = positions[:3]
|
|
142
|
+
center = np.mean(center_atoms, axis=0)
|
|
143
|
+
# Ensure the axis passes through the geometrical center
|
|
144
|
+
centered_positions = positions - center
|
|
145
|
+
# Define the perpendicular axis (normal to the plane formed by the first three points)
|
|
146
|
+
v1 = centered_positions[0] - centered_positions[1]
|
|
147
|
+
v2 = centered_positions[0] - centered_positions[2]
|
|
148
|
+
axis = v1 # Axis defined by the first two points
|
|
149
|
+
if use_centroid: # Axis defined by the cross product of the first three points
|
|
150
|
+
axis = np.cross(v2, v1)
|
|
151
|
+
axis_length = np.linalg.norm(axis)
|
|
152
|
+
axis = axis / axis_length
|
|
153
|
+
# Create the rotation object using scipy
|
|
154
|
+
rotation = Rotation.from_rotvec(np.radians(angle) * axis)
|
|
155
|
+
# Rotate all coordinates around the geometrical center
|
|
156
|
+
rotated_centered_positions = rotation.apply(centered_positions)
|
|
157
|
+
rotated_positions = (rotated_centered_positions + center).tolist()
|
|
158
|
+
#print(f'ROTATED_POSITIONS: {rotated_positions}') # DEBUG
|
|
159
|
+
if show_axis and use_centroid:
|
|
160
|
+
rotated_positions.append(center.tolist())
|
|
161
|
+
rotated_positions.append((center + axis).tolist())
|
|
162
|
+
return rotated_positions
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _save_qe(
|
|
166
|
+
filename,
|
|
167
|
+
output:str,
|
|
168
|
+
lines:list,
|
|
169
|
+
positions:list
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Copies `filename` to `output`, updating the old `lines` with the new `positions`.
|
|
172
|
+
|
|
173
|
+
The angle will be appended at the end of the input prefix to avoid overlapping calculations.
|
|
174
|
+
"""
|
|
175
|
+
shutil.copy(filename, output)
|
|
176
|
+
for i, line in enumerate(lines):
|
|
177
|
+
strings = line.split()
|
|
178
|
+
atom = strings[0]
|
|
179
|
+
new_line = f" {atom} {positions[i][0]:.15f} {positions[i][1]:.15f} {positions[i][2]:.15f}"
|
|
180
|
+
#print(f'OLD LINE: {line}') # DEBUG
|
|
181
|
+
#print(f'NEW_LINE: {new_line}') # DEBUG
|
|
182
|
+
edit.replace_line(output, line, new_line, raise_errors=True)
|
|
183
|
+
if len(lines) + 2 == len(positions): # In case show_axis=True
|
|
184
|
+
additional_positions = positions[-2:]
|
|
185
|
+
for pos in additional_positions:
|
|
186
|
+
pos.insert(0, 'He')
|
|
187
|
+
api.qe.add_atom(output, pos)
|
|
188
|
+
elif len(lines) != len(positions):
|
|
189
|
+
raise ValueError(f"What?! len(lines)={len(lines)} and len(positions)={len(positions)}")
|
|
190
|
+
# Add angle to calculation prefix
|
|
191
|
+
output_name = os.path.basename(output)
|
|
192
|
+
splits = output_name.split('_')
|
|
193
|
+
angle_str = splits[-1].replace('.in', '')
|
|
194
|
+
prefix = ''
|
|
195
|
+
content = api.qe.read_in(output)
|
|
196
|
+
if 'prefix' in content.keys():
|
|
197
|
+
prefix = content['prefix']
|
|
198
|
+
prefix = prefix.strip("'")
|
|
199
|
+
prefix = "'" + prefix + angle_str + "'"
|
|
200
|
+
api.qe.set_value(output, 'prefix', prefix)
|
|
201
|
+
return output
|
|
202
|
+
|