Coverage for dynasor/modes/mode_projector.py: 100%

211 statements  

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

1from __future__ import annotations 

2from typing import Optional 

3 

4import numpy as np 

5import warnings 

6import pickle 

7 

8from ase.calculators.singlepoint import SinglePointCalculator 

9from ase.units import fs 

10from ase import Atoms 

11from numpy.typing import NDArray 

12 

13from dynasor.units import radians_per_fs_to_THz 

14from dynasor.tools.structures import get_displacements 

15from .tools import (get_dynamical_matrix, group_eigvals, mode_coordinates, mode_forces, 

16 mode_momenta, symmetrize_eigenvectors) 

17from .qpoint import QPoint 

18from .atoms import Prim, Supercell 

19from ..qpoints.tools import get_commensurate_lattice_points 

20 

21 

22class ModeProjector: 

23 """ 

24 The :class:`ModeProjector` maps between real atomic displacements `u` and 

25 complex mode coordinates `Q`. 

26 

27 Some special python methods are implemented. The `__str__` and `__repr__` 

28 provides useful info. :class:`QPoint <dynasor.modes.qpoint.QPoint>` objects are 

29 representations of a single q-point and associated information and can be accessed either by 

30 call providing a reduced wavevector 

31 

32 >>> mp((1/2, 0, 0)) # doctest: +SKIP 

33 

34 or by index corresponding to reduced q-point accessible from 

35 :attr:`~ModeProjector.q_reduced` 

36 

37 >>> mp[2] # doctest: +SKIP 

38 

39 In addition to mode coordinates `Q` the class can also map the atomic 

40 velocities `v` to mode momenta `P` as well as atomic forces `f` to mode 

41 forces `F`. The class can also map back. This mapping is done using 

42 getters and setters. Internally only Q, P and F are stored 

43 

44 >>> Q = mp.get_Q() # doctest: +SKIP 

45 >>> mp.set_u(u) # doctest: +SKIP 

46 

47 In addition, the forces corresponding to the harmonic forces can be 

48 accessed by :meth:`~ModeProjector.get_f_harmonic()` and 

49 :meth:`~ModeProjector.get_F_harmonic()`. For ASE Atoms objects the 

50 displacements etc. can be updated and applied by 

51 

52 >>> mp.update_from_atoms(atoms) # doctest: +SKIP 

53 >>> atoms = mp.get_atoms(harmonic_forces=False) # doctest: +SKIP 

54 

55 The shapes for each property uses the following variables 

56 

57 * `N`: Number of atoms in supercell 

58 * `Nu`: unit cells (`N/Np`) 

59 * `Np`: primitive basis atoms (`N/Nu`) 

60 * `Nb`: bands (`Np*3`) 

61 * `Nq`: q-points (`Nu`) 

62 

63 Please consult the documentation or the specific getters and setters to see 

64 the exact transformations used. 

65 

66 Units 

67 ^^^^^ 

68 The internal units in dynasor are Å, fs and eV. All frequencies are angular 

69 (a.k.a. the "physicist's convention" with 2π included). These are the units 

70 dynasor will expect and return. In, e.g., print functions conventional units 

71 such fs, Å, THz, Da, meV are commonly used. 

72 

73 **Mass:** 

74 The internal unit choice (eV, Å, fs) means that the mass unit is not Dalton 

75 but rather 0.009648533290731906 Da. 

76 We refer to this unit as the "dynasor mass unit" (dmu), 

77 i.e., 1 Da = 103.64269572045423 dmu. 

78 As a user you will only see this unit in the output of :class:`ModeProjector` objects. 

79 Masses provided via, e.g., ASE Atoms objects are converted internally. 

80 

81 **Waves:** 

82 dynasor reports and expects spatial (angular) frequencies in rad/Å and temporal (angular) 

83 frequencies in rad/fs. This follows the often-used convention in physics to include the 

84 factor of 2π in the wave vectors. For instance the wavelength is given by λ=2π/q. 

85 

86 **Mode amplitudes:** 

87 Mode amplitudes are reported in Å√dmu = fs√eV. 

88 

89 **Velocities:** 

90 For modes the momenta are reported in Å√dmu/fs or just √eV 

91 while atomic velocities are reported in Å/fs. 

92 

93 **Mode forces:** 

94 The force is defined as the derivative of the momenta with respect to time 

95 so the unit used when reporting mode forces is Å√dmu/fs² (or √eV/fs). 

96 

97 Internal arrays 

98 ^^^^^^^^^^^^^^^ 

99 For the curious, the internal data arrays are 

100 

101 * :attr:`primitive`, :attr:`supercell`, :attr:`force_constants` (input) 

102 * :attr:`_q`, :attr:`q_minus` (reduced q-points and which q-points are related by inversion) 

103 * :attr:`_D`, :attr:`_w2`, :attr:`_W` 

104 (dynamical matrices, frequencies (ev/Ų/Da), polarization vectors) 

105 * :attr:`_X` (eigenmodes which are mass weighted "polarization vectors" in the supercell) 

106 """ 

