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/solve.py ADDED
@@ -0,0 +1,271 @@
1
+ """
2
+ # Description
3
+
4
+ This module is used to solve any given quantum system.
5
+
6
+ Although the functions of this module can be used independently,
7
+ it is highly recommended to use the methods `System.solve()` or
8
+ `System.solve_potential()` instead to solve the whole quantum system
9
+ or just the potential values.
10
+ These user methods perform all calculations automatically,
11
+ see `qrotor.system.System.solve()` and
12
+ `qrotor.system.System.solve_potential()` respectively for more details.
13
+
14
+ This documentation page is left for reference and advanced users only.
15
+
16
+
17
+ # Index
18
+
19
+ | | |
20
+ | --- | --- |
21
+ | `energies()` | Solve the quantum system, including eigenvalues and eigenvectors |
22
+ | `potential()` | Solve the potential values of the system |
23
+ | `schrodinger()` | Solve the Schrödiger equation for the system |
24
+ | `hamiltonian_matrix()` | Calculate the hamiltonian matrix of the system |
25
+ | `laplacian_matrix()` | Calculate the second derivative matrix for a given grid |
26
+ | `excitations()` | Get excitation levels and tunnel splitting energies |
27
+ | `E_levels` | Group a list of degenerated eigenvalues by energy levels |
28
+
29
+ ---
30
+ """
31
+
32
+
33
+ from .system import System
34
+ from .potential import solve as solve_potential
35
+ from .potential import interpolate
36
+ import time
37
+ import numpy as np
38
+ from scipy import sparse
39
+ import aton
40
+ from ._version import __version__
41
+
42
+
43
+ def energies(system:System, filename:str=None) -> System:
44
+ """Solves the quantum `system`.
45
+
46
+ This includes solving the potential, the eigenvalues and the eigenvectors.
47
+
48
+ The resulting System object is saved with pickle to `filename` if specified.
49
+ """
50
+ system = potential(system)
51
+ system = schrodinger(system)
52
+ if filename:
53
+ aton.st.file.save(system, filename)
54
+ return system
55
+
56
+
57
+ def potential(system:System, gridsize:int=None) -> System:
58
+ """Solves the potential values of the `system`.
59
+
60
+ Creates a grid if not yet present.
61
+ It also interpolates the potential if `system.gridsize` is larger than the current grid;
62
+ optionally, an alternative `gridsize` can be specified.
63
+
64
+ It then solves the potential according to the potential name.
65
+ Then it applies extra operations, such as removing the potential offset
66
+ if `system.correct_potential_offset = True`.
67
+ """
68
+ if gridsize:
69
+ system.gridsize = gridsize
70
+ if not any(system.grid):
71
+ system.set_grid()
72
+ if system.gridsize and any(system.grid):
73
+ if system.gridsize > len(system.grid):
74
+ system = interpolate(system)
75
+ V = solve_potential(system)
76
+ if system.correct_potential_offset is True:
77
+ offset = min(V)
78
+ V = V - offset
79
+ system.potential_offset = offset
80
+ system.potential_max = max(V)
81
+ system.potential_min = min(V)
82
+ system.potential_values = V
83
+ return system
84
+
85
+
86
+ def schrodinger(system:System) -> System:
87
+ """Solves the Schrödinger equation for a given `system`.
88
+
89
+ Uses ARPACK in shift-inverse mode to solve the hamiltonian sparse matrix.
90
+ """
91
+ time_start = time.time()
92
+ V = system.potential_values
93
+ H = hamiltonian_matrix(system)
94
+ print('Solving Schrodinger equation...')
95
+ # Solve eigenvalues with ARPACK in shift-inverse mode, with a sparse matrix
96
+ eigenvalues, eigenvectors = sparse.linalg.eigsh(H, system.searched_E, which='LM', sigma=0, maxiter=10000)
97
+ if any(eigenvalues) is None:
98
+ print('WARNING: Not all eigenvalues were found.\n')
99
+ else: print('Done.')
100
+ system.version = __version__
101
+ system.runtime = time.time() - time_start
102
+ system.eigenvalues = eigenvalues
103
+ system.energy_barrier = max(V) - min(eigenvalues)
104
+ # Solve excitations and tunnel splittings, assuming triplet degeneracy
105
+ system = excitations(system)
106
+ # Do we really need to save eigenvectors?
107
+ if system.save_eigenvectors == True:
108
+ system.eigenvectors = np.transpose(eigenvectors)
109
+ # Save potential max and min, in case these are not already saved
110
+ system.potential_max = max(V)
111
+ system.potential_min = min(V)
112
+ return system
113
+
114
+
115
+ def hamiltonian_matrix(system:System):
116
+ """Calculates the Hamiltonian sparse matrix for a given `system`."""
117
+ print(f'Creating Hamiltonian sparse matrix of size {system.gridsize}...')
118
+ V = system.potential_values.tolist()
119
+ potential = sparse.diags(V, format='lil')
120
+ B = system.B
121
+ x = system.grid
122
+ H = -B * laplacian_matrix(x) + potential
123
+ return H
124
+
125
+
126
+ def laplacian_matrix(grid):
127
+ """Calculates the Laplacian (second derivative) matrix for a given `grid`."""
128
+ x = grid
129
+ n = len(x)
130
+ diagonals = [-2*np.ones(n), np.ones(n), np.ones(n)]
131
+ laplacian_matrix = sparse.spdiags(diagonals, [0, -1, 1], m=n, n=n, format='lil')
132
+ # Periodic boundary conditions
133
+ laplacian_matrix[0, -1] = 1
134
+ laplacian_matrix[-1, 0] = 1
135
+ dx = x[1] - x[0]
136
+ laplacian_matrix /= dx**2
137
+ return laplacian_matrix
138
+
139
+
140
+ def excitations(system: System) -> System:
141
+ """Calculate the excitation levels and the tunnel splitting energies of a system.
142
+
143
+ Automatically detects degenerated energy levels by looking at significant jumps
144
+ between consecutive eigenvalues. Within each level, finds two subgroups
145
+ to calculate tunnel splittings. Stops when energies reach the maximum potential.
146
+
147
+ Excitations are calculated as the energy difference between the mean energy of the
148
+ ground state level and the mean energy of each excited level.
149
+
150
+ Tunnel splittings are calculated as the difference between the mean values of
151
+ the two subgroups within each degenerate level.
152
+ """
153
+ # Get eigenvalues, stop before any possible None value
154
+ eigenvalues = system.eigenvalues
155
+ if not isinstance(eigenvalues, (list, np.ndarray)) or len(eigenvalues) == 0:
156
+ return system
157
+ if None in eigenvalues:
158
+ none_index = eigenvalues.tolist().index(None)
159
+ eigenvalues = eigenvalues[:none_index]
160
+ if len(eigenvalues) < 3:
161
+ return system
162
+ # Group degenerated eigenvalues into energy levels
163
+ levels, degeneracy = E_levels(eigenvalues, system.potential_max)
164
+ system.E_levels = levels
165
+ system.deg = degeneracy
166
+ # Calculate excitations and splittings
167
+ ground_energy = np.mean(levels[0]) # Mean of ground state level
168
+ excitations = []
169
+ tunnel_splittings = []
170
+ for level in levels:
171
+ level_mean = np.mean(level)
172
+ excitations.append(level_mean - ground_energy)
173
+ # Get the tunnel splitting within the level
174
+ if len(level) > 1:
175
+ # Find the largest gap within the level to split into two subgroups
176
+ internal_gaps = np.diff(level)
177
+ split_idx = np.argmax(internal_gaps) + 1
178
+ # Split into two subgroups
179
+ subgroup1 = level[:split_idx]
180
+ subgroup2 = level[split_idx:]
181
+ # Medians of subgroups
182
+ median1 = np.median(subgroup1)
183
+ median2 = np.median(subgroup2)
184
+ # Tunnel splitting is the difference between medians
185
+ tunnel_splittings.append(abs(median2 - median1))
186
+ else:
187
+ tunnel_splittings.append(0)
188
+ system.excitations = excitations[1:] # Exclude ground state
189
+ system.splittings = tunnel_splittings
190
+ return system
191
+
192
+
193
+ def E_levels(eigenvalues, vmax:float=None) -> list:
194
+ """Group a list of degenerated eigenvalues by energy levels.
195
+
196
+ Automatically detects degenerated energy levels by
197
+ looking at significant jumps between consecutive eigenvalues.
198
+
199
+ An optional `vmax` can be specified,
200
+ to avoid including too many eigenvalues
201
+ above a certain potential maximum.
202
+ Only two more eigenvalues are considered after `vmax`,
203
+ to properly detect energy levels around the maximum.
204
+
205
+ Example:
206
+ ```python
207
+ levels, deg = qr.solve.E_levels(array([1.1, 1.2, 1.3, 5.4, 5.5, 5.6]))
208
+ levels # [array([1.1, 1.2, 1.3]), array([5.4, 5.5, 5.6])]
209
+ deg # 3
210
+ ```
211
+ """
212
+ if vmax: # Include all eigenvalues below Vmax plus 3 more eigenvalues
213
+ # Check if any values are above vmax
214
+ eigenvalues_above_vmax = eigenvalues > vmax
215
+ if np.any(eigenvalues_above_vmax):
216
+ index_first_above_vmax = np.where(eigenvalues_above_vmax)[0][0]
217
+ eigenvalues = eigenvalues[:(index_first_above_vmax + 2)]
218
+ # Group degenerated eigenvalues into energy levels
219
+ for scale in np.arange(2, 4, 0.25): # First search going to bigger scales
220
+ levels, degeneracy = _get_E_levels_by_gap(eigenvalues, scale)
221
+ if (degeneracy > 1) and (degeneracy % 1 == 0):
222
+ break
223
+ else:
224
+ levels, degeneracy = None, None
225
+ if not degeneracy: # If it didn't work, search with tighter values
226
+ for scale in np.arange(0.75, 2, 0.25):
227
+ levels, degeneracy = _get_E_levels_by_gap(eigenvalues, scale)
228
+ if (degeneracy > 1) and (degeneracy % 1 == 0):
229
+ break
230
+ if not (degeneracy > 1) and not (degeneracy % 1) == 0:
231
+ return levels, degeneracy # I give up
232
+ # Correct the last two levels
233
+ if len(levels) >= 2 and len(levels[-2]) != degeneracy:
234
+ levels[-2] = np.concatenate((levels[-2], levels[-1]))
235
+ levels.pop(-1)
236
+ # Split last level into groups of size = degeneracy
237
+ last_level = levels[-1]
238
+ additional_levels = len(last_level) // degeneracy
239
+ if additional_levels > 0:
240
+ # Replace last level with list of complete degeneracy groups
241
+ complete_groups = [last_level[i:i+degeneracy] for i in range(0, additional_levels*degeneracy, degeneracy)]
242
+ levels.pop(-1) # Remove original last level
243
+ levels.extend(complete_groups) # Add all complete groups
244
+ else:
245
+ levels.pop(-1) # Remove incomplete last level
246
+ return levels, degeneracy
247
+
248
+
249
+ def _get_E_levels_by_gap(eigenvalues, scale:float=2) -> tuple:
250
+ """Split a list of eigenvalues into energy levels by looking at gaps.
251
+
252
+ If the gap is bigger than the average gap times `scale`, it is considered a new level.
253
+
254
+ Returns a tuple with the estimated levels and the average degeneracy.
255
+ The last two levels are not taken into account to estimate the degeneracy.
256
+ """
257
+ # Find gaps between consecutive eigenvalues
258
+ gaps = np.diff(eigenvalues)
259
+ # Use mean gap times scale as threshold to distinguish energy levels
260
+ med_gap = np.mean(gaps)
261
+ level_breaks = np.where(gaps > scale * med_gap)[0] + 1
262
+ levels = np.split(eigenvalues, level_breaks)
263
+ # Calculate average degeneracy excluding last two levels if possible
264
+ if len(levels) > 2:
265
+ avg_degeneracy = float(np.mean([len(level) for level in levels[:-2]]))
266
+ else:
267
+ avg_degeneracy = float(len(levels[0]))
268
+ if avg_degeneracy % 1 == 0:
269
+ avg_degeneracy = int(avg_degeneracy)
270
+ return levels, avg_degeneracy
271
+
qrotor/system.py ADDED
@@ -0,0 +1,275 @@
1
+ """
2
+ # Description
3
+
4
+ The `System` object contains all the information needed for a single QRotor calculation.
5
+ This class can be loaded directly as `qrotor.System()`.
6
+
7
+ ---
8
+ """
9
+
10
+
11
+ import numpy as np
12
+ from .constants import *
13
+ from aton import alias
14
+ from ._version import __version__
15
+
16
+
17
+ class System:
18
+ """Quantum system.
19
+
20
+ Contains all the data for a single QRotor calculation, with both inputs and outputs.
21
+
22
+ Energy units are in meV and angles are in radians, unless stated otherwise.
23
+ """
24
+ def __init__(
25
+ self,
26
+ comment: str = None,
27
+ searched_E: int = 21,
28
+ correct_potential_offset: bool = True,
29
+ save_eigenvectors: bool = True,
30
+ group: str = '',
31
+ B: float = B_CH3,
32
+ gridsize: int = 200000,
33
+ grid = [],
34
+ potential_name: str = '',
35
+ potential_constants: list = None,
36
+ potential_values = [],
37
+ ):
38
+ """A new quantum system can be instantiated as `system = qrotor.System()`.
39
+ This new system will contain the default values listed above.
40
+ """
41
+ ## Technical
42
+ self.version = __version__
43
+ """Version of the package used to generate the data."""
44
+ self.comment: str = comment
45
+ """Custom comment for the dataset."""
46
+ self.searched_E: int = searched_E
47
+ """Number of energy eigenvalues to be searched."""
48
+ self.correct_potential_offset: bool = correct_potential_offset
49
+ """Correct the potential offset as `V - min(V)` or not."""
50
+ self.save_eigenvectors: bool = save_eigenvectors
51
+ """Save or not the eigenvectors. Final file size will be bigger."""
52
+ self.group: str = group
53
+ """Chemical group, methyl or amine: `'CH3'`, `'CD3'`, `'NH3'`, `'ND3'`.
54
+
55
+ Can be used to set the value of `B` automatically at startup.
56
+ It can also be configured afterwards with `System.set_group()`.
57
+ This group can be used as metadata to analyse different datasets.
58
+ """
59
+ self.set_group(group) # Normalise the group name, and set the value of B
60
+ ## Potential
61
+ if not B:
62
+ B = self.B
63
+ self.B: float = B
64
+ """Rotational inertia, as in $B=\\frac{\\hbar^2}{2I}$.
65
+
66
+ Defaults to the value for a methyl group.
67
+ """
68
+ self.gridsize: int = gridsize
69
+ """Number of points in the grid."""
70
+ self.grid = grid
71
+ """The grid with the points to be used in the calculation.
72
+
73
+ Can be set automatically over $2 \\pi$ with `System.set_grid()`.
74
+ Units must be in radians.
75
+ """
76
+ self.potential_name: str = potential_name
77
+ """Name of the desired potential: `'zero'`, `'titov2023'`, `'test'`...
78
+
79
+ If empty or unrecognised, the custom potential values inside `System.potential_values` will be used.
80
+ """
81
+ self.potential_constants: list = potential_constants
82
+ """List of constants to be used in the calculation of the potential energy, in the `qrotor.potential` module."""
83
+ self.potential_values = potential_values
84
+ """Numpy [ndarray](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html) with the potential values for each point in the grid.
85
+
86
+ Can be calculated with a function available in the `qrotor.potential` module,
87
+ or loaded externally with the `qrotor.potential.load()` function.
88
+ Potential energy units must be in meV.
89
+ """
90
+ # Potential values determined upon solving
91
+ self.potential_offset: float = None
92
+ """`min(V)` before offset correction when `correct_potential_offset = True`"""
93
+ self.potential_min: float = None
94
+ """`min(V)`"""
95
+ self.potential_max: float = None
96
+ """`max(V)`"""
97
+ # Energies determined upon solving
98
+ self.eigenvectors = []
99
+ """Eigenvectors, if `save_eigenvectors` is True. Beware of the file size."""
100
+ self.eigenvalues = []
101
+ """Calculated eigenvalues of the system. In meV."""
102
+ self.E_levels: list = []
103
+ """List of `eigenvalues` grouped by energy levels, found below `potential_max`."""
104
+ self.deg: float = None
105
+ """Estimated degeneracy of the `E_levels` found below `potential_max`."""
106
+ self.excitations: list = []
107
+ """Torsional excitations, as the difference between each energy level with respect to the ground state.
108
+
109
+ Considers the means between degenerated eigenvalues for all energy levels below `potential_max`.
110
+ """
111
+ self.splittings: list = []
112
+ """Tunnel splitting energies, for every degenerated energy level.
113
+
114
+ Calculated for all `E_levels` as the difference between
115
+ the mean of the eigenvalues from A and the mean of the eigenvalues from E,
116
+ see [R. M. Dimeo, American Journal of Physics 71, 885–893 (2003)](https://doi.org/10.1119/1.1538575).
117
+ """
118
+ self.energy_barrier: float = None
119
+ """Activation energy or energy barrier, from the ground torsional state to the top of the potential barrier, `max(V) - min(eigenvalues)`"""
120
+ self.runtime: float = None
121
+ """Time taken to solve the eigenvalues."""
122
+
123
+ def solve(self, new_gridsize:int=None):
124
+ """Default user method to solve the quantum system.
125
+
126
+ The potential can be interpolated to a `new_gridsize`.
127
+
128
+ Same as running `qrotor.solve.energies(System)`
129
+ with an optional new gridsize.
130
+ """
131
+ from .solve import energies
132
+ if new_gridsize:
133
+ self.gridsize = new_gridsize
134
+ return energies(self)
135
+
136
+ def solve_potential(self, new_gridsize:int=None):
137
+ """Default user method to quickly solve the potential of the quantum system.
138
+
139
+ This method does not solve the energies of the system,
140
+ it just computes the potential and sets `System.potential_max`,
141
+ `System.potential_min` and `System.potential_offset` accordingly.
142
+ To solve the potential AND the energies, check `System.solve()`.
143
+
144
+ The potential can be interpolated to a `new_gridsize`.
145
+
146
+ Same as running `qrotor.solve.potential(System)`
147
+ with an optional new gridsize.
148
+ """
149
+ from .solve import potential
150
+ if new_gridsize:
151
+ self.gridsize = new_gridsize
152
+ return potential(self)
153
+
154
+ def change_phase(self, phase:float, calculate:bool=True):
155
+ """Apply a phase shift to the grid and potential values.
156
+
157
+ The `phase` should be a multiple of $\\pi$ (e.g., 3/2 for $3\\pi/2$).
158
+ The resulting grid will be expressed between $-2\\pi$ and $2\\pi$.
159
+
160
+ The System is solved immediately after the phase change.
161
+ This last step ensures that all eigenvalues and wavefunctions are correct.
162
+ You can override this step with `calculate = False`,
163
+ but remember to solve the System later!
164
+ """
165
+ if not any(self.potential_values) or not any(self.grid):
166
+ raise ValueError("System.potential_values and System.grid must be set before applying a phase shift.")
167
+ # Normalise the phase between 0 and 2
168
+ if abs(phase) >= 2:
169
+ phase = phase % 2
170
+ while phase < 0:
171
+ phase = phase + 2
172
+ # Shift the grid, between -2pi and 2pi
173
+ self.grid = (self.grid + (phase * np.pi))
174
+ # Apply the phase shift to potential values
175
+ phase_points = int((phase / 2) * self.gridsize)
176
+ self.potential_values = np.roll(self.potential_values, phase_points)
177
+ # Check that the grid is still within -2pi and 2pi, otherwise normalise it for a final time
178
+ while self.grid[0] <= (-2 * np.pi + 0.1): # With a small tolerance
179
+ self.grid = self.grid + 2 * np.pi
180
+ while self.grid[-1] >= 2.5 * np.pi: # It was not a problem until reaching 5/2 pi
181
+ self.grid = self.grid -2 * np.pi
182
+ print(f'Potential shifted by {phase}π')
183
+ if calculate:
184
+ self.solve()
185
+ return self
186
+
187
+ def set_grid(self, gridsize:int=None):
188
+ """Sets the `System.grid` to the specified `gridsize` from 0 to $2\\pi$.
189
+
190
+ If the system had a previous grid and potential values,
191
+ it will interpolate those values to the new gridsize,
192
+ using `qrotor.potential.interpolate()`.
193
+ """
194
+ if gridsize == self.gridsize:
195
+ return self # Nothing to do here
196
+ if gridsize:
197
+ self.gridsize = gridsize
198
+ # Should we interpolate?
199
+ if any(self.potential_values) and any(self.grid) and self.gridsize:
200
+ from .potential import interpolate
201
+ self = interpolate(self)
202
+ # Should we create the values from zero?
203
+ elif self.gridsize:
204
+ self.grid = np.linspace(0, 2*np.pi, self.gridsize)
205
+ else:
206
+ raise ValueError('gridsize must be provided if there is no System.gridsize')
207
+ return self
208
+
209
+ def set_group(self, group:str=None, B:float=None):
210
+ """Normalise `System.group` name, and set `System.B` based on it."""
211
+ for name in alias.chemical['CH3']:
212
+ if group.lower() == name:
213
+ self.group = 'CH3'
214
+ if not B:
215
+ B = B_CH3
216
+ self.B = B
217
+ return self
218
+ for name in alias.chemical['CD3']:
219
+ if group.lower() == name:
220
+ self.group = 'CD3'
221
+ if not B:
222
+ B = B_CD3
223
+ self.B = B
224
+ return self
225
+ for name in alias.chemical['NH3']:
226
+ if group.lower() == name:
227
+ self.group = 'NH3'
228
+ if not B:
229
+ B = B_NH3
230
+ self.B = B
231
+ return self
232
+ for name in alias.chemical['ND3']:
233
+ if group.lower() == name:
234
+ self.group = 'ND3'
235
+ if not B:
236
+ B = B_ND3
237
+ self.B = B
238
+ return self
239
+ self.group = group # No match was found
240
+ self.B = None
241
+ return self
242
+
243
+ def reduce_size(self):
244
+ """Discard data that takes too much space,
245
+ like eigenvectors, potential values and grids."""
246
+ self.eigenvectors = []
247
+ self.potential_values = []
248
+ self.grid = []
249
+ return self
250
+
251
+ def summary(self):
252
+ """Returns a dict with a summary of the System data."""
253
+ return {
254
+ 'version': self.version,
255
+ 'comment': self.comment,
256
+ 'searched_E': self.searched_E,
257
+ 'correct_potential_offset': self.correct_potential_offset,
258
+ 'save_eigenvectors': self.save_eigenvectors,
259
+ 'group': self.group,
260
+ 'B': self.B,
261
+ 'gridsize': self.gridsize,
262
+ 'potential_name': self.potential_name,
263
+ 'potential_constants': self.potential_constants.tolist() if isinstance(self.potential_constants, np.ndarray) else self.potential_constants,
264
+ 'potential_offset': self.potential_offset,
265
+ 'potential_min': self.potential_min,
266
+ 'potential_max': self.potential_max,
267
+ 'eigenvalues': self.eigenvalues.tolist() if isinstance(self.eigenvalues, np.ndarray) else self.eigenvalues,
268
+ 'E_levels': self.E_levels,
269
+ 'deg': self.deg,
270
+ 'excitations': self.excitations,
271
+ 'splittings': self.splittings,
272
+ 'energy_barrier': self.energy_barrier,
273
+ 'runtime': self.runtime,
274
+ }
275
+