qrotor.potential

Description

This module contains functions to calculate the actual potential_values of the system.

Index

User functions:

save() Save the potential from a System to a data file
load() Load a System with a custom potential from a potential data file
from_qe() Creates a potential data file from Quantum ESPRESSO outputs
merge() Add and subtract potentials from systems
scale() Scale potential values by a given factor

To solve the system, optionally interpolating to a new gridsize, use the System.solve(gridsize) method.
However, if you just want to quickly solve or interpolate the potential, check the System.solve_potential(gridsize) method. This will run several checks before applying the following functions automatically:

interpolate() Interpolates the current System.potential_values to a new System.gridsize
solve() Solve the potential values based on the potential name

A synthetic potential can be created by specifying its name in System.potential_name, along with the corresponding System.potential_constants if required. Available potentials are:

zero() Zero potential
sine() Sine potential
cosine() Cosine potential
titov2023() Potential of the hindered methyl rotor, as in titov2023.

  1"""
  2# Description
  3
  4This module contains functions to calculate the actual `potential_values` of the system.
  5
  6
  7# Index
  8
  9User 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
 19To solve the system, optionally interpolating to a new gridsize, use the `System.solve(gridsize)` method.  
 20However, if you just want to quickly solve or interpolate the potential, check the `System.solve_potential(gridsize)` method.
 21This 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
 28A synthetic potential can be created by specifying its name in `System.potential_name`,
 29along with the corresponding `System.potential_constants` if required.
 30Available 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
 43from .system import System
 44from . import constants
 45from . import systems
 46import numpy as np
 47import os
 48from copy import deepcopy
 49from scipy.interpolate import CubicSpline
 50import aton.alias as alias
 51import aton.file as file
 52import aton.api.pwx as pwx
 53from ._version import __version__
 54
 55
 56def save(
 57        system:System,
 58        comment:str='',
 59        filepath:str='potential.csv',
 60        angle:str='deg',
 61        energy:str='meV',
 62        ) -> None:
 63    """Save the rotational potential from a `system` to a CSV file.
 64
 65    The output `filepath` contains angle and energy columns,
 66    in degrees and meVs by default.
 67    The units can be changed with `angle` and `energy`,
 68    but only change these defaults if you know what you are doing.
 69    An optional `comment` can be included in the header of the file.
 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 * 1e-3