107 def __init__(self, primitive: Atoms, supercell: Atoms, force_constants: NDArray[float]): 

108 """The mode projector is initialized by a primitive cell and a 

109 supercell as well as harmonic force constants. 

110 

111 The force constants are assumed to be in units of eV/Ų as returned 

112 from phonopy. Be careful about the permutations when working with force 

113 constants and atoms object from different codes. 

114 

115 Parameters 

116 ---------- 

117 primitive 

118 Primitive cell. Note that the masses are stored internally as 

119 Dalton in ASE but will be converted to the internal dynasor 

120 mass unit (dmu). 

121 supercell 

122 Ideal supercell corresponding to the force constants. 

123 force_constants 

124 Force constants for the supercell in eV/Ų as a `(N, N, 3, 3)` array 

125 where `N` is `len(supercell)`. 

126 """ 

127 if len(primitive) == len(supercell): 

128 warnings.warn('Primitive and supercell have the same size') 

129 elif len(primitive) > len(supercell): 

130 raise ValueError('Primitive cell larger than supercell') 

131 elif not (len(supercell) / len(primitive)).is_integer(): 

132 raise ValueError('supercell size is not multiple of primitive size') 

133 

134 if len(supercell) != len(force_constants): 

135 raise ValueError('force constants shape is not compatible with supercell size') 

136 

137 if force_constants.shape != (len(supercell), len(supercell), 3, 3): 

138 raise ValueError('force constants shape should be (N, N, 3, 3)') 

139 

140 self.primitive = Prim(primitive) 

141 self.supercell = Supercell(supercell, primitive) 

142 self.force_constants = force_constants 

143 

144 # Find q-points in reduced primitive cell coordinates 

145 q_integer = get_commensurate_lattice_points(self.supercell.P.T) 

146 q_reduced = np.dot(q_integer, self.supercell.P_inv.T) 

147 self._q = np.array(sorted(tuple(q) for q in q_reduced)) 

148 

149 # The equivalent q-point corresponding to -q 

150 self._q_minus = [[tuple(q) for q in self._q].index(tuple((-q) % 1)) for q in self._q] 

151 

152 # Construct dynamical matrix and diagonalize at each q-point 

153 self._D, self._w2, self._W = [], [], [] 

154 for qi, q in enumerate(self._q): 

155 

156 D = get_dynamical_matrix( 

157 self.force_constants, self.supercell.offsets, self.supercell.indices, 

158 q.astype(np.float64)) 

159 

160 if qi == self.q_minus[qi]: 

161 assert np.allclose(D.imag, 0) 

162 D = D.real 

163 

164 D = np.einsum('ijab,i,j->ijab', 

165 D, self.primitive.masses**-0.5, self.primitive.masses**-0.5) 

166 D_matrix = D.transpose(0, 2, 1, 3).reshape(-1, self.primitive.n_atoms * 3) 

167 assert np.allclose(D_matrix, D_matrix.T.conj()) 

168 w2, W = np.linalg.eigh(D_matrix) 

169 W = W.T.reshape(-1, self.primitive.n_atoms, 3) 

170 

171 self._D.append(D) 

172 self._w2.append(w2) 

173 self._W.append(W) 

174 

175 self._D = np.array(self._D) 

176 self._w2 = np.array(self._w2) 

