Coverage for dynasor/modes/tools.py: 99%

88 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-22 07:00 +0000

1import numbers 

2from fractions import Fraction 

3from typing import Optional, Union 

4import numba 

5import numpy as np 

6from numpy.typing import NDArray 

7 

8 

9# Mode projections 

10def mode_coordinates(X: NDArray[complex], u: NDArray[float]) -> NDArray[complex]: 

11 r"""Projects atomic displacements onto modes, :math:`Q = X u`. 

12 

13 Parameters 

14 ---------- 

15 X 

16 Modes with shape ``(..., N, 3)``, where the leading dimensions define the output shape. 

17 u 

18 Atomic displacements in Å. 

19 

20 Returns 

21 ------- 

22 The mode coordinates in Å√dmu, with shape ``X.shape[:-2]``. 

23 """ 

24 return np.einsum('...na,na->...', X, u, optimize=True) 

25 

26 

27def mode_momenta(X: NDArray[complex], v: NDArray[float]) -> NDArray[complex]: 

28 r"""Projects atomic velocities onto modes, :math:`P = X^* v`. 

29 

30 Parameters 

31 ---------- 

32 X 

33 Modes with shape ``(..., N, 3)``, where the leading dimensions define the output shape. 

34 v 

35 Atomic velocities in Å/fs. 

36 

37 Returns 

38 ------- 

39 The mode momenta in √eV, with shape ``X.shape[:-2]``. 

40 """ 

41 return np.einsum('...na,na->...', X.conj(), v, optimize=True) 

42 

43 

44def mode_forces( 

45 X: NDArray[complex], 

46 f: NDArray[float], 

47 masses: NDArray[float], 

48) -> NDArray[complex]: 

49 r"""Projects atomic forces onto modes, :math:`F = X^* f / m`. 

50 

51 Parameters 

52 ---------- 

53 X 

54 Modes with shape ``(..., N, 3)``, where the leading dimensions define the output shape. 

55 f 

56 Atomic forces in eV/Å. 

57 masses 

58 Atomic masses in dmu, one per atom. 

59 

60 Returns 

61 ------- 

62 The mode forces in eV/Å√dmu, with shape ``X.shape[:-2]``. 

63 """ 

64 # The mass divides the forces rather than the modes, since f is the smaller array. 

65 return np.einsum('...na,na->...', X.conj(), f / masses[:, None], optimize=True) 

66 

67 

68# Linalg 

69def trace(A): 

70 return A[0, 0] + A[1, 1] + A[2, 2] 

71 

72 

73def det(A): 

74 if len(A) == 2: 

75 return A[0, 0] * A[1, 1] - A[0, 1] * A[1, 0] 

76 d = 0 

77 for i, B in enumerate(A[0]): 

78 minor = np.hstack([A[1:, :i], A[1:, i+1:]]) 

79 d += (-1)**i * B * det(minor) 

80 return d 

81 

82 

83def inv( 

84 A: Union[Fraction, NDArray[float]], 

85 as_fraction: Optional[bool] = True, 

86) -> Union[Fraction, NDArray[float]]: 

87 """ 

88 Inverts the A matrix. 

89 

90 Parameters 

91 ---------- 

92 A 

93 Array to be inverted. 

94 as_fraction 

95 Boolean which determines if inverted matrix is returned 

96 as :class:`Fraction` matrix or real matrix. 

97 """ 

98 

99 detx2 = det(A) * 2 

100 

101 rest = (trace(A)**2 - trace(A @ A)) * np.diag([1, 1, 1]) - 2 * A * trace(A) + 2 * A @ A 

102 

103 if detx2 < 0: 

104 detx2 = -detx2 

105 rest = -rest 

106 

107 A_inv = np.array([Fraction(n, detx2) for n in rest.flat]).reshape(3, 3) 

108 

109 assert np.all(A @ A_inv == np.eye(len(A))) 

110 

111 # return either real matrix or as fraction matrix 

112 A_inv_real = np.reshape([float(x) for x in A_inv.flat], A_inv.shape) 

113 assert np.allclose(A_inv_real, np.linalg.inv(A)) 

114 

115 if as_fraction: 

116 return A_inv 

117 else: 

118 return A_inv_real 

119 

120 

121def symmetrize_eigenvectors( 

122 eigenvectors: NDArray[float], 

123 cell: Optional[NDArray[float]] = None, 

124 max_iter: Optional[int] = 1000, 

125 method: Optional[str] = 'varimax', 

126 tol: float = 1e-12, 

127) -> NDArray[float]: 

128 """Takes a set of vectors and tries to make them nice 

129 

130 Parameters 

131 ---------- 

132 eigenvectors 

133 If there are n degenerate eigenvectors and m atoms in the basis the 

134 array should be `(n,m,3)`. 

135 cell 

136 If default `None` nothing is done to the cartesian directions but a cell 

137 can be provided so the directions are in scaled coordinates instead. 

138 max_iter 

139 Maximum number of iterations in the symmetrization procedure (safety cap). 

140 method 

141 Can be `'varimax'` or `'quartimax'` or a parameter between `0: quartimax` and 

142 `1: varimax`. Depending on the choice one obtains, e.g., Equamax, Parsimax, etc. 

143 tol 

144 Convergence tolerance passed to :func:`factor_analysis`; iteration stops 

145 early once the rotation matrix has converged. Set `tol=0` to always run 

146 `max_iter` iterations. 

147 

148 """ 

149 

150 if cell is None: 

151 cell = np.eye(3) 

152 

153 # s = band, i = basis, a = axis 

154 eigenvectors = np.einsum('sia,ab->sib', eigenvectors, np.linalg.inv(cell)) 

155 

156 components = eigenvectors.reshape(len(eigenvectors), -1).T 

