-
Notifications
You must be signed in to change notification settings - Fork 0
/
utils.py
345 lines (281 loc) · 13.1 KB
/
utils.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# this defines a few utility functions - hopefully we will soon be able to get rid of these and use better-defined utility functions
from scipy.spatial.transform import Rotation
from sympy.physics.wigner import wigner_d
import wigners
import numpy as np
def wigner_d_matrix(l, alpha, beta, gamma):
"""Computes a Wigner D matrix
D^l_{mm'}(alpha, beta, gamma)
from sympy and converts it to numerical values.
(alpha, beta, gamma) are Euler angles (radians, ZYZ convention) and l the irrep.
"""
return np.complex128(wigner_d(l, alpha, beta, gamma))
def rotation_matrix(alpha, beta, gamma):
"""A Cartesian rotation matrix in the appropriate convention
(ZYZ, implicit rotations) to be consistent with the common Wigner D definition.
(alpha, beta, gamma) are Euler angles (radians)."""
return Rotation.from_euler("ZYZ", [alpha, beta, gamma]).as_matrix()
def wigner_d_real(l, alpha, beta, gamma):
"""Computes a real-valued Wigner D matrix
D^l_{mm'}(alpha, beta, gamma)
(alpha, beta, gamma) are Euler angles (radians, ZYZ convention) and l the irrep.
Rotates real spherical harmonics by application from the left.
"""
wd = np.complex128(wigner_d(l, alpha, beta, gamma))
r2c = _real2complex(l)
return np.real(np.conjugate(r2c.T@wd)@r2c)
def xyz_to_spherical(data, axes=()):
"""
Converts a vector (or a list of outer products of vectors) from
Cartesian to l=1 spherical form. Given the definition of real
spherical harmonics, this is just mapping (y, z, x) -> (-1,0,1)
Automatically detects which directions should be converted
data: array
An array containing the data that must be converted
axes: array_like
A list of the dimensions that should be converted. If
empty, selects all dimensions with size 3. For instance,
a list of polarizabilities (ntrain, 3, 3) will convert
dimensions 1 and 2.
Returns:
The array in spherical (l=1) form
"""
shape = data.shape
rdata = data
# automatically detect the xyz dimensions
if len(axes) == 0:
axes = np.where(np.asarray(shape) == 3)[0]
return np.roll(data, -1, axis=axes)
def spherical_to_xyz(data, axes=()):
"""
The inverse operation of xyz_to_spherical. Arguments have the
same meaning, only it goes from l=1 to (x,y,z).
"""
shape = data.shape
rdata = data
# automatically detect the l=1 dimensions
if len(axes) == 0:
axes = np.where(np.asarray(shape) == 3)[0]
return np.roll(data, 1, axis=axes)
class ClebschGordanReal:
def __init__(self, l_max):
self._l_max = l_max
self._cg = {}
# real-to-complex and complex-to-real transformations as matrices
r2c = {}
c2r = {}
for L in range(0, self._l_max + 1):
r2c[L] = _real2complex(L)
c2r[L] = np.conjugate(r2c[L]).T
for l1 in range(self._l_max + 1):
for l2 in range(self._l_max + 1):
for L in range(
max(l1, l2) - min(l1, l2), min(self._l_max, (l1 + l2)) + 1
):
complex_cg = _complex_clebsch_gordan_matrix(l1, l2, L)
real_cg = (r2c[l1].T @ complex_cg.reshape(2 * l1 + 1, -1)).reshape(
complex_cg.shape
)
real_cg = real_cg.swapaxes(0, 1)
real_cg = (r2c[l2].T @ real_cg.reshape(2 * l2 + 1, -1)).reshape(
real_cg.shape
)
real_cg = real_cg.swapaxes(0, 1)
real_cg = real_cg @ c2r[L].T
if (l1 + l2 + L) % 2 == 0:
rcg = np.real(real_cg)
else:
rcg = np.imag(real_cg)
new_cg = []
for M in range(2 * L + 1):
cg_nonzero = np.where(np.abs(rcg[:,:,M])>1e-15)
cg_M = np.zeros( len(cg_nonzero[0]),
dtype=[('m1','>i4'), ('m2','>i4'), ( 'cg', '>f8')] )
cg_M["m1"] = cg_nonzero[0]
cg_M["m2"] = cg_nonzero[1]
cg_M["cg"] = rcg[cg_nonzero[0], cg_nonzero[1], M]
new_cg.append(cg_M)
self._cg[(l1, l2, L)] = new_cg
def combine(self, rho1, rho2, L):
# automatically infer l1 and l2 from the size of the coefficients vectors
l1 = (rho1.shape[1] - 1) // 2
l2 = (rho2.shape[1] - 1) // 2
if L > self._l_max or l1 > self._l_max or l2 > self._l_max:
raise ValueError("Requested CG entry has not been precomputed")
n_items = rho1.shape[0]
n_features = rho1.shape[2]
if rho1.shape[0] != rho2.shape[0] or rho1.shape[2] != rho2.shape[2]:
raise IndexError("Cannot combine differently-shaped feature blocks")
rho = np.zeros((n_items, 2 * L + 1, n_features))
if (l1, l2, L) in self._cg:
for M in range(2 * L + 1):
for m1, m2, cg in self._cg[(l1, l2, L)][M]:
rho[:, M] += rho1[:, m1, :] * rho2[:, m2, :] * cg
return rho
def combine_einsum(self, rho1, rho2, L, combination_string):
# automatically infer l1 and l2 from the size of the coefficients vectors
l1 = (rho1.shape[1] - 1) // 2
l2 = (rho2.shape[1] - 1) // 2
if L > self._l_max or l1 > self._l_max or l2 > self._l_max:
raise ValueError("Requested CG entry ", (l1, l2, L), " has not been precomputed")
n_items = rho1.shape[0]
if rho1.shape[0] != rho2.shape[0]:
raise IndexError(
"Cannot combine feature blocks with different number of items"
)
# infers the shape of the output using the einsum internals
features = np.einsum(combination_string, rho1[:, 0, ...], rho2[:, 0, ...]).shape
rho = np.zeros((n_items, 2 * L + 1) + features[1:])
if (l1, l2, L) in self._cg:
for M in range(2 * L + 1):
for m1, m2, cg in self._cg[(l1, l2, L)][M]:
rho[:, M, ...] += np.einsum(
combination_string, rho1[:, m1, ...], rho2[:, m2, ...] * cg
)
return rho
def couple(self, decoupled, iterate=0):
"""
Goes from an uncoupled product basis to a coupled basis.
A (2l1+1)x(2l2+1) matrix transforming like the outer product of
Y^m1_l1 Y^m2_l2 can be rewritten as a list of coupled vectors,
each transforming like a Y^L irrep.
The process can be iterated: a D dimensional array that is the product
of D Y^m_l can be turned into a set of multiple terms transforming as
a single Y^M_L.
decoupled: array or dict
(...)x(2l1+1)x(2l2+1) array containing coefficients that
transform like products of Y^l1 and Y^l2 harmonics. can also
be called on a array of higher dimensionality, in which case
the result will contain matrices of entries.
If the further index also correspond to spherical harmonics,
the process can be iterated, and couple() can be called onto
its output, in which case the decoupling is applied to each
entry.
iterate: int
calls couple iteratively the given number of times. equivalent to
couple(couple(... couple(decoupled)))
Returns:
--------
A dictionary tracking the nature of the coupled objects. When called one
time, it returns a dictionary containing (l1, l2) [the coefficients of the
parent Ylm] which in turns is a dictionary of coupled terms, in the form
L:(...)x(2L+1)x(...) array. When called multiple times, it applies the
coupling to each term, and keeps track of the additional l terms, so that
e.g. when called with iterate=1 the return dictionary contains terms of
the form
(l3,l4,l1,l2) : { L: array }
Note that this coupling scheme is different from the NICE-coupling where
angular momenta are coupled from left to right as (((l1 l2) l3) l4)... )
Thus results may differ when combining more than two angular channels.
"""
coupled = {}
# when called on a matrix, turns it into a dict form to which we can
# apply the generic algorithm
if not isinstance(decoupled, dict):
l2 = (decoupled.shape[-1] - 1) // 2
decoupled = {(): {l2: decoupled}}
# runs over the tuple of (partly) decoupled terms
for ltuple, lcomponents in decoupled.items():
# each is a list of L terms
for lc in lcomponents.keys():
# this is the actual matrix-valued coupled term,
# of shape (..., 2l1+1, 2l2+1), transforming as Y^m1_l1 Y^m2_l2
dec_term = lcomponents[lc]
l1 = (dec_term.shape[-2] - 1) // 2
l2 = (dec_term.shape[-1] - 1) // 2
# there is a certain redundance: the L value is also the last entry
# in ltuple
if lc != l2:
raise ValueError(
"Inconsistent shape for coupled angular momentum block."
)
# in the new coupled term, prepend (l1,l2) to the existing label
coupled[(l1, l2) + ltuple] = {}
for L in range(
max(l1, l2) - min(l1, l2), min(self._l_max, (l1 + l2)) + 1
):
Lterm = np.zeros(shape=dec_term.shape[:-2] + (2 * L + 1,))
for M in range(2 * L + 1):
for m1, m2, cg in self._cg[(l1, l2, L)][M]:
Lterm[..., M] += dec_term[..., m1, m2] * cg
coupled[(l1, l2) + ltuple][L] = Lterm
# repeat if required
if iterate > 0:
coupled = self.couple(coupled, iterate - 1)
return coupled
def decouple(self, coupled, iterate=0):
"""
Undoes the transformation enacted by couple.
"""
decoupled = {}
# applies the decoupling to each entry in the dictionary
for ltuple, lcomponents in coupled.items():
# the initial pair in the key indicates the decoupled terms that generated
# the L entries
l1, l2 = ltuple[:2]
# shape of the coupled matrix (last entry is the 2L+1 M terms)
shape = next(iter(lcomponents.values())).shape[:-1]
dec_term = np.zeros(
shape
+ (
2 * l1 + 1,
2 * l2 + 1,
)
)
for L in range(max(l1, l2) - min(l1, l2), min(self._l_max, (l1 + l2)) + 1):
# supports missing L components, e.g. if they are zero because of symmetry
if not L in lcomponents:
continue
for M in range(2 * L + 1):
for m1, m2, cg in self._cg[(l1, l2, L)][M]:
dec_term[..., m1, m2] += cg * lcomponents[L][..., M]
# stores the result with a key that drops the l's we have just decoupled
if not ltuple[2:] in decoupled:
decoupled[ltuple[2:]] = {}
decoupled[ltuple[2:]][l2] = dec_term
# rinse, repeat
if iterate > 0:
decoupled = self.decouple(decoupled, iterate - 1)
# if we got a fully decoupled state, just return an array
if ltuple[2:] == ():
decoupled = next(iter(decoupled[()].values()))
return decoupled
def _real2complex(L):
"""
Computes a matrix that can be used to convert from real to complex-valued
spherical harmonics(coefficients) of order L.
It's meant to be applied to the left, ``real2complex @ [-L..L]``.
"""
result = np.zeros((2 * L + 1, 2 * L + 1), dtype=np.complex128)
I_SQRT_2 = 1.0 / np.sqrt(2)
for m in range(-L, L + 1):
if m < 0:
result[L - m, L + m] = I_SQRT_2 * 1j * (-1) ** m
result[L + m, L + m] = -I_SQRT_2 * 1j
if m == 0:
result[L, L] = 1.0
if m > 0:
result[L + m, L + m] = I_SQRT_2 * (-1) ** m
result[L - m, L + m] = I_SQRT_2
return result
def _complex_clebsch_gordan_matrix(l1, l2, L):
if np.abs(l1 - l2) > L or np.abs(l1 + l2) < L:
return np.zeros((2 * l1 + 1, 2 * l2 + 1, 2 * L + 1), dtype=np.double)
else:
return wigners.clebsch_gordan_array(l1, l2, L)
def _real_clebsch_gordan_matrix(l1, l2, L):
complex_cg = _complex_clebsch_gordan_matrix(l1, l2, L)
real_cg = (_real2complex(l1).T @ complex_cg.reshape(2 * l1 + 1, -1)).reshape(
complex_cg.shape
)
real_cg = real_cg.swapaxes(0, 1)
real_cg = (_real2complex(l2).T @ real_cg.reshape(2 * l2 + 1, -1)).reshape(
real_cg.shape
)
real_cg = real_cg.swapaxes(0, 1)
real_cg = real_cg @ np.conjugate(_real2complex(L))
if (l1 + l2 + L) % 2 == 0:
rcg = np.real(real_cg)
else:
rcg = np.imag(real_cg)
return rcg