177 self._W = np.array(self._W) 

178 

179 # Post check basic symmetries, group eigenvalues and try to make degenerate modes nicer 

180 for q, q_minus in enumerate(self.q_minus): 

181 q_minus = self.q_minus[q] 

182 

183 assert np.allclose(self._D[q], self._D[q_minus].conj()) 

184 assert np.allclose(self._w2[q], self._w2[q_minus]) 

185 

186 # tolerances for grouping and sorting eigenvalues and eigenvectors 

187 group_decimals = 12 

188 tolerance = 10**(-group_decimals) 

189 # Keep sorting coarser than the convergence tolerance so tiny SVD 

190 # roundoff cannot change the order of equivalent modes. 

191 sort_decimals = 10 

192 

193 for group in group_eigvals(self._w2[q], tolerance**0.5): 

194 W = symmetrize_eigenvectors(self._W[q, group]) 

195 

196 # Try to order them 

197 W_sort = W.copy().transpose(0, 2, 1).reshape(len(W), -1) 

198 # abs is because we want to consider the magnitude 

199 # - (minus) basically reverts the sort order to place largest first 

200 # T is just because how lexsort works, we want to consider each 

201 # atom and direction as a key for the bands 

202 # -1 is because we want to make the x-direction of the first 

203 # atom the most significant key 

204 # At the end the first band should have the largest magnitude 

205 # for the first atom in x 

206 # Suppress numerical noise in the mode signatures before sorting. 

207 # Keep this precision deliberately below the factor-analysis 

208 # convergence noise so tiny SVD/BLAS differences cannot permute 

209 # otherwise equivalent degenerate modes. 

210 argsort = np.lexsort(np.round(-np.abs(W_sort).T[::-1], sort_decimals)) 

211 self._W[q, group] = W[argsort] 

212 

213 self._W[q_minus] = self._W[q].conj() 

214 

215 # Construct supercell projection matrix: q_ks = X_ksna u_na. 

216 # phase and mass depend only on (q, index), so compute once and 

217 # broadcast against a gather of self._W. 

218 indices = self.supercell.indices 

219 

220 # self._q has dtype=object (Fraction); cast to float for the phase 

221 q_float = self._q.astype(np.float64) 

222 phase = np.exp(-1j * 2*np.pi * q_float @ self.supercell.offsets.T) # (n_q, n_super) 

223 mass_sqrt = self.primitive.masses[indices]**0.5 # (n_super,) 

224 # Build _X in place to avoid extra full-size temporaries (halves peak 

225 # memory). astype pins the dtype (no-op unless _W is real, e.g. Gamma). 

226 self._X = self._W[:, :, indices, :].astype(np.complex128, copy=False) 

227 np.conjugate(self._X, out=self._X) 

228 self._X *= phase[:, None, :, None] 

229 self._X *= mass_sqrt[None, None, :, None] 

230 self._X /= (self.supercell.n_atoms / self.primitive.n_atoms)**0.5 

231 

232 # Init arrays to hold Q, P and F 

233 self._Q = np.zeros((len(self._q), self.primitive.n_atoms*3), dtype=np.complex128) 

234 self._P = np.zeros_like(self._Q) 

235 self._F = np.zeros_like(self._Q) 

236 

237 def __str__(self): 

238 strings = ['### ModeProjector ###'] 

239 strings += [f'{self.supercell}'] 

240 strings += [f'{self.primitive}'] 

241 string = '\n'.join(strings) 

242 

243 # ASCII DOS! 

244 width = 80 

245 height = 24 

246 dos = np.full((height, width), ' ') 

247 

248 THz = self.omegas * radians_per_fs_to_THz 

249 

250 hist, bins = np.histogram(THz.flat, bins=width) 

251 

252 for i, h in enumerate(hist): 

253 dos[np.round(h * (height - 1) / hist.max()).astype(int), i] = '+' # '·' or 'x' 

254 

255 dos = dos[::-1] 

256 dos[-1, dos[-1] == ' '] = '-' 

257 dos = '\n'.join([''.join(d) for d in dos]) 

258 

259 string += f'\n{dos}' 