104        potential_data += 'Potential/eV\n'
105    elif energy.lower() in alias.units['Ry']:
106        potential_values = potential_values * constants.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
125def load(
126        filepath:str='potential.csv',
127        comment:str=None,
128        tags:str='',
129        system:System=None,
130        angle:str='deg',
131        energy:str='meV',
132        ) -> System:
133    """Read a rotational potential energy datafile.
134
135    The input file in `filepath` should contain two columns with angle and potential energy values.
136    Degrees and meV are assumed as default units unless stated in `angle` and `energy`.
137    Units will be converted automatically to radians and meV.
138
139    An optional `comment` can be included in the output System.
140    Set to the parent folder name by default.
141
142    A previous System object can be provided through `system` to update its potential values.
143    """
144    file_path = file.get(filepath)
145    system = System() if system is None else system
146    with open(file_path, 'r') as f:
147        lines = f.readlines()
148    # Read the comment
149    loaded_comment = ''
150    if lines[0].startswith('## '):
151        loaded_comment = lines[0][3:].strip()
152    # Read data
153    positions = []
154    potentials = []
155    for line in lines:
156        if line.startswith('#'):
157            continue
158        position, potential = line.split()
159        positions.append(float(position.strip().strip(',').strip()))
160        potentials.append(float(potential.strip()))
161    # Save angles to numpy arrays
162    if angle.lower() in alias.units['deg']:
163        positions = np.radians(positions)
164    elif angle.lower() in alias.units['rad']:
165        positions = np.array(positions)
166    else:
167        raise ValueError(f"Angle unit '{angle}' not recognized.")
168    # Save energies to numpy arrays
169    if energy.lower() in alias.units['eV']:
170        potentials = np.array(potentials) * 1000
171    elif energy.lower() in alias.units['meV']:
172        potentials = np.array(potentials)
173    elif energy.lower() in alias.units['Ry']:
174        potentials = np.array(potentials) * constants.Ry_to_meV
175    else:
176        raise ValueError(f"Energy unit '{energy}' not recognized.")
177    # Set the system
178    system.grid = np.array(positions)
179    system.gridsize = len(positions)
180    system.potential_values = np.array(potentials)
181    # System comment as the loaded comment or the parent folder name
182    if comment:
183        system.comment = comment
184    elif loaded_comment:
185        system.comment = loaded_comment
186    else:
187        system.comment = os.path.basename(os.path.dirname(file_path))
188    if tags:
189        system.tags = tags
190    print(f"Loaded {filepath}")
191    return system
192
193
194def from_qe(
195        folder=None,
196        filepath:str='potential.csv',
197        include:list=['.out'],
198        exclude:list=['slurm-'],
199        energy:str='meV',
200        comment:str=None,
201        ) -> System:
202    """Compiles a rotational potential CSV file from Quantum ESPRESSO pw.x outputs,
203    created with `qrotor.rotation.rotate_qe()`.
204    Returns a `System` object with the new potential values.
205
206    The angle in degrees is extracted from the output filenames,
207    which must follow `whatever_ANGLE.out`.
208
209    Outputs from SCF calculations must be located in the provided `folder` (CWD if None).
210    Files can be filtered by those containing the specified `include` filters,
211    excluding those containing any string from the `exclude` list. 
212    The output `filepath` name is `'potential.dat'` by default.
213
214    Energy values are saved to meV by dafault, unless specified in `energy`.
215    Only change the energy units if you know what you are doing;
216    remember that default energy units in QRotor are meV!
217    """
218    folder = file.get_dir(folder)
219    # Check if a previous potential.dat file exists, and ask to overwrite it
220    previous_potential_file = file.get(filepath, return_anyway=True)
221    if previous_potential_file:
222        print(f"WARNING: Previous '{filepath}' file will be overwritten, proceed anyway?")
223        answer = input("(y/n): ")
224        if not answer.lower() in alias.boolean[True]:
225            print("Aborted.")
226            return None
227    # Get the files to read
228    files = file.get_list(folder=folder, include=include, exclude=exclude, abspath=True)
229    folder_name = os.path.basename(folder)
230    # Set header
231    potential_data = f'## {comment}\n' if comment else f'## {folder_name}\n'
232    potential_data += '# Rotational potential dataset\n'
233    potential_data += f'# Calculated with QE pw.x using QRotor {__version__}\n'
234    potential_data += '# https://pablogila.github.io/qrotor\n'
235    potential_data += '#\n'
236    if energy.lower() in alias.units['eV']:
237        potential_data += '# Angle/deg,    Potential/eV\n'
238    elif energy.lower() in alias.units['meV']:
239        potential_data += '# Angle/deg,    Potential/meV\n'
240    elif energy.lower() in alias.units['Ry']:
241        potential_data += '# Angle/deg,    Potential/Ry\n'
242    else:
243        potential_data += '# Angle/deg,    Potential/meV\n'
244    potential_data += '#\n'
245    potential_data_list = []
246    print('Extracting the potential as a function of the angle...')
247    print('----------------------------------')
248    counter_success = 0
249    counter_errors = 0
250    for file_path in files:
251        filename = os.path.basename(file_path)
252        file_path = file.get(filepath=file_path, include='.out', return_anyway=True)
253        if not file_path:  # Not an output file, skip it
254            continue
255        content = pwx.read_out(file_path)
256        if not content['Success']:  # Ignore unsuccessful calculations
257            print(f'x   {filename}')
258            counter_errors += 1
259            continue
260        if energy.lower() in alias.units['eV']:
261            energy_value = content['Energy'] * constants.Ry_to_eV
262        elif energy.lower() in alias.units['meV']:
263            energy_value = content['Energy'] * constants.Ry_to_meV
264        elif energy.lower() in alias.units['Ry']:
265            energy_value = content['Energy']
266        else:
267            print(f"WARNING: Energy unit '{energy}' not recognized, using meV instead.")
268            energy = 'meV'
269            energy_value = content['Energy'] * constants.Ry_to_meV
270        splits = filename.split('_')
271        angle_value = splits[-1].replace('.out', '')
272        angle_value = float(angle_value)
273        potential_data_list.append((angle_value, energy_value))
274        print(f'OK  {filename}')
275        counter_success += 1
276    # Sort by angle
277    potential_data_list_sorted = sorted(potential_data_list, key=lambda x: x[0])
278    # Append the sorted values as a string
279    for angle_value, energy_value in potential_data_list_sorted:
280        potential_data += f'{angle_value},    {energy_value}\n'
281    with open(filepath, 'w') as f:
282        f.write(potential_data)
283    print('----------------------------------')
284    print(f'Succesful calculations (OK): {counter_success}')
285    print(f'Faulty calculations     (x): {counter_errors}')
286    print('----------------------------------')
287    print(f'Saved angles and potential values at {filepath}')
288    # Warn the user if not in default units
289    if energy.lower() not in alias.units['meV']:
290        print(f"WARNING: You saved the potential in '{energy}' units! Remember that QRotor works in meVs!")
291    new_system = None
292    try:
293        new_system = load(filepath=filepath, comment=comment, energy=energy)
294    except:
295        pass
296    return new_system
297
298
299def merge(
300        add=[],
301        subtract=[],
302        comment:str=None
303        ) -> System:
304    """Add or subtract potentials from different systems.
305
306    Adds the potential values from the systems in `add`,
307    removes the ones from `subtract`.
308    All systems will be interpolated to the bigger gridsize if needed.
309
310    A copy of the first System will be returned with the resulting potential values,
311    with an optional `comment` if indicated.
312    """
313    add = systems.as_list(add)
314    subtract = systems.as_list(subtract)
315    gridsizes = systems.get_gridsizes(add)
316    gridsizes.extend(systems.get_gridsizes(subtract))
317    max_gridsize = max(gridsizes)
318    # All gridsizes should be max_gridsize
319    for s in add:
320        if s.gridsize != max_gridsize:
321            s.gridsize = max_gridsize
322            s = interpolate(s)
323    for s in subtract:
324        if s.gridsize != max_gridsize:
325            s.gridsize = max_gridsize
326            s = interpolate(s)
327
328    if len(add) == 0:
329        if len(subtract) == 0:
330            raise ValueError('No systems were provided!')
331        result = deepcopy(subtract[0])
332        result.potential_values = -result.potential_values
333        subtract.pop(0)
334    else:
335        result = deepcopy(add[0])
336        add.pop(0)
337
338    for system in add:
339        result.potential_values = np.sum([result.potential_values, system.potential_values], axis=0)
340    for system in subtract:
341        result.potential_values = np.sum([result.potential_values, -system.potential_values], axis=0)
342    if comment != None:
343        result.comment = comment
344    return result
345
346
347def scale(
348        system:System,
349        factor:float,
350        comment:str=None
351        ) -> System:
352    """Returns a copy of `system` with potential values scaled by a `factor`.
353
354    An optional `comment` can be included.
355    """
356    result = deepcopy(system)
357    if factor != 0:
358        result.potential_values = system.potential_values * factor
359    else:
360        result.potential_values = np.zeros(system.gridsize)
361    if comment != None:
362        result.comment = comment
363    return result
364
365
366def interpolate(system:System) -> System:
367    """Interpolates the current `System.potential_values`
368    to a new grid of size `System.gridsize`.
369
370    This basic function is called by `qrotor.solve.potential()`,
371    which is the recommended way to interpolate potentials.
372    """
373    print(f"Interpolating potential to a grid of size {system.gridsize}...")
374    V = system.potential_values
375    grid = system.grid
376    gridsize = system.gridsize
377    new_grid = np.linspace(0, 2*np.pi, gridsize)
378    # Impose periodic boundary conditions
379    grid_periodic = np.append(grid, grid[0] + 2*np.pi)
380    V_periodic = np.append(V, V[0])
381    cubic_spline = CubicSpline(grid_periodic, V_periodic, bc_type='periodic')
382    new_V = cubic_spline(new_grid)
383    system.grid = new_grid
384    system.potential_values = new_V
385    return system
386
387
388def solve(system:System):
389    """Solves `System.potential_values`
390    according to the `System.potential_name`,
391    returning the new `potential_values`.
392    Avaliable potential names are `zero`, `sine` and `titov2023`.
393
394    If `System.potential_name` is not present or not recognised,
395    the current `System.potential_values` are used.
396
397    This basic function is called by `qrotor.solve.potential()`,
398    which is the recommended way to solve potentials.
399    """
400    data = deepcopy(system)
401    # Is there a potential_name?
402    if not data.potential_name:
403        if data.potential_values is None or len(data.potential_values) == 0:
404            raise ValueError(f'No potential_name and no potential_values found in the system!')
405    elif data.potential_name.lower() == 'titov2023':
406        data.potential_values = titov2023(data)
407    elif data.potential_name.lower() in alias.math['0']:
408        data.potential_values = zero(data)
409    elif data.potential_name.lower() in alias.math['sin']:
410        data.potential_values = sine(data)
411    elif data.potential_name.lower() in alias.math['cos']:
412        data.potential_values = cosine(data)
413    # At least there should be potential_values
414    #elif not any(data.potential_values):
415    elif data.potential_values is None or len(data.potential_values) == 0:
416        raise ValueError("Unrecognised potential_name '{data.potential_name}' and no potential_values found")
417    return data.potential_values
418
419
420def zero(system:System):
421    """Zero potential.
422
423    $V(x) = 0$
424    """
425    x = system.grid
426    return 0 * np.array(x)
427
428
429def sine(system:System):
430    """Sine potential.
431
432    $V(x) = C_0 + \\frac{C_1}{2} sin(x C_2 + C_3)$  
433    With $C_0$ as the potential offset,
434    $C_1$ as the max potential value (without considering the offset),
435    $C_2$ as the frequency, and $C_3$ as the phase.
436    If no `System.potential_constants` are provided, defaults to $sin(3x)$  
437    """
438    x = system.grid
439    C = system.potential_constants
440    C0 = 0
441    C1 = 1
442    C2 = 3
443    C3 = 0
444    if C:
445        if len(C) > 0:
446            C0 = C[0]
447        if len(C) > 1:
448            C1 = C[1]
449        if len(C) > 2:
450            C2 = C[2]
451        if len(C) > 3:
452            C3 = C[3]
453    return C0 + (C1 / 2) * np.sin(np.array(x) * C2 + C3)
454
455
456def cosine(system:System):
457    """Cosine potential.
458
459    $V(x) = C_0 + \\frac{C_1}{2} cos(x C_2 + C_3)$  
460    With $C_0$ as the potential offset,
461    $C_1$ as the max potential value (without considering the offset),
462    $C_2$ as the frequency, and $C_3$ as the phase.
463    If no `System.potential_constants` are provided, defaults to $cos(3x)$  
464    """
465    x = system.grid
466    C = system.potential_constants
467    C0 = 0
468    C1 = 1
469    C2 = 3
470    C3 = 0
471    if C:
472        if len(C) > 0:
473            C0 = C[0]
474        if len(C) > 1:
475            C1 = C[1]
476        if len(C) > 2:
477            C2 = C[2]
478        if len(C) > 3:
479            C3 = C[3]
480    return C0 + (C1 / 2) * np.cos(np.array(x) * C2 + C3)
481
482
483def titov2023(system:System):
484    """Potential energy function of the hindered methyl rotor, from
485    [K. Titov et al., Phys. Rev. Mater. 7, 073402 (2023)](https://link.aps.org/doi/10.1103/PhysRevMaterials.7.073402).  
486
487    $V(x) = C_0 + C_1 sin(3x) + C_2 cos(3x) + C_3 sin(6x) + C_4 cos(6x)$  
488    Default constants are `qrotor.constants.constants_titov2023`[0].  
489    """
490    x = system.grid
491    C = system.potential_constants
492    if C is None:
493        C = constants.constants_titov2023[0]
494    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)
def save( system: qrotor.system.System, comment: str = '', filepath: str = 'potential.csv', angle: str = 'deg', energy: str = 'meV') -> None:
 57def 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    An optional `comment` can be included in the header of the file.
 71    """
 72    print('Saving potential data file...')
 73    # Check if a previous potential.dat file exists, and ask to overwrite it
 74    previous_potential_file = file.get(filepath, return_anyway=True)
 75    if previous_potential_file:
 76        print(f"WARNING: Previous '{filepath}' file will be overwritten, proceed anyway?")
 77        answer = input("(y/n): ")
 78        if not answer.lower() in alias.boolean[True]:
 79            print("Aborted.")
 80            return None
 81    # Set header
 82    potential_data = f'## {comment}\n' if comment else f'## {system.comment}\n' if system.comment else ''
 83    potential_data += '# Rotational potential dataset\n'
 84    potential_data += f'# Saved with QRotor {__version__}\n'
 85    potential_data += '# https://pablogila.github.io/qrotor\n'
 86    potential_data += '#\n'
 87    # Check that grid and potential values are the same size
 88    if len(system.grid) != len(system.potential_values):
 89        raise ValueError('len(system.grid) != len(system.potential_values)')
 90    grid = system.grid
 91    potential_values = system.potential_values
 92    # Convert angle units
 93    if angle.lower() in alias.units['rad']:
 94        potential_data += '# Angle/rad,    '
 95    else:
 96        grid = np.degrees(grid)
 97        potential_data += '# Angle/deg,    '
 98        if not angle.lower() in alias.units['deg']:
 99            print(f"WARNING: Unrecognised '{angle}' angle units, using degrees instead")
100    # Convert energy units
101    if energy.lower() in alias.units['meV']:
102        potential_data += 'Potential/meV\n'
103    elif energy.lower() in alias.units['eV']:
104        potential_values = potential_values * 1e-3
105        potential_data += 'Potential/eV\n'
106    elif energy.lower() in alias.units['Ry']:
107        potential_values = potential_values * constants.meV_to_Ry
108        potential_data += 'Potential/Ry\n'
109    else:
110        print(f"WARNING:  Unrecognised '{energy}' energy units, using meV instead")
111        potential_data += 'Potential/meV\n'
112    potential_data += '#\n'
113    # Save all values
114    for angle_value, energy_value in zip(grid, potential_values):
115        potential_data += f'{angle_value},    {energy_value}\n'
116    with open(filepath, 'w') as f:
117        f.write(potential_data)
118    print(f'Saved to {filepath}')
119    # Warn the user if not in default units
120    if angle.lower() not in alias.units['deg']:
121        print(f"WARNING: You saved the potential in '{angle}' angle units! Remember that QRotor works in degrees!")
122    if energy.lower() not in alias.units['meV']:
123        print(f"WARNING: You saved the potential in '{energy}' energy units! Remember that QRotor works in meVs!")

Save the rotational potential from a system to a CSV file.

The output filepath contains angle and energy columns, in degrees and meVs by default. The units can be changed with angle and energy, but only change these defaults if you know what you are doing. An optional comment can be included in the header of the file.

def load( filepath: str = 'potential.csv', comment: str = None, tags: str = '', system: qrotor.system.System = None, angle: str = 'deg', energy: str = 'meV') -> qrotor.system.System:
126def load(
127        filepath:str='potential.csv',
128        comment:str=None,
129        tags:str='',
130        system:System=None,
131        angle:str='deg',
132        energy:str='meV',
133        ) -> System:
134    """Read a rotational potential energy datafile.
135
136    The input file in `filepath` should contain two columns with angle and potential energy values.
137    Degrees and meV are assumed as default units unless stated in `angle` and `energy`.
138    Units will be converted automatically to radians and meV.
139
140    An optional `comment` can be included in the output System.
141    Set to the parent folder name by default.
142
143    A previous System object can be provided through `system` to update its potential values.
144    """
145    file_path = file.get(filepath)
146    system = System() if system is None else system
147    with open(file_path, 'r') as f:
148        lines = f.readlines()
149    # Read the comment
150    loaded_comment = ''
151    if lines[0].startswith('## '):
152        loaded_comment = lines[0][3:].strip()
153    # Read data
154    positions = []
155    potentials = []
156    for line in lines:
157        if line.startswith('#'):
158            continue
159        position, potential = line.split()
160        positions.append(float(position.strip().strip(',').strip()))
161        potentials.append(float(potential.strip()))
162    # Save angles to numpy arrays
163    if angle.lower() in alias.units['deg']:
164        positions = np.radians(positions)
165    elif angle.lower() in alias.units['rad']:
166        positions = np.array(positions)
167    else:
168        raise ValueError(f"Angle unit '{angle}' not recognized.")
169    # Save energies to numpy arrays
170    if energy.lower() in alias.units['eV']:
171        potentials = np.array(potentials) * 1000
172    elif energy.lower() in alias.units['meV']:
173        potentials = np.array(potentials)
174    elif energy.lower() in alias.units['Ry']:
175        potentials = np.array(potentials) * constants.Ry_to_meV
176    else:
177        raise ValueError(f"Energy unit '{energy}' not recognized.")
178    # Set the system
179    system.grid = np.array(positions)
180    system.gridsize = len(positions)
181    system.potential_values = np.array(potentials)
182    # System comment as the loaded comment or the parent folder name
183    if comment:
184        system.comment = comment
185    elif loaded_comment:
186        system.comment = loaded_comment
187    else:
188        system.comment = os.path.basename(os.path.dirname(file_path))
189    if tags:
190        system.tags = tags
191    print(f"Loaded {filepath}")
192    return system

Read a rotational potential energy datafile.

The input file in filepath should contain two columns with angle and potential energy values. Degrees and meV are assumed as default units unless stated in angle and energy. Units will be converted automatically to radians and meV.

An optional comment can be included in the output System. Set to the parent folder name by default.

A previous System object can be provided through system to update its potential values.

def from_qe( folder=None, filepath: str = 'potential.csv', include: list = ['.out'], exclude: list = ['slurm-'], energy: str = 'meV', comment: str = None) -> qrotor.system.System:
195def from_qe(
196        folder=None,
197        filepath:str='potential.csv',
198        include:list=['.out'],
199        exclude:list=['slurm-'],
200        energy:str='meV',
201        comment:str=None,
202        ) -> System:
203    """Compiles a rotational potential CSV file from Quantum ESPRESSO pw.x outputs,
204    created with `qrotor.rotation.rotate_qe()`.
205    Returns a `System` object with the new potential values.
206
207    The angle in degrees is extracted from the output filenames,
208    which must follow `whatever_ANGLE.out`.
209
210    Outputs from SCF calculations must be located in the provided `folder` (CWD if None).
211    Files can be filtered by those containing the specified `include` filters,
212    excluding those containing any string from the `exclude` list. 
213    The output `filepath` name is `'potential.dat'` by default.
214
215    Energy values are saved to meV by dafault, unless specified in `energy`.
216    Only change the energy units if you know what you are doing;
217    remember that default energy units in QRotor are meV!
218    """
219    folder = file.get_dir(folder)
220    # Check if a previous potential.dat file exists, and ask to overwrite it
221    previous_potential_file = file.get(filepath, return_anyway=True)
222    if previous_potential_file:
223        print(f"WARNING: Previous '{filepath}' file will be overwritten, proceed anyway?")
224        answer = input("(y/n): ")
225        if not answer.lower() in alias.boolean[True]:
226            print("Aborted.")
227            return None
228    # Get the files to read
229    files = file.get_list(folder=folder, include=include, exclude=exclude, abspath=True)
230    folder_name = os.path.basename(folder)
231    # Set header
232    potential_data = f'## {comment}\n' if comment else f'## {folder_name}\n'
233    potential_data += '# Rotational potential dataset\n'
234    potential_data += f'# Calculated with QE pw.x using QRotor {__version__}\n'
235    potential_data += '# https://pablogila.github.io/qrotor\n'
236    potential_data += '#\n'
237    if energy.lower() in alias.units['eV']:
238        potential_data += '# Angle/deg,    Potential/eV\n'
239    elif energy.lower() in alias.units['meV']:
240        potential_data += '# Angle/deg,    Potential/meV\n'
241    elif energy.lower() in alias.units['Ry']:
242        potential_data += '# Angle/deg,    Potential/Ry\n'
243    else:
244        potential_data += '# Angle/deg,    Potential/meV\n'
245    potential_data += '#\n'
246    potential_data_list = []
247    print('Extracting the potential as a function of the angle...')
248    print('----------------------------------')
249    counter_success = 0
250    counter_errors = 0
251    for file_path in files:
252        filename = os.path.basename(file_path)
253        file_path = file.get(filepath=file_path, include='.out', return_anyway=True)
254        if not file_path:  # Not an output file, skip it
255            continue
256        content = pwx.read_out(file_path)
257        if not content['Success']:  # Ignore unsuccessful calculations
258            print(f'x   {filename}')
259            counter_errors += 1
260            continue
261        if energy.lower() in alias.units['eV']:
262            energy_value = content['Energy'] * constants.Ry_to_eV
263        elif energy.lower() in alias.units['meV']:
264            energy_value = content['Energy'] * constants.Ry_to_meV
265        elif energy.lower() in alias.units['Ry']:
266            energy_value = content['Energy']
267        else:
268            print(f"WARNING: Energy unit '{energy}' not recognized, using meV instead.")
269            energy = 'meV'
270            energy_value = content['Energy'] * constants.Ry_to_meV
271        splits = filename.split('_')
272        angle_value = splits[-1].replace('.out', '')
273        angle_value = float(angle_value)
274        potential_data_list.append((angle_value, energy_value))
275        print(f'OK  {filename}')
276        counter_success += 1
277    # Sort by angle
278    potential_data_list_sorted = sorted(potential_data_list, key=lambda x: x[0])
279    # Append the sorted values as a string
280    for angle_value, energy_value in potential_data_list_sorted:
281        potential_data += f'{angle_value},    {energy_value}\n'
282    with open(filepath, 'w') as f:
283        f.write(potential_data)
284    print('----------------------------------')
285    print(f'Succesful calculations (OK): {counter_success}')
286    print(f'Faulty calculations     (x): {counter_errors}')
287    print('----------------------------------')
288    print(f'Saved angles and potential values at {filepath}')
289    # Warn the user if not in default units
290    if energy.lower() not in alias.units['meV']:
291        print(f"WARNING: You saved the potential in '{energy}' units! Remember that QRotor works in meVs!")
292    new_system = None
293    try:
294        new_system = load(filepath=filepath, comment=comment, energy=energy)
295    except:
296        pass
297    return new_system

Compiles a rotational potential CSV file from Quantum ESPRESSO pw.x outputs, created with qrotor.rotation.rotate_qe(). Returns a System object with the new potential values.

The angle in degrees is extracted from the output filenames, which must follow whatever_ANGLE.out.

Outputs from SCF calculations must be located in the provided folder (CWD if None). Files can be filtered by those containing the specified include filters, excluding those containing any string from the exclude list. The output filepath name is 'potential.dat' by default.

Energy values are saved to meV by dafault, unless specified in energy. Only change the energy units if you know what you are doing; remember that default energy units in QRotor are meV!

def merge(add=[], subtract=[], comment: str = None) -> qrotor.system.System:
300def merge(
301        add=[],
302        subtract=[],
303        comment:str=None
304        ) -> System:
305    """Add or subtract potentials from different systems.
306
307    Adds the potential values from the systems in `add`,
308    removes the ones from `subtract`.
309    All systems will be interpolated to the bigger gridsize if needed.
310
311    A copy of the first System will be returned with the resulting potential values,
312    with an optional `comment` if indicated.
313    """
314    add = systems.as_list(add)
315    subtract = systems.as_list(subtract)
316    gridsizes = systems.get_gridsizes(add)
317    gridsizes.extend(systems.get_gridsizes(subtract))
318    max_gridsize = max(gridsizes)
319    # All gridsizes should be max_gridsize
320    for s in add:
321        if s.gridsize != max_gridsize:
322            s.gridsize = max_gridsize
323            s = interpolate(s)
324    for s in subtract:
325        if s.gridsize != max_gridsize:
326            s.gridsize = max_gridsize
327            s = interpolate(s)
328
329    if len(add) == 0:
330        if len(subtract) == 0:
331            raise ValueError('No systems were provided!')
332        result = deepcopy(subtract[0])
333        result.potential_values = -result.potential_values
334        subtract.pop(0)
335    else:
336        result = deepcopy(add[0])
337        add.pop(0)
338
339    for system in add:
340        result.potential_values = np.sum([result.potential_values, system.potential_values], axis=0)
341    for system in subtract:
342        result.potential_values = np.sum([result.potential_values, -system.potential_values], axis=0)
343    if comment != None:
344        result.comment = comment
345    return result

Add or subtract potentials from different systems.

Adds the potential values from the systems in add, removes the ones from subtract. All systems will be interpolated to the bigger gridsize if needed.

A copy of the first System will be returned with the resulting potential values, with an optional comment if indicated.

def scale( system: qrotor.system.System, factor: float, comment: str = None) -> qrotor.system.System:
348def scale(
349        system:System,
350        factor:float,
351        comment:str=None
352        ) -> System:
353    """Returns a copy of `system` with potential values scaled by a `factor`.
354
355    An optional `comment` can be included.
356    """
357    result = deepcopy(system)
358    if factor != 0:
359        result.potential_values = system.potential_values * factor
360    else:
361        result.potential_values = np.zeros(system.gridsize)
362    if comment != None:
363        result.comment = comment
364    return result

Returns a copy of system with potential values scaled by a factor.

An optional comment can be included.

def interpolate(system: qrotor.system.System) -> qrotor.system.System:
367def interpolate(system:System) -> System:
368    """Interpolates the current `System.potential_values`
369    to a new grid of size `System.gridsize`.
370
371    This basic function is called by `qrotor.solve.potential()`,
372    which is the recommended way to interpolate potentials.
373    """
374    print(f"Interpolating potential to a grid of size {system.gridsize}...")
375    V = system.potential_values
376    grid = system.grid
377    gridsize = system.gridsize
378    new_grid = np.linspace(0, 2*np.pi, gridsize)
379    # Impose periodic boundary conditions
380    grid_periodic = np.append(grid, grid[0] + 2*np.pi)
381    V_periodic = np.append(V, V[0])
382    cubic_spline = CubicSpline(grid_periodic, V_periodic, bc_type='periodic')
383    new_V = cubic_spline(new_grid)
384    system.grid = new_grid
385    system.potential_values = new_V
386    return system

Interpolates the current System.potential_values to a new grid of size System.gridsize.

This basic function is called by qrotor.solve.potential(), which is the recommended way to interpolate potentials.

def solve(system: qrotor.system.System):
389def solve(system:System):
390    """Solves `System.potential_values`
391    according to the `System.potential_name`,
392    returning the new `potential_values`.
393    Avaliable potential names are `zero`, `sine` and `titov2023`.
394
395    If `System.potential_name` is not present or not recognised,
396    the current `System.potential_values` are used.
397
398    This basic function is called by `qrotor.solve.potential()`,
399    which is the recommended way to solve potentials.
400    """
401    data = deepcopy(system)
402    # Is there a potential_name?
403    if not data.potential_name:
404        if data.potential_values is None or len(data.potential_values) == 0:
405            raise ValueError(f'No potential_name and no potential_values found in the system!')
406    elif data.potential_name.lower() == 'titov2023':
407        data.potential_values = titov2023(data)
408    elif data.potential_name.lower() in alias.math['0']:
409        data.potential_values = zero(data)
410    elif data.potential_name.lower() in alias.math['sin']:
411        data.potential_values = sine(data)
412    elif data.potential_name.lower() in alias.math['cos']:
413        data.potential_values = cosine(data)
414    # At least there should be potential_values
415    #elif not any(data.potential_values):
416    elif data.potential_values is None or len(data.potential_values) == 0:
417        raise ValueError("Unrecognised potential_name '{data.potential_name}' and no potential_values found")
418    return data.potential_values

Solves System.potential_values according to the System.potential_name, returning the new potential_values. Avaliable potential names are zero, sine and titov2023.

If System.potential_name is not present or not recognised, the current System.potential_values are used.

This basic function is called by qrotor.solve.potential(), which is the recommended way to solve potentials.

def zero(system: qrotor.system.System):
421def zero(system:System):
422    """Zero potential.
423
424    $V(x) = 0$
425    """
426    x = system.grid
427    return 0 * np.array(x)

Zero potential.

$V(x) = 0$

def sine(system: qrotor.system.System):
430def sine(system:System):
431    """Sine potential.
432
433    $V(x) = C_0 + \\frac{C_1}{2} sin(x C_2 + C_3)$  
434    With $C_0$ as the potential offset,
435    $C_1$ as the max potential value (without considering the offset),
436    $C_2$ as the frequency, and $C_3$ as the phase.
437    If no `System.potential_constants` are provided, defaults to $sin(3x)$  
438    """
439    x = system.grid
440    C = system.potential_constants
441    C0 = 0
442    C1 = 1
443    C2 = 3
444    C3 = 0
445    if C:
446        if len(C) > 0:
447            C0 = C[0]
448        if len(C) > 1:
449            C1 = C[1]
450        if len(C) > 2:
451            C2 = C[2]
452        if len(C) > 3:
453            C3 = C[3]
454    return C0 + (C1 / 2) * np.sin(np.array(x) * C2 + C3)

Sine potential.

$V(x) = C_0 + \frac{C_1}{2} sin(x C_2 + C_3)$
With $C_0$ as the potential offset, $C_1$ as the max potential value (without considering the offset), $C_2$ as the frequency, and $C_3$ as the phase. If no System.potential_constants are provided, defaults to $sin(3x)$

def cosine(system: qrotor.system.System):
457def cosine(system:System):
458    """Cosine potential.
459
460    $V(x) = C_0 + \\frac{C_1}{2} cos(x C_2 + C_3)$  
461    With $C_0$ as the potential offset,
462    $C_1$ as the max potential value (without considering the offset),
463    $C_2$ as the frequency, and $C_3$ as the phase.
464    If no `System.potential_constants` are provided, defaults to $cos(3x)$  
465    """
466    x = system.grid
467    C = system.potential_constants
468    C0 = 0
469    C1 = 1
470    C2 = 3
471    C3 = 0
472    if C:
473        if len(C) > 0:
474            C0 = C[0]
475        if len(C) > 1:
476            C1 = C[1]
477        if len(C) > 2:
478            C2 = C[2]
479        if len(C) > 3:
480            C3 = C[3]
481    return C0 + (C1 / 2) * np.cos(np.array(x) * C2 + C3)

Cosine potential.

$V(x) = C_0 + \frac{C_1}{2} cos(x C_2 + C_3)$
With $C_0$ as the potential offset, $C_1$ as the max potential value (without considering the offset), $C_2$ as the frequency, and $C_3$ as the phase. If no System.potential_constants are provided, defaults to $cos(3x)$

def titov2023(system: qrotor.system.System):
484def titov2023(system:System):
485    """Potential energy function of the hindered methyl rotor, from
486    [K. Titov et al., Phys. Rev. Mater. 7, 073402 (2023)](https://link.aps.org/doi/10.1103/PhysRevMaterials.7.073402).  
487
488    $V(x) = C_0 + C_1 sin(3x) + C_2 cos(3x) + C_3 sin(6x) + C_4 cos(6x)$  
489    Default constants are `qrotor.constants.constants_titov2023`[0].  
490    """
491    x = system.grid
492    C = system.potential_constants
493    if C is None:
494        C = constants.constants_titov2023[0]
495    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)

Potential energy function of the hindered methyl rotor, from K. Titov et al., Phys. Rev. Mater. 7, 073402 (2023).

$V(x) = C_0 + C_1 sin(3x) + C_2 cos(3x) + C_3 sin(6x) + C_4 cos(6x)$
Default constants are qrotor.constants.constants_titov2023[0].