157 

158 rotation_matrix = factor_analysis(components, iterations=max_iter, method=method, tol=tol) 

159 

160 new_eigenvectors = np.dot(components, rotation_matrix).T 

161 

162 new_eigenvectors = new_eigenvectors.reshape(len(new_eigenvectors), -1, 3) 

163 

164 new_eigenvectors = np.einsum('sia,ab->sib', new_eigenvectors, cell) 

165 

166 return new_eigenvectors 

167 

168 

169def factor_analysis( 

170 L: NDArray[float], 

171 iterations: Optional[int] = 1000, 

172 method: Optional[str] = 'varimax', 

173 tol: float = 1e-12, 

174) -> NDArray[float]: 

175 """Performs factor analysis on `L` finding rotation matrix `R` such that `L @ R = L'` is simple. 

176 

177 In the future consider using the scikit learn methods directly but beware 

178 the changes need to accommodate complex numbers. 

179 

180 Parameters 

181 ---------- 

182 L 

183 Matrix whose columns are rotated. 

184 iterations 

185 Maximum number of iterations (safety cap). 

186 method 

187 Can be `'varimax'` or `'quartimax'` or a parameter between `0: quartimax` 

188 and `1: varimax`. 

189 tol 

190 Convergence tolerance: iteration stops once the rotation matrix `R` 

191 stops changing, i.e. once ``max|R - R_prev| < tol`` between two 

192 successive sweeps, with `iterations` as a safety cap. 

193 

194 References: 

195 * *Sparse Modeling of Landmark and Texture Variability using the Orthomax Criterion* 

196 Mikkel B. Stegmann, Karl Sjöstrand, Rasmus Larsen 

197 http://www2.imm.dtu.dk/pubdb/edoc/imm4041.pdf 

198 

199 * http://www.cs.ucl.ac.uk/staff/d.barber/brml 

200 

201 * https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.FactorAnalysis.html # noqa 

202 

203 * https://stats.stackexchange.com/questions/185216/factor-rotation-methods-varimax-quartimax-oblimin-etc-what-do-the-names # noqa 

204 

205 """ 

206 

207 nrow, ncol = L.shape 

208 R = np.eye(ncol) 

209 

210 if method == 'varimax': 

211 gamma = 1 

212 elif method == 'quartimax': 

213 gamma = 0 

214 else: 

215 gamma = method 

216 

217 for _ in range(iterations): 

218 LR = np.dot(L, R) 

219 grad = LR * (np.abs(LR)**2 - gamma * np.mean(np.abs(LR)**2, axis=0)) 

220 G = L.T.conj() @ grad 

221 u, s, vh = np.linalg.svd(G) 

222 R_new = u @ vh 

223 change = np.abs(R_new - R).max() 

224 R = R_new 

225 if change < tol: 

226 break 

227 

228 return R 

229 

230 

231def group_eigvals( 

232 vals: NDArray[float], 

233 tol: Optional[float] = 1e-6, 

234) -> list[list[int]]: 

235 assert sorted(vals) == list(vals), vals 

236 

237 groups = [[0]] 

238 for i in range(1, len(vals)): 

239 if np.abs(vals[i] - vals[i-1]) < tol: 

240 groups[-1].append(i) 

241 else: 

242 groups.append([i]) 

243 return groups 

244 

245 

246# misc 

247def as_fraction(not_fraction): 

248 if isinstance(not_fraction, numbers.Number): 

249 return Fraction(not_fraction) 

250 

251 if isinstance(not_fraction, np.ndarray): 

252 arr = np.array([Fraction(n) for n in not_fraction.flat]) 

253 return arr.reshape(not_fraction.shape) 

254 

255 if isinstance(not_fraction, tuple): 

256 return tuple(Fraction(n) for n in not_fraction) 

257 

258 if isinstance(not_fraction, list): 258 ↛ exitline 258 didn't return from function 'as_fraction' because the condition on line 258 was always true

259 arr = [Fraction(n) for n in not_fraction] 

260 return arr 

261 

262 

263@numba.njit # pragma: no cover 

264def get_dynamical_matrix(fc, offsets, indices, q): 

265 

266 n = indices.max() + 1 

267 N = len(fc) 

268 D = np.zeros(shape=(n, n, 3, 3), dtype=np.complex128) 

269 for ia in range(n): 

270 for a in range(N): 

271 if ia != indices[a]: 

272 continue 

273 na = offsets[a] 

274 for b in range(N): 

275 ib = indices[b] 

276 nb = offsets[b] 

277 dn = (nb - na).astype(np.float64) 

278 D[ia, ib] += fc[a, b] * np.exp(2j*np.pi * np.dot(dn, q)) 

279 break 

280 return D 

281 

282 

283# For debug, this is a slower but perhaps more accurate variant, if the fc 

284# obeys translational invariance this should give the same result 

285# @numba.njit 

286# def get_dynamical_matrix_full(fc, offsets, indices, q): 

287# 

288# n = indices.max() + 1 

289# N = len(fc) 

290# D = np.zeros(shape=(n, n, 3, 3), dtype=np.complex128) 

291# for I in range(N): 

292# i = indices[I] 

293# m = offsets[I] 

294# for J in range(N): 

295# j = indices[J] 

296# n = offsets[J] 

297# 

298# off = (n - m).astype(np.float64) 

299# 

300# phase = np.exp(1j * 2*np.pi * np.dot(off, q)) 

301# 

302# D[i, j] += fc[I, J] * phase 

303# 

304# D /= (N / D.shape[0]) 

305# 

306# return D 

307 

308 

309def make_table(M): 

310 rows = [] 

311 for r in M: 

312 rows.append(''.join(f'{e:<20.2f}' for e in r)) 

313 return '\n'.join(rows)