260 string += f'\n|{THz.min():<10.2f} THz' + ' '*(width - 26) + f'{THz.max():>10.2f}|' 

261 

262 return string 

263 

264 def __repr__(self): 

265 return str(self) 

266 

267 def __getitem__(self, q) -> QPoint: 

268 """Returns the q-point object based on its index""" 

269 if q < 0 or q >= len(self._q): 

270 raise IndexError 

271 return QPoint(q, self) 

272 

273 def __call__(self, qpoint) -> QPoint: 

274 """Tries to find a matching q-point based on reduced coordinate""" 

275 qpoint = np.array(qpoint).astype(np.float64) % 1 

276 for q, qpoint2 in enumerate(np.array(self._q).astype(np.float64)): 

277 if np.allclose(qpoint, qpoint2): 

278 return QPoint(q, self) 

279 raise ValueError('qpoint not compatible, check mp.q_reduced') 

280 

281 # Getters ans setters for internal mutable arrays 

282 def get_Q(self) -> NDArray[complex]: 

283 """Return the mode coordinates as a ``(Nq, Nb)`` complex array in Å√dmu.""" 

284 return self._Q.copy() 

285 

286 def get_P(self) -> NDArray[complex]: 

287 """Return the mode momenta as a ``(Nq, Nb)`` complex array in √eV.""" 

288 return self._P.copy() 

289 

290 def get_F(self) -> NDArray[complex]: 

291 """Return the mode forces as a ``(Nq, Nb)`` complex array in eV/Å√dmu.""" 

292 return self._F.copy() 

293 

294 def get_F_harmonic(self) -> NDArray[complex]: 

295 r"""Return the harmonic mode forces as a ``(Nq, Nb)`` complex array in eV/Å√dmu. 

296 

297 Computed as :math:`-\omega^2 Q^*`. The conjugate follows the convention 

298 :math:`F = X^* f / m` used by :meth:`set_f`, in which :math:`F` is the force 

299 conjugate to :math:`Q`, i.e. :math:`-\partial V/\partial Q`. Note that 

300 :math:`Q` and :math:`Q^*` coincide only at self-conjugate q-points. 

301 """ 

302 return -self._w2 * self.get_Q().conj() 

303 

304 def set_Q(self, Q: NDArray[complex]) -> None: 

305 """Sets the internal mode coordinates :math:`Q`. 

306 

307 The function ensures :math:`Q(-q)=Q^*(q)`. 

308 """ 

309 # This ensures that stuff like mp.set_Q(0) works while not updating the 

310 # array until the assert 

311 Q_new = self.get_Q() 

312 Q_new[:] = Q 

313 if not np.allclose(np.conjugate(Q_new), Q_new[self.q_minus]): 

314 raise ValueError('Supplied Q does not fulfill Q(-q) = Q(q)*') 

315 self._Q[:] = Q_new 

316 

317 def set_P(self, P: NDArray[complex]) -> None: 

318 """Sets the internal mode momenta :math:`P`. 

319 

320 The function ensures :math:`P(-q)=P^*(q)`. 

321 """ 

322 P_new = self.get_P() 

323 P_new[:] = P 

324 if not np.allclose(np.conjugate(P_new), P_new[self.q_minus]): 

325 raise ValueError('Supplied P does not fulfill P(-q) = P(q)*') 

326 self._P[:] = P_new 

327 

328 def set_F(self, F: NDArray[complex]) -> None: 

329 """Sets the internal mode forces :math:`F`. 

330 

331 The function ensures :math:`F(-q)=F^*(q)`. 

332 """ 

333 F_new = self.get_F() 

334 F_new[:] = F 

335 if not np.allclose(np.conjugate(F_new), F_new[self.q_minus]): 

336 raise ValueError('Supplied F does not fulfill F(-q) = F(q)*') 

337 self._F[:] = F_new 

338 

339 def get_u(self) -> NDArray[float]: 

340 """Return the atomic displacements as a ``(N, 3)`` array in Å.""" 

341 u = np.einsum('ksna,ks,n->na', self._X.conj(), self._Q, 1 / self.supercell.masses) 

342 assert np.allclose(u.imag, 0) 

343 return u.real 

344 

345 def get_v(self) -> NDArray[float]: 

346 """Return the atomic velocities as a ``(N, 3)`` array in Å/fs.""" 

347 v = np.einsum('ksna,ks,n->na', self._X, self._P, 1 / self.supercell.masses) 

348 assert np.allclose(v.imag, 0) 

349 return v.real 

350 

351 def get_f(self) -> NDArray[float]: 

352 """Return the atomic forces as a ``(N, 3)`` array in eV/Å.""" 

353 f = np.einsum('ksna,ks->na', self._X, self._F) 

354 assert np.allclose(f.imag, 0) 

355 return f.real 

356 

357 def get_f_harmonic(self) -> NDArray[float]: 

358 """Return the harmonic atomic forces for the current displacements 

359 as a ``(N, 3)`` array in eV/Å. 

360 """ 

361 F_harmonic = self.get_F_harmonic() 

362 f_harmonic = np.einsum('ksna,ks->na', self._X, F_harmonic) 

363 assert np.allclose(f_harmonic.imag, 0) 

364 return f_harmonic.real 

365 

366 def set_u(self, u: NDArray[float]) -> None: 

367 """Sets the internal mode coordinates :math:`Q` given the atomic displacements :math:`u`. 

368 

369 .. math:: 

370 

371 Q = X u 

372 

373 Parameters 

374 ---------- 

375 u 

376 The atomic displacements in Å. 

377 """ 

378 Q = mode_coordinates(self._X, u) 

379 self.set_Q(Q) 

380 

381 def set_v(self, v: NDArray[float]) -> None: 

382 """Sets the internal mode momenta :math:`P` given the atomic velocities :math:`v`. 

383 

384 .. math:: 

385 

386 P = X^* * v 

387 

388 Parameters 

389 ---------- 

390 v 

391 The atomic velocities in Å/fs. 

392 """ 

393 P = mode_momenta(self._X, v) 

394 self.set_P(P) 

395 

396 def set_f(self, f: NDArray[float]) -> None: 

397 """Sets the internal mode forces :math:`F` given the atomic forces :math:`f`. 

398 

399 .. math:: 

400 

401 F = X^* * f / m 

402 

403 Parameters 

404 ---------- 

405 f 

406 The atomic forces in eV/Å. 

407 """ 

408 F = mode_forces(self._X, f, self.supercell.masses) 

409 self.set_F(F) 

410 

411 # Convenience functions to handle ASE Atoms objects 

412 def get_atoms(self, harmonic_forces: Optional[bool] = False) -> Atoms: 

413 r"""Returns ASE :class:`~ase.Atoms` object with displacement, 

414 velocities, forces, and harmonic energies. 

415 

416 Parameters 

417 ---------- 

418 harmonic_forces 

419 Whether the forces should be taken from the internal `F` or via `-\omega^2 Q`. 

420 """ 

421 atoms = self.supercell.to_ase() 

422 atoms.positions += self.get_u() 

423 atoms.set_velocities(self.get_v() / fs) 

424 E = self.potential_energies.sum() 

425 f = self.get_f_harmonic() if harmonic_forces else self.get_f() 

426 

427 atoms.calc = SinglePointCalculator( 

428 energy=E, forces=f, stress=None, magmoms=None, atoms=atoms) 

429 

430 return atoms 

431 

432 def update_from_atoms(self, atoms: Atoms) -> None: 

433 """Updates the :class:`ModeProjector` objects with displacements, velocities, 

434 and forces from an ASE :class:`~ase.Atoms` object. 

435 

436 Checks for an attached calculator in the first place and next for a forces array. 

437 

438 If no data sets corresponding array to zeros. 

439 

440 The velocities are converted to dynasor units internally. Note that 

441 :attr:`atoms`'s masses are not used; masses are fixed from the 

442 ``primitive``/``supercell`` structures given at construction, since the 

443 dynamical matrix and mode eigenvectors already depend on them. 

444 """ 

445 

446 u = get_displacements(atoms, self.supercell) 

447 if np.max(np.abs(u)) > 2.0: 

448 warnings.warn('Displacements larger than 2Å. Is the atoms object permuted?') 

449 self.set_u(u) 

450 self.set_v(atoms.get_velocities() * fs) 

451 try: 

452 self.set_f(atoms.get_forces()) 

453 except RuntimeError: 

454 if 'forces' in atoms.arrays: 

455 self.set_f(atoms.arrays['forces']) 

456 else: 

457 self.set_f(np.zeros_like(atoms.positions)) 

458 

459 # properties 

460 @property 

461 def q_minus(self) -> NDArray[float]: 

462 """The index of the corresponding counter-propagating mode (:math:`-q`).""" 

463 return self._q_minus.copy() 

464 

465 @property 

466 def q_reduced(self) -> NDArray[float]: 

467 """The q-points in reduced coordinates. 

468 

469 For example a zone boundary mode would be (1/2, 0, 0) 

470 """ 

471 return self._q.astype(float) 

472 

473 @property 

474 def q_cartesian(self) -> NDArray[float]: 

475 """The q-points in cartesian coordinates with unit of rad/Å (2π included).""" 

476 return 2 * np.pi * self.q_reduced @ self.primitive.inv_cell 

477 

478 @property 

479 def omegas(self) -> NDArray[float]: 

480 """The frequencies of each mode in rad/fs. 

481 

482 Following convention, negative values indicate imaginary frequencies. 

483 """ 

484 return np.sign(self._w2) * np.sqrt(np.abs(self._w2)) 

485 

486 @property 

487 def polarizations(self) -> NDArray[float]: 

488 """The polarization vectors for each mode `(Nq, Nb, Np, 3)`.""" 

489 return self._W 

490 

491 @property 

492 def eigenmodes(self) -> NDArray[float]: 

493 """The eigenmodes in the supercell as `(Nq, Nb, N, 3)`-array 

494 

495 The eigenmodes include the masses such that :math:`Q = X u` 

496 where :math:`u` are the supercell displacements. 

497 """ 

498 return self._X 

499 

500 @property 

501 def potential_energies(self) -> NDArray[float]: 

502 r"""Potential energy per mode as `(Nq, Nb)`-array. 

503 

504 The potential energies are defined as :math:`1/2 \omega^2 Q Q^*` and should equal 

505 :math:`1/2 k_B T` in equilibrium for a harmonic system. 

506 """ 

507 return 1 / 2 * np.abs(self._Q) ** 2 * self._w2 

508 

509 @property 

510 def kinetic_energies(self) -> NDArray[float]: 

511 """Kinetic energy per mode as `(Nq, Nb)`-array. 

512 

513 The kinetic energies are defined as :math:`1/2 P P^*`. Should equal 

514 :math:`1/2 k_B T` in equilibrium. 

515 """ 

516 return 1 / 2 * np.abs(self._P)**2 

517 

518 @property 

519 def virial_energies(self) -> NDArray[complex]: 

520 r"""The virial energies per mode as `(Nq, Nb)`-array. 

521 

522 The virial energies are defined here as :math:`-1/2 Q F`, which should have an 

523 expectation value of :math:`1/2 k_B T` per mode in equilibrium. For a harmonic 

524 system this is simply equal to the potential energy. This means that 

525 the virial energy can be used to monitor the anharmonicity or 

526 define a measure of the potential energy. 

527 

528 Note that while :math:`Q F` is in general complex the virial energy 

529 obeys :math:`E(-\boldsymbol{q}) = E^*(\boldsymbol{q})`, meaning the 

530 imaginary parts cancel between :math:`\boldsymbol{q}` and 

531 :math:`-\boldsymbol{q}` and the total is real. Take the real part to 

532 obtain per-mode energies; this leaves the sum unchanged. 

533 """ 

534 return -1 / 2 * self._Q * self._F 

535 

536 def write(self, file_name: str) -> None: 

537 """Uses pickle to write mode projector to file.""" 

538 with open(file_name, 'wb') as f: 

539 pickle.dump(self, f) 

540 

541 @classmethod 

542 def read(cls, file_name: str) -> ModeProjector: 

543 """Return :class:`ModeProjector` instance from pickle file 

544 that was saved using :func:`~ModeProjector.write`.""" 

545 with open(file_name, 'rb') as f: 

546 mp = pickle.load(f) 

547 return mp